REST vs gRPC vs GraphQL: A Production Decision Guide
Lesson 2 of 5
Picture three separate client problems landing on one team at once: internal checkout calling inventory 40 times per request (P99 latency: 850ms), mobile over-fetching 41 unused fields per product, and external partners depending on 12 stable REST endpoints.
One answer: use all three protocols. gRPC for internal service calls. GraphQL for mobile. REST for partners.
REST, gRPC, and GraphQL solve different problems. REST handles external APIs and caching. gRPC trims per-call overhead on internal service-to-service calls through binary serialization and HTTP/2 multiplexing. GraphQL lets clients fetch exactly the fields they need, eliminating over-fetching on mobile. [1]
- gRPC wins: Compact binary payloads, strong type contracts, streaming
- GraphQL wins: Flexible field selection, complex joins in one request, mobile bandwidth
- REST wins: Browser compatibility, HTTP caching, external API stability [2]
The quick start: REST vs gRPC vs GraphQL
Source[2]| Use case | Best choice | Why |
|---|---|---|
| External / public API | REST | CDN cacheable, no compiler needed, 30-year ecosystem |
| Internal microservices | gRPC | Binary Protobuf payloads, HTTP/2 multiplexing, type-safe clients |
| Mobile / diverse clients | GraphQL | Clients query only fields they need; joins in one round-trip |
| Stable versioned contracts | REST | Path versioning (/api/v1/, /api/v2/) is explicit and cacheable |
| High-frequency calls | gRPC | Savings multiply: 40 calls/request × 100 req/s = 4,000 calls/s, so trimming 700 bytes per call (say 1 KB JSON → 300 B Protobuf) saves 2.8 MB/s |
| Browser access | REST | No compilation step; curl/fetch just work |
As a decision flow:
graph TD
Start{"Who calls<br/>this API?"} -->|browser /<br/>3rd parties| REST["REST"]
Start -->|internal<br/>service| Q1{"Latency /<br/>bandwidth tight?"}
Start -->|mobile app /<br/>diverse clients| Q2{"Over-fetching<br/>a real problem?"}
Q1 -->|yes| gRPC["gRPC"]
Q1 -->|no, prefer<br/>ergonomics| REST
Q2 -->|yes, clients<br/>need field control| GraphQL["GraphQL"]
Q2 -->|no| REST
Browser clients almost always get REST; internal RPC almost always gets gRPC when tuned for performance; GraphQL earns its keep specifically when the over-fetching or under-fetching problem outweighs its schema + caching complexity.
REST: HTTP Semantics as Your Contract
REST leverages HTTP semantics[2]: status codes, caching headers, ETags, conditional requests. This means CDNs, proxies, and load balancers understand your API without custom logic. URLs name resources (/orders/123), HTTP verbs describe operations (GET, POST, PATCH).
REST code: Idempotency and ETags
The following Spring Boot example shows two production patterns:
@RestController
@RequestMapping("/api/v1")
public class OrderController {
private final OrderService orderService;
@GetMapping("/orders/{id}")
public ResponseEntity<OrderResponse> getOrder(
@PathVariable String id,
@RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) {
Order order = orderService.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
String etag = "\"" + order.getVersion() + "\"";
// Conditional GET: client already has this version — return 304
if (etag.equals(ifNoneMatch)) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
.eTag(etag).build();
}
return ResponseEntity.ok()
.eTag(etag)
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).mustRevalidate())
.body(OrderResponse.from(order));
}
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(
@Valid @RequestBody CreateOrderRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey) {
// Idempotency: same key = return original response, never a duplicate
if (idempotencyKey != null) {
Optional<Order> existing = orderService.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) {
return ResponseEntity.created(buildOrderUri(existing.get().getId()))
.body(OrderResponse.from(existing.get()));
}
}
Order order = orderService.create(request, idempotencyKey);
return ResponseEntity.created(buildOrderUri(order.getId()))
.body(OrderResponse.from(order));
}
private URI buildOrderUri(String orderId) {
return UriComponentsBuilder.fromPath("/api/v1/orders/{id}")
.buildAndExpand(orderId).toUri();
}
}Two critical patterns: ETags enable cache revalidation (client sends If-None-Match, server returns 304 Not Modified). Idempotency keys prevent duplicate orders on network retries.
REST versioning and error responses
Version in the URL path: /api/v1/, /api/v2/. Avoid header-based versioning — it's invisible in logs and breaks caching.
// Version is explicit and cacheable
@RestController
@RequestMapping("/api/v1/products")
public class ProductControllerV1 { /* ... */ }
@RestController
@RequestMapping("/api/v2/products")
public class ProductControllerV2 { /* ... */ }Standardize errors via RFC 9457 Problem Details: never return plain strings.
{
"type": "https://api.example.com/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "order 12345 not found"
}When REST fails
Over-fetching: Mobile downloads 41 unused fields; desktop needs all 47. One REST endpoint can't serve both.
Under-fetching: Fetching a user profile + orders + order items requires 3+ round-trips.
No built-in streaming: WebSockets and Server-Sent Events are bolt-ons, not REST-native.
gRPC: Binary Protocol for Internal Services
The wire format comparison — JSON-over-HTTP/1.1 vs Protobuf-over-HTTP/2 for the same GetOrder(id=123) call:[3]
graph TD
subgraph REST[REST + JSON over HTTP/1.1]
direction LR
R1[Client] -->|GET /orders/123<br/>field names in every body| R2[TCP connection 1]
R1 -->|GET /orders/124<br/>parallel = new conn| R3[TCP connection 2]
R1 -->|GET /orders/125| R4[TCP connection 3]
R2 --> RBody[JSON body<br/>id, customer_id,<br/>total_cents, status<br/>field names sent<br/>every request]
end
subgraph GRPC[gRPC + Protobuf over HTTP/2]
direction LR
G1[Client] -->|stream 1: GetOrder 123| G2[Single TCP connection<br/>multiplexed]
G1 -->|stream 3: GetOrder 124<br/>same conn| G2
G1 -->|stream 5: GetOrder 125<br/>same conn| G2
G2 --> GBody[Protobuf body<br/>field tags 1, 2, 3, 4<br/>varint compact ints]
end
style RBody fill:#fdd
style GBody fill:#dfd
style R2 fill:#fdd
style R3 fill:#fdd
style R4 fill:#fdd
REST ~~~ GRPC
Two separate mechanisms produce the difference: compact Protobuf encoding shrinks each message, and HTTP/2 multiplexing removes the extra connection per concurrent request.
gRPC is an RPC framework built on HTTP/2 using Protocol Buffers[4] as IDL[3]. Three properties make it best for internal service calls:
- Binary serialization: Field numbers, not field names, go on the wire, and small integers take fewer bytes as varints. [4]
- HTTP/2 multiplexing: Many concurrent RPCs share one TCP connection. HTTP/1.1 reuses connections, but pipelining only partly solved concurrency, so HTTP/1.1 clients open several connections to send requests in parallel. [5]
- Generated clients:
protocgenerates type-safe clients in Go, Java, Python from a single.protofile. No SDK drift.
gRPC service definition
syntax = "proto3";
package commerce.orders.v1;
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc CreateOrder(CreateOrderRequest) returns (Order);
rpc StreamOrderEvents(StreamOrderEventsRequest) returns (stream OrderEvent);
}
message Order {
string id = 1;
string customer_id = 2;
repeated LineItem items = 3;
OrderStatus status = 4;
double total_usd = 5;
int64 created_at_ms = 6;
}
message LineItem {
string product_id = 1;
int32 quantity = 2;
double unit_price_usd = 3;
}
enum OrderStatus {
PENDING = 0;
CONFIRMED = 1;
SHIPPED = 2;
DELIVERED = 3;
}
message GetOrderRequest { string id = 1; }
message CreateOrderRequest {
string customer_id = 1;
repeated LineItem items = 2;
string idempotency_key = 3;
}
message StreamOrderEventsRequest { string order_id = 1; }
message OrderEvent {
string order_id = 1;
OrderStatus status = 2;
int64 occurred_at_ms = 3;
}gRPC server in Go
func (s *OrderServer) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {
if req.GetId() == "" {
return nil, status.Error(codes.InvalidArgument, "order id required")
}
row := s.db.QueryRow(ctx,
`SELECT id, customer_id, status, total_usd, created_at
FROM orders WHERE id = $1`, req.GetId())
var o pb.Order
var createdAt time.Time
var statusStr string
err := row.Scan(&o.Id, &o.CustomerId, &statusStr, &o.TotalUsd, &createdAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, status.Errorf(codes.NotFound, "order %q not found", req.GetId())
}
if err != nil {
return nil, status.Error(codes.Internal, "fetch failed")
}
st, ok := pb.OrderStatus_value[statusStr]
if !ok { // never let a bad row silently become PENDING (the zero value)
return nil, status.Errorf(codes.Internal, "order %q has unknown status %q", o.Id, statusStr)
}
o.Status = pb.OrderStatus(st)
o.CreatedAtMs = createdAt.UnixMilli()
// Fetch line items
rows, err := s.db.Query(ctx,
`SELECT product_id, quantity, unit_price_usd FROM order_items WHERE order_id = $1`,
o.Id)
if err != nil {
return nil, status.Error(codes.Internal, "fetch line items failed")
}
defer rows.Close()
for rows.Next() {
var item pb.LineItem
if err := rows.Scan(&item.ProductId, &item.Quantity, &item.UnitPriceUsd); err != nil {
return nil, status.Error(codes.Internal, "scan line item failed")
}
o.Items = append(o.Items, &item)
}
if err := rows.Err(); err != nil {
return nil, status.Error(codes.Internal, "line item iteration failed")
}
return &o, nil
}
func (s *OrderServer) StreamOrderEvents(
req *pb.StreamOrderEventsRequest,
stream pb.OrderService_StreamOrderEventsServer,
) error {
ctx := stream.Context()
ch, unsubscribe := s.events.Subscribe(req.GetOrderId())
defer unsubscribe()
for {
select {
case <-ctx.Done(): // client disconnected or deadline passed
return status.FromContextError(ctx.Err()).Err()
case event, ok := <-ch:
if !ok {
return nil // publisher closed the subscription
}
if err := stream.Send(event); err != nil {
return err
}
}
}
}Key production patterns: GetOrder validates input and returns gRPC status codes (not HTTP). StreamOrderEvents uses server streaming [3]: one request, then a long-lived stream of events pushed to the client, released as soon as the client goes away. Plain request/response REST has no native equivalent.
Proto versioning rules
The one unbreakable rule for gRPC: never break backwards compatibility.
// SAFE: Add new fields — old clients ignore them
message Order {
string id = 1;
double total = 2;
string tracking_id = 4; // ✅ New field
}
// DANGEROUS: Never reuse field numbers
message Order {
string id = 1;
// double total = 2; // ❌ Deleted
reserved 2; // ✅ Prevent reuse
string currency_total = 5;
}
// SAFE: Add new enum values (at the end only)
enum OrderStatus {
PENDING = 0;
SHIPPED = 1;
RETURNED = 2; // ✅ New value
}The full rule set, including the changes that look harmless and are not [6]:
| Proto change | Verdict | Why |
|---|---|---|
| Add a new field with an unused number | Go | Old clients skip unknown fields |
| Append a new enum value at the end | Go | Existing values keep their numbers |
| Rename a field, same number and type | Wire-safe | The wire format is fine; generated code breaks for every consumer |
Delete a field without reserved | Avoid | The number gets reused later and old clients silently misread the new field |
Change a field's type (int32 → string) | Avoid | Decoders fail or read garbage; there is no compatible path |
Move fields into an existing oneof | Avoid | The bytes don't change, but a parse keeps only the last member seen and silently drops the rest |
Enforce in CI with buf breaking --against '.git#branch=main' — fails the build on any breaking change.
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("listen: %v", err)
}
creds, err := credentials.NewServerTLSFromFile("server.crt", "server.key")
if err != nil {
log.Fatalf("TLS: %v", err)
}
srv := grpc.NewServer(
grpc.Creds(creds),
grpc.ChainUnaryInterceptor(
tracingInterceptor(),
loggingInterceptor(logger),
authInterceptor(tokenVerifier),
recoveryInterceptor(), // catch panics, return codes.Internal
),
)
orderSrv := &OrderServer{db: pool, events: eventBus}
pb.RegisterOrderServiceServer(srv, orderSrv)
// Health checking — required for Kubernetes readiness probes
healthSrv := health.NewServer()
grpc_health_v1.RegisterHealthServer(srv, healthSrv)
healthSrv.SetServingStatus(
"commerce.orders.v1.OrderService",
grpc_health_v1.HealthCheckResponse_SERVING,
)
// Graceful shutdown: mark NOT_SERVING before terminating
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-stop
healthSrv.SetServingStatus(
"commerce.orders.v1.OrderService",
grpc_health_v1.HealthCheckResponse_NOT_SERVING,
)
time.Sleep(5 * time.Second) // drain in-flight RPCs from load balancer
srv.GracefulStop()
}()
if err := srv.Serve(lis); err != nil {
log.Fatalf("serve: %v", err)
}
}Go Client with Retry Policy
const serviceConfig = `{
"methodConfig": [{
"name": [{"service": "commerce.orders.v1.OrderService"}],
"retryPolicy": {
"maxAttempts": 3,
"initialBackoff": "0.1s",
"maxBackoff": "1s",
"backoffMultiplier": 2.0,
"retryableStatusCodes": ["UNAVAILABLE"]
},
"timeout": "5s"
}],
"retryThrottling": {"maxTokens": 10, "tokenRatio": 0.1}
}`
func NewOrderClient(addr string, tlsCfg *tls.Config) (*OrderClient, error) {
conn, err := grpc.NewClient(addr,
grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)),
grpc.WithDefaultServiceConfig(serviceConfig),
)
if err != nil {
return nil, fmt.Errorf("dial %s: %w", addr, err)
}
return &OrderClient{client: pb.NewOrderServiceClient(conn)}, nil
}
func (c *OrderClient) GetOrder(ctx context.Context, id string) (*pb.Order, error) {
// Derive timeout from incoming context — never use context.Background() here
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
return c.client.GetOrder(ctx, &pb.GetOrderRequest{Id: id})
}gRPC challenges
- Browser clients: No HTTP/2 trailer support in Fetch API. Need
grpc-webproxy (Envoy). Adds a network hop. - L7 load balancing required: Standard L4 load balancers pin all RPCs from a client to one backend. Use Envoy or client-side DNS round-robin to distribute traffic.
- Debugging: Can't curl it. Use
grpcurlinstead.
GraphQL: Client-Driven Queries
GraphQL[1] lets clients query exactly the fields they need. One endpoint replaces dozens of REST resources. Mobile asks for four product fields; the desktop detail page adds a description and related products. Both get one response, no over-fetching.
GraphQL schema and queries
type Query {
order(id: ID!): Order
product(id: ID!): Product
products(first: Int = 20, after: String): ProductConnection
}
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderPayload!
}
type Order {
id: ID!
status: OrderStatus!
items: [LineItem!]!
customer: Customer!
total: Money!
}
type LineItem {
product: Product!
quantity: Int!
unitPrice: Money!
}
type Product {
id: ID!
name: String!
description: String
price: Money!
thumbnailUrl: String!
inStock: Boolean!
relatedProducts(first: Int): [Product!]!
}GraphQL queries and the N+1 problem
# Mobile: exactly 4 fields per product
query CheckoutProducts {
products(first: 20) {
edges {
node {
name
price
thumbnailUrl
inStock
}
}
}
}
# Desktop: more fields, including expensive relatedProducts
query DetailPage($id: ID!) {
product(id: $id) {
name
price
description
relatedProducts(first: 5) {
name
price
}
}
}The N+1 problem: Fetching 20 products without DataLoader triggers 1 query for products + 20 queries for related products = 21 total. With DataLoader, it's 1 + 1 = 2 queries. DataLoader batches lookups into a single SELECT ... WHERE id IN query.
Go resolver with DataLoader
package graph
import (
"context"
"errors"
"log/slog"
"example.com/shop/graph/model"
"github.com/graph-gophers/dataloader/v7"
)
type Resolver struct {
productRepo ProductRepository
orderRepo OrderRepository
}
type DataLoaders struct {
ProductByID *dataloader.Loader[string, *model.Product]
}
type loadersKey struct{}
// Request-scoped: middleware stores a fresh set in each request's context.
// The default cache never evicts, so a process-wide loader serves stale rows.
func loadersFor(ctx context.Context) *DataLoaders {
return ctx.Value(loadersKey{}).(*DataLoaders)
}
// Field resolver: runs once per object. DataLoader batches these.
func (r *lineItemResolver) Product(
ctx context.Context,
obj *model.LineItem,
) (*model.Product, error) {
return loadersFor(ctx).ProductByID.Load(ctx, obj.ProductID)()
}
// Mutation: validation errors in the payload, infrastructure failures top-level
func (r *mutationResolver) CreateOrder(
ctx context.Context,
input model.CreateOrderInput,
) (*model.CreateOrderPayload, error) {
if len(input.Items) == 0 {
return &model.CreateOrderPayload{
Errors: []model.UserError{{Message: "order must contain at least one item"}},
}, nil
}
order, err := r.orderRepo.Create(ctx, input)
if err != nil {
// Not the client's fault: log the cause, don't leak it in the response
slog.ErrorContext(ctx, "create order", "err", err)
return nil, errors.New("order could not be created")
}
return &model.CreateOrderPayload{Order: order}, nil
}Critical pattern: Validation errors go in the payload, where clients can show them; infrastructure failures go top-level with the cause logged, so clients and dashboards can tell a bad input from an outage.
GraphQL challenges
- Error semantics: Returns HTTP 200 even on failures. Need GraphQL-specific monitoring (error rate by operation name, not HTTP status).
- N+1 queries: DataLoader is mandatory. Without it, one query triggers N subqueries.
- Schema evolution: Breaking changes aren't caught without
graphql-inspectorin CI. Removing a field silently breaks clients. - Depth limits required: Protect against exponential queries:
query { products { relatedProducts { relatedProducts { ... } } } }. Use complexity budgets.
Production checklist
When you've chosen your protocol(s), verify:
- REST: Version in URL paths (
/api/v1/,/api/v2/). Use idempotency keys for POST. Standardize errors with RFC 9457 Problem Details. - gRPC: Lock proto evolution rules in CI with
buf breaking. Use L7 load balancing (Envoy) to avoid uneven distribution. Enable reflection for debugging withgrpcurl. - GraphQL: Add DataLoader batching. Set complexity limits. Return validation errors in payload objects. Use schema registry and
graphql-inspectorfor breaking change detection. - Multi-protocol: Plan for three auth stacks, three observability pipelines, three client generators. Worth it only if access patterns truly differ.
Federation patterns: gateway, BFF, GraphQL federation, service mesh
Once you run more than a handful of services, the question stops being "which protocol" and starts being "how do clients reach all of them coherently". Four common patterns, each with sharp edges:
Sits in front of every backend, handling auth, rate limiting, and request shaping. Kong, Envoy, and AWS API Gateway dominate here: the gateway terminates TLS, validates JWTs once, and forwards trusted requests with internal headers. The trap is that gateways become god-objects when teams push business logic into them. Keep them stateless and protocol-agnostic; never let them call databases.
Backend for Frontend gives each client surface its own shaping layer. The mobile BFF stitches gRPC calls into a slim payload; the web BFF returns a denormalised view tuned for the browser. BFFs solve the chatty-mobile problem without forcing GraphQL on every team. Cost: more services to operate, and a real risk of duplicated business logic if BFFs start owning rules instead of orchestrating them.
Apollo Federation and Hot Chocolate compose a single supergraph from subgraphs owned by different teams. Each team owns the types they understand best: the catalog team owns Product, the order team owns Order, the supergraph stitches them. This works when teams agree on entity keys and a schema registry blocks breaking changes. It collapses when ownership is fuzzy or when one team's slow resolver poisons every query.
gRPC with Istio or Linkerd pushes cross-cutting concerns into sidecars: mTLS, retries, traffic shifting, observability. Application code stays clean; the mesh handles the network. Below roughly 20 services the operational tax of running a mesh exceeds the benefit, and a smarter gRPC client config covers most needs.
A typical large-system blueprint: API gateway at the edge, mobile and web BFFs behind it, a GraphQL supergraph for product surfaces that need flexible composition, and a service mesh wrapping all internal gRPC traffic.
Migrating internal calls to gRPC while REST stays external
Picture a public REST API serving 200+ partners, sitting in front of an internal monolith that talks to itself over plain JSON. The pattern: move internal hops to gRPC incrementally while the external REST contract stays frozen, so partners never notice the change. Whatever latency you gain comes from smaller payloads and connection reuse on the internal fan-out; measure it per route during rollout rather than assuming a number. The bridge looks like this:
// Edge handler: still serves REST to partners, calls gRPC internally
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
id := chi.URLParam(r, "id")
// Internal call: now gRPC. External shape: still JSON.
order, err := h.grpcClient.GetOrder(ctx, &pb.GetOrderRequest{Id: id})
if err != nil {
switch status.Code(err) {
case codes.NotFound:
writeProblemDetail(w, http.StatusNotFound, "order not found", id)
case codes.DeadlineExceeded:
writeProblemDetail(w, http.StatusGatewayTimeout, "upstream timeout", id)
default:
writeProblemDetail(w, http.StatusBadGateway, "upstream error", id)
}
return
}
// Translate proto → versioned JSON contract partners depend on
resp := OrderResponseV1{
ID: order.Id,
CustomerID: order.CustomerId,
Status: strings.ToLower(order.Status.String()),
TotalUSD: order.TotalUsd,
CreatedAt: time.UnixMilli(order.CreatedAtMs).UTC().Format(time.RFC3339),
}
// Marshal before writing anything: Encode-to-w would fail partway on
// a bad value (e.g. NaN from a corrupt total_usd) and leave the client
// with a silent 200 and an empty body.
body, err := json.Marshal(resp)
if err != nil {
slog.ErrorContext(ctx, "marshal order response", "order_id", id, "err", err)
writeProblemDetail(w, http.StatusInternalServerError, "response encoding failed", id)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "private, max-age=30")
if _, err := w.Write(body); err != nil {
// Headers are already sent at this point; all that's left is to record the failure.
slog.ErrorContext(ctx, "write order response", "order_id", id, "err", err)
}
}Two rules keep a migration like this safe. First, centralize the gRPC status code to HTTP status code mapping — partners should never see a codes.NotFound leak through as a 500. Second, treat the proto schema as a purely internal contract: keep the public JSON shape in its own Go struct so a change to one never silently ripples into the other. Skip that boundary and every proto rename becomes a partner-breaking change.
Roll out behind a feature flag per route: 1% of gRPC traffic, then 10%, then 50%, then 100%, watching error rates and latency at each step. Watch specifically for retry storms. gRPC has no default retry policy [7]; the client above opts in to retrying UNAVAILABLE for up to three attempts. When several layers each retry, attempts multiply, and a flapping pod under load turns a partial outage into a full one. The retryThrottling block is the brake: once failures drain its tokens to half of maxTokens, the client stops retrying until successes refill them. [7] That failure mode is the reason a staged rollout with a fast per-route rollback matters more than the migration's happy path.
Versioning that survives multi-year evolution
Most APIs die from versioning, not performance. Three strategies that have aged well:
Path versioning for REST (/api/v1/, /api/v2/) is explicit, cacheable, and visible in every log line. Run versions in parallel for at least 12 months after deprecation. Publish a sunset header (Sunset: Mon, 01 Nov 2027 00:00:00 GMT) the day deprecation is announced — partners need machine-readable signals, not blog posts.
Additive-only Protobuf evolution for gRPC. Never reuse field numbers; reserve them when fields are deleted. Never change a field's type. Never renumber an enum. CI must enforce this — buf breaking --against '.git#branch=main' fails the build on any wire-incompatible change. After three years of additive changes, a commerce.orders.v1 package is still readable by clients that compiled against the original proto. A typical evolution after two years looks like this:
message Order {
string id = 1;
string customer_id = 2;
repeated LineItem items = 3;
OrderStatus status = 4;
// Year 1: total in dollars (deprecated, kept for old clients)
double total_usd = 5 [deprecated = true];
int64 created_at_ms = 6;
reserved 7; // never reuse: was a removed field
reserved "legacy_promo_code"; // also block the old name
// Year 2: replaced with structured Money to support multi-currency
Money total_money = 8;
// Year 2: optional shipping info — old clients ignore unknown fields
ShippingDetails shipping = 9;
}GraphQL field deprecation, never field deletion. Mark fields @deprecated(reason: "use totalMoney") and watch usage in your schema registry. Only delete a field when traffic to it has been zero for 90 days. Adding a non-null field to an input type is a breaking change; adding nullable fields is safe.
The common thread: every protocol has a "safe" subset of changes and a "breaking" subset. Make the break point loud (CI failure, schema registry alert) rather than discovering it from a partner Slack at 2am.
Frequently Asked Questions
When should I use gRPC instead of REST?
Use gRPC for internal service-to-service communication where low latency and strict contracts matter. gRPC pairs Protocol Buffers [3] with HTTP/2, so payloads are compact and concurrent calls share one connection [5]. Measure the latency gain on your own traffic rather than assuming a multiplier. REST is better for public APIs where broad client compatibility and human readability are priorities.
Can I use REST, gRPC, and GraphQL in the same system?
Yes, and many production systems do. A common pattern is gRPC for internal microservice communication, GraphQL for mobile/frontend-facing APIs that need flexible field selection, and REST for external partner APIs that need stable versioned contracts.
What is the N+1 problem in GraphQL and how do I solve it?
The N+1 problem occurs when resolving a list of N items triggers N additional database queries for related data. Solve it with DataLoader, which batches and deduplicates queries within a single request tick, turning N+1 queries into 2 batched queries.
Keep Reading
- gRPC in Production: Interceptors, Streaming, and Deadline Propagation — Deep dive into the gRPC patterns this article introduces: interceptor chains, deadline budgets, health checking, and L7 load balancing
- Spring Boot REST: JPA, Validation, Exception Handling, and Testing — Production patterns for the REST controllers shown here, including global exception handling and integration testing
- HTTP/1.1 vs HTTP/2 vs HTTP/3: The Protocol Evolution Guide — The transport layer that powers all three protocols, including HTTP/2 multiplexing (gRPC) and why QUIC matters for mobile clients
Sources
- 1.GraphQL Specification (October 2021 Edition) — graphql.org, 2021
- 2.HTTP Semantics — IETF, 2022
- 3.gRPC — Concepts and core documentation — grpc.io, 2026
- 4.Protocol Buffers — Wire format, encoding, and language guide — Protocol Buffers Documentation, 2026
- 5.HTTP/2 — IETF, 2022
- 6.Language Guide (proto 3) — Protocol Buffers Documentation, 2026
- 7.Retry — grpc.io, 2026
Engineering Team
An independent engineering publication covering distributed systems, databases, and production infrastructure. Every factual claim is cited to a primary source or removed.
Read Next
Microservices Architecture: From Monolith to Production-Ready Services
When to decompose a monolith, how to define boundaries, and the patterns that work: API gateways, sagas, and event-driven comms.
Consistent Hashing: The Algorithm Behind Scalable Distributed Systems
Adding one cache server shouldn't invalidate every key. Consistent hashing with virtual nodes and bounded loads — full Go and Java implementations.
Kafka vs RabbitMQ vs NATS vs SQS: Choosing the Right Message Broker
Kafka vs RabbitMQ vs NATS vs SQS: delivery semantics, ordering, throughput, ops complexity, and a decision framework with Go code.