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.
§01 takeaway + payload tool · 10 min
§02 gRPC in full — especially deadlines & L7-LB caveat · 25 min · MANDATORY
§03 selection framework + advisor · 15 min · MANDATORY
§04 versioning rules, in full · 15 min · MANDATORY
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.
Style
Contract
Sweet spot
You inherit
REST/JSON
OpenAPI (optional discipline)
public APIs, CRUD, anything cacheable
verbosity, loose typing, over-fetching
gRPC
protobuf (enforced)
service↔service, streaming, polyglot codegen
HTTP/2-aware LB, browser needs grpc-web, opaque on the wire
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
Server streaming — one request, response stream: live score feeds, watch-progress sync down.
Bidirectional — independent streams both ways: session control channels. With flow control inherited from HTTP/2 — backpressure you don't have to build (and with WebSockets, you do).
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:
Protobuf: field numbers are the contract — never reuse or renumber; only add fields (new fields are unknown-but-preserved to old readers); mark removed fields reserved; never change a field's type; everything effectively optional with defaults, so absence must be semantically safe.
JSON/REST: consumers must ignore unknown fields (Jackson: FAIL_ON_UNKNOWN_PROPERTIES=false — deliberately, estate-wide); additive changes only; removals/renames go through deprecate → dual-write → migrate → remove.
Breaking changes get a new major surface (/v2, new proto package) with both versions served during migration — and a sunset date enforced by gateway metrics on per-consumer version usage, or v1 lives forever.
Contract tests in CI: compatibility checked mechanically (buf breaking-change detection for proto; consumer-driven contracts for REST). Review-time vigilance does not scale to 200 services; pipelines do.
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;
messageCheckRequest {
string user_id = 1;
string content_id = 2;
reserved3; // was device_type — removed, number retired foreverstring device_id = 4; // added later: old servers ignore, old clients omit —
} // so absence MUST be handled as "unknown device"serviceEntitlementService {
rpcCheck(CheckRequest) returns (CheckResponse);
rpcWatchGrants(WatchRequest) returns (streamGrantEvent); // server streaming
}
B · Deadline + status-aware retry
CheckResponsecheck(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 correctcase DEADLINE_EXCEEDED -> fallbackDeny("latency"); // budget gone: do NOT pile ondefault -> 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.