Go is the language of choice for building microservices due to its simplicity, performance, and excellent concurrency support. Kubernetes, Docker, Istio, etcd, and Prometheus are all written in Go — the infrastructure that runs microservices is itself built with Go, which tells you something about its fitness for this domain. This chapter covers gRPC, Protocol Buffers, and essential microservices patterns.
gRPC is a high-performance RPC framework that uses HTTP/2 and Protocol Buffers. Think of gRPC as a phone call between services: both sides agree on the language (protobuf schema) before the conversation starts, the connection is efficient (HTTP/2 multiplexing — multiple calls share one connection), and both sides can talk simultaneously (bidirectional streaming). REST, by comparison, is more like sending letters: each message is self-contained (JSON), the format is human-readable but verbose, and there is no built-in concept of an ongoing conversation.
package mainimport ( "context" "log" "net" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" pb "github.com/myapp/proto/user")type userServer struct { pb.UnimplementedUserServiceServer // Embed this to satisfy the interface for forward compatibility users map[int64]*pb.User}func NewUserServer() *userServer { return &userServer{ users: make(map[int64]*pb.User), }}func (s *userServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) { user, ok := s.users[req.Id] if !ok { // Idiomatic gRPC: use status.Errorf with standard codes, not fmt.Errorf. // The client can switch on the code to decide how to handle the error. return nil, status.Errorf(codes.NotFound, "user %d not found", req.Id) } return &pb.GetUserResponse{User: user}, nil}func (s *userServer) ListUsers(ctx context.Context, req *pb.ListUsersRequest) (*pb.ListUsersResponse, error) { var users []*pb.User for _, user := range s.users { users = append(users, user) if len(users) >= int(req.PageSize) { break } } return &pb.ListUsersResponse{ Users: users, TotalCount: int32(len(s.users)), }, nil}func (s *userServer) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.User, error) { // Validate if req.Name == "" { return nil, status.Error(codes.InvalidArgument, "name is required") } if req.Email == "" { return nil, status.Error(codes.InvalidArgument, "email is required") } // Create user user := &pb.User{ Id: int64(len(s.users) + 1), Name: req.Name, Email: req.Email, Status: pb.UserStatus_USER_STATUS_ACTIVE, } s.users[user.Id] = user return user, nil}// Server streamingfunc (s *userServer) WatchUsers(req *pb.ListUsersRequest, stream pb.UserService_WatchUsersServer) error { // Send existing users for _, user := range s.users { if err := stream.Send(user); err != nil { return err } } // In a real app, watch for new users and stream them // This is simplified - you'd use channels/pub-sub ticker := time.NewTicker(time.Second) defer ticker.Stop() for { select { case <-stream.Context().Done(): return stream.Context().Err() case <-ticker.C: // Check for new users and stream } }}// Client streamingfunc (s *userServer) BatchCreateUsers(stream pb.UserService_BatchCreateUsersServer) error { var users []*pb.User for { req, err := stream.Recv() if err == io.EOF { return stream.SendAndClose(&pb.ListUsersResponse{ Users: users, TotalCount: int32(len(users)), }) } if err != nil { return err } user, err := s.CreateUser(stream.Context(), req) if err != nil { return err } users = append(users, user) }}func main() { lis, err := net.Listen("tcp", ":50051") if err != nil { log.Fatalf("failed to listen: %v", err) } grpcServer := grpc.NewServer() pb.RegisterUserServiceServer(grpcServer, NewUserServer()) log.Println("gRPC server starting on :50051") if err := grpcServer.Serve(lis); err != nil { log.Fatalf("failed to serve: %v", err) }}
Pitfall — Not Implementing UnimplementedServer: If you do not embed pb.UnimplementedUserServiceServer, your server will fail to compile when new RPC methods are added to the proto definition. The Unimplemented embedding provides default “unimplemented” responses for new methods, ensuring forward compatibility. This is a gRPC best practice and is enforced by default in newer versions of protoc-gen-go-grpc.
Pitfall — Shared Mutable State Without Synchronization: The userServer above uses a plain map which is not safe for concurrent access. In production, gRPC servers handle multiple requests concurrently (one goroutine per request). You must protect shared state with a sync.RWMutex or use a database. Without this, concurrent map access will cause a runtime panic.
import ( "google.golang.org/grpc/health" "google.golang.org/grpc/health/grpc_health_v1")func main() { grpcServer := grpc.NewServer() // Register your services pb.RegisterUserServiceServer(grpcServer, NewUserServer()) // Register health service healthServer := health.NewServer() grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) // Set service health status healthServer.SetServingStatus("user.UserService", grpc_health_v1.HealthCheckResponse_SERVING) // Update health based on dependencies go func() { for { if dbHealthy() { healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) } else { healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_NOT_SERVING) } time.Sleep(10 * time.Second) } }() grpcServer.Serve(lis)}
Compare gRPC and REST for service-to-service communication. When would you choose each, and what are the hidden operational costs of gRPC?
Strong Answer:
gRPC excels at service-to-service (east-west) traffic: binary protobuf serialization is 5-10x smaller and faster than JSON, HTTP/2 multiplexing avoids head-of-line blocking, bidirectional streaming enables real-time data flows, and code generation from proto files gives you type-safe clients in any language with compile-time contract verification.
REST excels at client-facing (north-south) traffic: every browser, mobile client, and CLI tool speaks HTTP/JSON natively. REST is human-readable, debuggable with curl, and does not require code generation tooling. API gateways, CDNs, and load balancers all understand HTTP/REST natively.
Hidden operational costs of gRPC: you need a protobuf compilation pipeline in CI (protoc + plugins), proto file versioning and backward compatibility management, gRPC debugging is harder (cannot use curl, need grpcurl or Postman with gRPC support), load balancers need gRPC-aware configuration (because gRPC uses HTTP/2 and multiplexes requests on a single connection, naive round-robin at the TCP level does not work — you need L7 load balancing), and browser clients cannot call gRPC directly (you need grpc-web or a REST gateway like grpc-gateway).
My recommendation: use gRPC between internal services where performance and type safety matter. Expose REST/JSON for external APIs and browser clients. Use grpc-gateway to generate a REST facade over your gRPC services when both are needed.
Follow-up: gRPC uses HTTP/2 with persistent connections. How does this affect load balancing, and what pattern do you use to handle it?Because gRPC multiplexes all requests over a single HTTP/2 connection, a TCP-level (L4) load balancer routes all requests from one client to the same server. If you have 3 servers and 3 clients, each client connects to one server, and load distribution depends on which client is busiest. The fix is L7 (application-level) load balancing that inspects individual gRPC requests within the HTTP/2 stream and routes them independently. Alternatively, use client-side load balancing: the gRPC client resolves multiple server addresses (via DNS or service discovery) and distributes requests using round-robin or another policy. In Go, you configure this with grpc.WithDefaultServiceConfig specifying round_robin and a custom resolver that returns multiple addresses. In Kubernetes, use headless services so DNS returns all pod IPs, enabling client-side balancing.
How do gRPC interceptors work in Go? Compare them to HTTP middleware and explain how you would implement logging, authentication, and error recovery.
Strong Answer:
gRPC interceptors are the gRPC equivalent of HTTP middleware. There are two types: unary interceptors (for request-response RPCs) and stream interceptors (for streaming RPCs). Each interceptor has access to the request, the server info (method name, service), and calls the next handler in the chain.
The signature for a unary server interceptor is: func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error). You can inspect/modify the request before calling handler(ctx, req), and inspect/modify the response/error after.
For logging: record the start time, call the handler, then log the method name, duration, and error status. The info.FullMethod gives you the service and method name (like /user.UserService/GetUser).
For authentication: extract the token from gRPC metadata (metadata.FromIncomingContext(ctx)), validate it, and inject claims into the context with context.WithValue. Return status.Error(codes.Unauthenticated, ...) if validation fails. This is analogous to reading the Authorization header in HTTP middleware.
For error recovery: wrap the handler call in a defer func() { if r := recover(); r != nil { ... } }() to catch panics and convert them to status.Error(codes.Internal, ...) instead of crashing the server.
Key difference from HTTP middleware: gRPC interceptors chain with grpc.ChainUnaryInterceptor(), and gRPC uses structured error codes (codes.NotFound, codes.PermissionDenied) instead of HTTP status codes. The error codes are richer and more standardized than HTTP status codes for RPC semantics.
Follow-up: What are the four types of gRPC methods, and when would you use server streaming versus bidirectional streaming?Unary (single request, single response) is the default for most CRUD operations. Server streaming (single request, stream of responses) is for when the server has a large or ongoing result set — think real-time price feeds, log tailing, or paginated results streamed incrementally. Client streaming (stream of requests, single response) is for batch uploads where the client sends many items and gets a summary response. Bidirectional streaming (both sides stream simultaneously) is for real-time interactive protocols like chat, collaborative editing, or game state synchronization. In practice, unary covers 90% of use cases. Server streaming covers most of the rest. Client streaming and bidirectional are rare and significantly more complex to implement correctly (error handling, backpressure, reconnection).
Your microservice makes calls to three downstream services. One of them starts returning errors. How do you prevent this from cascading and taking down your service?
Strong Answer:
Three layers of defense: timeouts, circuit breakers, and bulkheads.
Timeouts: every downstream call must have a context timeout. If service C is slow, the request to C times out after (say) 2 seconds instead of hanging indefinitely. Without timeouts, your goroutines pile up waiting for the slow service, exhausting memory and goroutine capacity.
Circuit breaker: after N consecutive failures to service C (or a failure rate threshold), the circuit opens and subsequent calls fail immediately without even attempting the request. This gives service C time to recover and prevents your service from wasting resources on requests that will fail. After a timeout period, the circuit moves to half-open and probes C with a single request. If it succeeds, the circuit closes.
Bulkheads: isolate the failure domain. If service C’s connection pool is separate from services A and B, exhausting C’s pool does not affect A and B. In Go, this means using separate HTTP clients (with their own connection pools) or separate goroutine pools for each downstream service.
Additionally: implement graceful degradation. If service C is the recommendation engine and it is down, return a static default recommendation set instead of failing the entire request. The user gets a slightly worse experience but the service stays up.
In Go specifically: use errgroup.WithContext so that if one downstream call fails, the context is cancelled and the other concurrent calls are aborted. Combine with context.WithTimeout per call, and sony/gobreaker for circuit breaking.
Follow-up: How do you implement distributed tracing across Go microservices, and why is it essential for debugging?Distributed tracing (using OpenTelemetry + Jaeger/Zipkin) propagates a trace ID across service boundaries. Each service creates spans for its operations and links them to the parent trace. In Go, the gRPC interceptor (or HTTP middleware) extracts the trace context from incoming request headers, creates a child span, and injects the context into outgoing requests. When a request is slow or fails, you look up the trace ID and see the entire call graph: which services were involved, how long each took, and where the bottleneck or error occurred. Without tracing, debugging a problem across 5 microservices requires correlating logs from 5 different systems using timestamps and request IDs. With tracing, you have a single view of the entire request lifecycle. This is not optional for production microservices — it is the only way to understand latency and failure in a distributed system.