The one-and-done request
You need to verify a user's email address. The client sends the email, the server checks a database, and sends back a boolean. One question, one answer. No continuous data feed. No uploading a massive file in chunks. Just a single request and a single response. This is the most common communication pattern in distributed systems, and in Go it is called a unary RPC.
RPC stands for Remote Procedure Call. It is a way to make a function call on a different machine look like a local function call. Unary means one. One request triggers one response. Under the hood, gRPC uses Protocol Buffers to define the exact shape of the data and HTTP/2 to move it across the network. You write a contract in a .proto file, run a code generator, and get Go structs, methods, and network plumbing for free. The analogy is a standardized form at a government office. You fill out the form exactly as specified, hand it to the clerk, and get a stamped receipt back. The clerk does not call you back later. The transaction finishes in one step.
Goroutines are cheap. Channels are not magic. Protocol Buffers are just a stricter JSON.
How unary RPC actually works
Traditional REST APIs exchange JSON over HTTP/1.1. JSON is human-readable but verbose. HTTP/1.1 opens a new connection for every request or relies on keep-alive heuristics that add latency. gRPC replaces both with binary serialization and HTTP/2 multiplexing. Protocol Buffers compile your schema into compact byte sequences. HTTP/2 allows multiple concurrent streams over a single TCP connection, which cuts handshake overhead and reduces head-of-line blocking.
You do not write the serialization code. You write a .proto file that describes your messages and services. The protoc compiler reads that file and generates Go code. The generated code contains the struct definitions, a client stub, a server interface, and the registration functions. Your job is to implement the server interface and call the client stub. The framework handles the rest.
Trust gofmt. Argue logic, not formatting. The generated code follows strict conventions, and your implementation should too.
The minimal setup
Start with the contract. Protocol Buffers uses a strict syntax to describe messages and services. Field numbers are permanent identifiers. Do not reuse them if you delete a field.
// Define the message shapes the client and server will exchange
syntax = "proto3";
package example;
// Request carries the input data
message CheckRequest {
string username = 1;
}
// Response carries the result
message CheckResponse {
bool available = 1;
}
// Service declares the available remote methods
service AvailabilityService {
rpc Check (CheckRequest) returns (CheckResponse);
}
Run the compiler with the Go plugins. The command generates a _pb.go file containing the structs and an interface named AvailabilityServiceServer. Your server must satisfy that interface.
// Server holds dependencies for the RPC handler
type Server struct {
AvailabilityServiceServer
repo Repository
}
// CheckAvailability handles the unary RPC call
func (s *Server) CheckAvailability(ctx context.Context, req *CheckRequest) (*CheckResponse, error) {
// Validate input before touching any external resources
if req.Username == "" {
return nil, status.Error(codes.InvalidArgument, "username is required")
}
// Simulate a database lookup
available := req.Username != "admin"
// Return the response struct and nil error on success
return &CheckResponse{Available: available}, nil
}
The method signature is fixed by the generated code. It takes a context.Context as the first argument, followed by the request pointer, and returns a response pointer and an error. This matches the standard Go convention for functions that perform I/O. The receiver name s follows the community standard: one or two letters matching the type initial. Do not use this or self.
Register the service before starting the listener. The generated code provides a registration function that binds your implementation to the gRPC server instance.
// Run starts the gRPC server and registers the service
func Run() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
// Create the gRPC server instance
grpcServer := grpc.NewServer()
// Bind the implementation to the generated interface
pb.RegisterAvailabilityServiceServer(grpcServer, &Server{})
// Start accepting connections
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not swallow errors or use panics for control flow.
What happens under the hood
Here is what happens when the client calls Check. The client library serializes the CheckRequest struct into compact binary bytes. It opens an HTTP/2 stream to the server address and writes the bytes. The server receives the stream, deserializes the bytes back into a CheckRequest struct, and calls your CheckAvailability method. Your method runs, returns a CheckResponse and an error. The server serializes the response, sends it back over the same stream, and closes the stream. The client deserializes the response and hands it to your calling code. The whole cycle usually takes a few milliseconds.
The generated code handles the serialization, stream management, and HTTP/2 framing. You only write the business logic inside the handler. This separation keeps your code readable and prevents manual byte manipulation. The interface contract ensures type safety at compile time. If you change a field type in the .proto file and regenerate, the compiler rejects your handler with cannot use s (type *Server) as AvailabilityServiceServer value in argument: *Server does not implement AvailabilityServiceServer (wrong type for method Check). This catches breaking changes before they reach production.
Accept interfaces, return structs. The generated code gives you an interface to implement, but your handler returns concrete struct pointers. This keeps the API flexible while giving callers predictable memory layouts.
A production-ready handler
Real services need cancellation support, proper error codes, and structured logging. The context.Context parameter is not optional. It carries deadlines, cancellation signals, and request-scoped values. Always pass it to downstream calls. Functions that take a context should respect cancellation and deadlines.
// CheckAvailability handles the unary RPC call with proper context propagation
func (s *Server) CheckAvailability(ctx context.Context, req *CheckRequest) (*CheckResponse, error) {
// Respect client cancellation to avoid wasting database connections
select {
case <-ctx.Done():
return nil, status.Error(codes.Canceled, ctx.Err().Error())
default:
}
if req.Username == "" {
return nil, status.Error(codes.InvalidArgument, "username is required")
}
// Pass context to the database driver so queries can be aborted
exists, err := s.repo.IsUsernameTaken(ctx, req.Username)
if err != nil {
// Map internal errors to gRPC status codes
return nil, status.Error(codes.Internal, "database check failed")
}
return &CheckResponse{Available: !exists}, nil
}
Notice the explicit context check. If the client drops the connection or hits a deadline, ctx.Done() closes. Your handler returns immediately instead of waiting for a slow database query. The status.Error function wraps the message in a gRPC status object. The framework reads that object and sets the correct HTTP/2 status code and trailing metadata.
Do not pass a *string. Strings are already cheap to pass by value. The request struct holds the string directly, and the generated code copies it efficiently. Use _ to discard values intentionally when you need to satisfy a multi-return signature but only care about one value. result, _ := s.repo.Lookup(ctx, id) says you considered the error and chose to drop it. Use it sparingly with errors.
Where things go wrong
Unary RPC looks simple until you hit the edge cases. The most common mistake is returning a raw fmt.Errorf instead of a gRPC status code. The gRPC framework expects errors that implement GRPCStatus() *Status. If you return a plain error, the framework wraps it in codes.Unknown, which gives the client zero useful information. Use status.Error or status.Errorf from google.golang.org/grpc/status.
Another trap is blocking the handler. If your method spawns a goroutine to do work and returns immediately, the gRPC framework closes the response stream while your background goroutine is still running. The client gets a response, but your server is leaking resources. Keep the work inside the handler or use a proper job queue. Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path.
Forgetting to register the service crashes the server at startup. The generated code gives you a RegisterAvailabilityServiceServer function. You must call it with your grpc.Server instance before calling ListenAndServe. If you skip it, the server starts but returns UNIMPLEMENTED for every call. The compiler will not catch this. You will see rpc error: code = Unimplemented desc = method Check not implemented in the client logs.
Context leaks are the silent killer. If you pass a background context to a long-running query instead of the incoming ctx, the query ignores client cancellation. The connection stays open, the goroutine blocks, and your server slowly runs out of file descriptors. Always thread the context through every I/O boundary. The worst goroutine bug is the one that never logs.
Public names start with a capital letter. Private start lowercase. No keywords like public or private. The generated code follows this strictly. Your handler methods must be exported if they are called through the interface, but the interface itself is usually in the generated package. Keep your implementation details private to the server struct.
When to reach for unary RPC
Use a unary RPC when you need a simple request-response cycle with low latency and strict typing. Use server streaming when the server needs to send multiple chunks of data for a single request, like a live feed or a large file download. Use client streaming when the client needs to send multiple chunks before the server responds, like uploading a large file in parts. Use bidirectional streaming when both sides need to send independent messages over a long-lived connection, like a chat application or a collaborative editor. Use plain HTTP/REST when you need browser compatibility, caching, or integration with existing web tooling. Use unary RPC when you control both ends, want efficient binary serialization, and need predictable error codes.
Context is plumbing. Run it through every long-lived call site.