Skip to main content

Microservices & gRPC

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 Fundamentals

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.

Why gRPC?

Protocol Buffers

Protocol Buffers (protobuf) is Google’s language-neutral serialization format.

Generating Go Code


gRPC Server

Implementing the Service

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.

gRPC Client


gRPC Interceptors (Middleware)

Server Interceptors

Client Interceptors


Service Discovery

Using Consul

Load Balancing with gRPC


Health Checks


Circuit Breaker


Event-Driven Architecture

Publishing Events

Consuming Events


Distributed Tracing


Interview Questions

  • Protocol: gRPC uses HTTP/2, REST typically HTTP/1.1
  • Payload: gRPC uses binary Protobuf, REST uses text (JSON/XML)
  • Streaming: gRPC has native bidirectional streaming
  • Type Safety: gRPC has strong contracts, REST relies on documentation
  • Performance: gRPC is faster due to binary serialization and HTTP/2
  1. Unary: Single request, single response
  2. Server streaming: Single request, stream of responses
  3. Client streaming: Stream of requests, single response
  4. Bidirectional streaming: Stream in both directions
Use google.golang.org/grpc/status package with standard codes:
  • codes.NotFound for missing resources
  • codes.InvalidArgument for bad input
  • codes.Unauthenticated for auth failures
  • codes.PermissionDenied for authorization failures
  • codes.Internal for server errors
  • Circuit Breaker: Prevent cascading failures
  • Retry with backoff: Handle transient failures
  • Timeout: Prevent hanging requests
  • Load balancing: Distribute traffic
  • Service discovery: Dynamic endpoint resolution
  • Health checks: Monitor service availability

Summary


Interview Deep-Dive

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.
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).
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.