System Design Masterclass · Module 07 / Week 2

REST, gRPC, GraphQL & Real-time

API style is a per-edge decision, not a religion. The mechanics of each, a selection framework you can defend in review, and the versioning discipline that keeps 200 services deployable independently.

~3h study~2h exercise2 interactive toolsFast track ~80 min

Fast track — built APIs in all four?

Compresses to ~80 min. Style overviews collapse; gRPC operational depth (deadlines, LB quirks), the selection framework, and compatibility rules stay mandatory — that's where estates rot.

  1. §01 takeaway + payload tool · 10 min
  2. §02 gRPC in full — especially deadlines & L7-LB caveat · 25 min · MANDATORY
  3. §03 selection framework + advisor · 15 min · MANDATORY
  4. §04 versioning rules, in full · 15 min · MANDATORY
  5. Exercise step 2 · 20 min
STEP 01

The styles in one table

SKIM
Fast-track takeawayREST = resources over HTTP semantics: universally consumable, cache-friendly, loosely contracted. gRPC = typed RPC on protobuf/HTTP-2: ~5–10× smaller payloads, streaming, codegen — at the cost of browser friction and L7-LB awareness. GraphQL = client-shaped queries: solves mobile over/under-fetching and API aggregation, imports resolver N+1, query-cost policing, and cache complexity. WebSockets = bidirectional push; SSE = server→client push over plain HTTP that survives proxies and auto-reconnects — the underrated default for notifications/live tickers. Run the payload tool below once.
StyleContractSweet spotYou inherit
REST/JSONOpenAPI (optional discipline)public APIs, CRUD, anything cacheableverbosity, loose typing, over-fetching
gRPCprotobuf (enforced)service↔service, streaming, polyglot codegenHTTP/2-aware LB, browser needs grpc-web, opaque on the wire
GraphQLschema (enforced)mobile/web BFF, aggregating many servicesresolver N+1, query cost limits, cache story
WebSocketnone — you invent onebidirectional real-time (chat, gaming)stateful connections: LB stickiness, reconnect+resume protocol, backpressure
SSEevent stream over HTTPserver→client push: scores, notificationsone-directional; ~nothing else — that's the point
Same entitlement response · wire size
JSON
1.9 KB
JSON+gzip
640 B
protobuf
310 B
protobuf+gzip
230 B
Field names dominate JSON; protobuf sends tags. But note gzip closes most of the gap — the compelling gRPC arguments are contracts, streaming, and codegen, not bytes. Serialization CPU (recall Module 02: parsing 200 KB JSON ≈ ms-scale) matters at high RPS.
STEP 02

gRPC beyond hello-world

MANDATORY

Fast trackThe three subsections below — deadlines, load balancing, streaming semantics — are where gRPC estates succeed or melt. Full read.

Deadlines, not timeouts

gRPC's deadline is absolute and propagated: the client sets "this whole operation is worthless after T", and every downstream hop receives the remaining budget in metadata. A 300 ms playback-auth deadline arriving at the entitlement service with 120 ms left means it can skip the optional enrichment call — deadline-aware degradation. Contrast per-hop timeouts, which compose blindly: five sequential 2 s timeouts can burn 10 s on a request the user abandoned at 1 s. Rule: every gRPC call sets a deadline; no infinite defaults survive code review. Servers should check Context.current().isCancelled() before expensive work — the client may be long gone.

The load-balancing caveat

gRPC = long-lived HTTP/2 connections carrying many calls. An L4 (connection-level) balancer balances connections, not calls — 3 pods, 3 connections, and one chatty client pins one pod at 100% while the LB dashboard shows "balanced". Fixes: an L7/HTTP-2-aware proxy (Envoy — per-call balancing), client-side LB with service discovery, or connection MAX_CONNECTION_AGE to force periodic re-balance. This single fact explains most "gRPC load is lumpy" incidents (full treatment: Module 08).

Streaming semantics

Errors are first-class: status codes (DEADLINE_EXCEEDED, UNAVAILABLE, RESOURCE_EXHAUSTED) with typed detail payloads — and retry policy should key on them: retry UNAVAILABLE, never retry INVALID_ARGUMENT, retry DEADLINE_EXCEEDED only at the top with budget left.

STEP 03

Selection framework

MANDATORY

Answer per edge, not per company. Tick what's true for the edge you're designing:

Style advisor

The defaults it encodes — also the defensible-in-review shape for your estate: gRPC inside (owned both ends, contracts + codegen + deadlines), REST at the public edge (reach + caching), GraphQL only at a BFF layer where screen-aggregation pain is real (never service↔service), SSE before WebSockets unless the client genuinely pushes, and events via the message bus rather than any of these when the interaction is notification-shaped (Module 15).

STEP 04

Versioning & compatibility discipline

MANDATORY

With hundreds of services, you can never deploy both sides of an edge atomically — so every change must be compatible with the version currently running on the other side. The rules:

Staff expectationThe deeper habit: expand–migrate–contract as the shape of every change — API fields, DB columns, topic schemas, config. Add the new alongside the old, migrate consumers with metrics proving it, contract only when usage hits zero. Engineers who internalize this stop causing the "safe refactor" outages that dominate incident reviews in large estates.
STEP 05

Code: the parts worth copying

SKIM
Fast-track takeawayTwo snippets: a proto that follows every §04 rule (numbered fields, reserved, additive evolution) and a Java client call with deadline + status-aware retry. If your services already look like this, skip; if any call site lacks a deadline, that's this week's tech-debt ticket.

A · Contract that evolves safely

syntax = "proto3";
package ott.entitlement.v1;

message CheckRequest {
  string user_id    = 1;
  string content_id = 2;
  reserved 3;                         // was device_type — removed, number retired forever
  string device_id  = 4;              // added later: old servers ignore, old clients omit —
}                                       // so absence MUST be handled as "unknown device"

service EntitlementService {
  rpc Check(CheckRequest) returns (CheckResponse);
  rpc WatchGrants(WatchRequest) returns (stream GrantEvent);  // server streaming
}

B · Deadline + status-aware retry

CheckResponse check(String userId, String contentId) {
  try {
    return stub
        .withDeadlineAfter(250, TimeUnit.MILLISECONDS)   // ALWAYS. propagates downstream
        .check(CheckRequest.newBuilder()
            .setUserId(userId).setContentId(contentId).build());
  } catch (StatusRuntimeException e) {
    return switch (e.getStatus().getCode()) {
      case UNAVAILABLE      -> retryOnceWithBudget();     // transient: retry is correct
      case DEADLINE_EXCEEDED -> fallbackDeny("latency");  // budget gone: do NOT pile on
      default               -> throw e;                  // INVALID_ARGUMENT etc: a bug, surface it
    };
  }
}
STEP 06

Exercise

MANDATORY

Fast trackStep 2 only (~30 min): breaking compatibility on purpose and catching it with tooling is the durable lesson.

1
Build both edges. Entitlement check as REST (Spring Web) and gRPC (grpc-spring-boot-starter) on the same service. Load-test both with identical logic; record p50/p99 and CPU at 2k RPS. Attribute the difference (serialization? connection handling?) with a profiler, not a guess.
2
Break the contract, get caught. Add buf breaking-change detection to CI. Renumber a field — watch CI fail. Then do it "right": reserved + new field, old client jar against new server, prove both directions work. Keep the CI config; it goes in your platform template repo.
3
Stream a match. Server-streaming RPC pushing score events; client with deadline and mid-stream reconnect handling. Kill the server mid-stream; make the client resume from last event ID — you've just invented the resume protocol SSE gives free (Last-Event-ID); note the comparison.
4
Paper. For five real edges of your platform (app→BFF, BFF→entitlement, service↔service internal, third-party partner API, live-score push), assign a style with a two-sentence defense each, using §03.

Self-check

Why do per-hop timeouts compose badly, and what does gRPC replace them with?
Per-hop timeouts don't know the caller's remaining patience: five sequential hops × 2 s each can spend 10 s on a request the user abandoned after 1 s — while retries multiply the waste. gRPC propagates an absolute deadline; each hop sees remaining budget, can degrade or refuse fast, and work stops when the answer became worthless.
Your gRPC traffic hits one pod of three. LB dashboard says healthy. Explain and give two fixes.
L4 balancing of long-lived HTTP/2 connections: it balanced the 3 connections, but call volume per connection is unequal. Fixes: L7/HTTP-2-aware proxy balancing per-call (Envoy), client-side LB over discovered endpoints, or bounded connection age forcing periodic redistribution.
When is GraphQL the wrong answer even though the client aggregates many services?
Service↔service: both ends are owned, so over-fetching is fixed by changing the API — you'd pay resolver complexity, query-cost policing, and cache loss for flexibility nobody needs. Also wrong when responses are highly cacheable at the CDN (POST-shaped queries defeat HTTP caching without persisted-query machinery).
Why must "field absent" be semantically safe in proto3, and what's the OTT-flavored trap?
Proto3 fields are optional with defaults, and version skew guarantees some peer won't send new fields. Trap: add region_restriction to CheckRequest and treat absence as "no restriction" — every old client bypasses geo-blocking. Absence must map to the safe default (deny/unknown), or the rollout is a security hole.