Module 10: API Design Tradeoffs in Practice
Why this matters
Every prior module gave you a piece: when to leave REST (00), gRPC (01–02), GraphQL (03–04), how to keep consumers updated (05), and the edge components that sit in front — gateways and BFFs (06). Real systems don't use these one at a time. A mature product is almost always multi-paradigm: a public REST API for partners, internal gRPC between services, a GraphQL BFF for the apps, webhooks for outbound events. Module 00 sketched that end state; this module is about living in it — the practical decisions that only show up once several paradigms coexist in one system and have to evolve together over years.
Three of those decisions are where teams get burned. Mixing paradigms coherently (not letting the same domain logic fork three ways behind three protocols). Versioning across paradigms, because REST, gRPC, and GraphQL each evolve their contracts differently — a URL version, a Protobuf field number, an additive-only graph — and a change that's safe in one is a silent break in another (a theme the cumulative review will press on). And documentation strategy, because a system with three contract styles needs three kinds of docs that stay honest, or integrators drown. None of this is new technology — it's the judgment that ties the whole track together into a system you could actually run and grow.
This is the last standard module, so it's also where the track's design philosophy has to become a habit: every paradigm, update mechanism, and edge component is a cost that must earn its keep against a concrete pain (module 00), and every contract is a promise you have to evolve without breaking. Get this module right and you can walk into a real system, read its API surface, and reason about where each paradigm belongs and how to change it safely.
Concepts
Mixing paradigms in one system without forking your domain
The realistic topology from module 00 — public REST + internal gRPC + GraphQL BFF + webhooks — only works if the paradigms are edges onto one domain, not three reimplementations of it. The failure mode is letting "create an order" grow three subtly different implementations behind the REST controller, the gRPC handler, and the GraphQL mutation. The discipline:
- Domain logic lives in one place; paradigms are adapters. A single
create_order(...)service function is called by the REST route, the gRPC method, and the GraphQL resolver. Each paradigm handles only its own translation (parsing, shaping, status mapping); none owns business rules. This is the hexagonal/ports-and-adapters instinct, and it's what keeps a multi-paradigm system from drifting into three inconsistent products. - Each edge does what it's best at. Partners get REST (versioned, cacheable, universal). Internal service-to-service calls are gRPC (typed, fast). The apps get a GraphQL BFF (client picks fields, aggregates services). Outbound events are webhooks (server-to-server push). The assignment is module 00's consumer-first framework applied per edge — not a house style imposed everywhere.
- The BFF is the seam between worlds. It speaks the client's paradigm on the outside (GraphQL) and calls the internal one (gRPC) inside — the translation layer from module 06. That seam is where over-/under-fetch gets resolved and where you avoid exposing internal service shapes to clients.
The one-liner: one domain, many edges. Mixing paradigms is a feature of a mature system; forking your domain behind them is the bug.
partners ──► REST route ─┐
own apps ──► GraphQL resolver├─► create_order() ──► orders_repo
internal ──► gRPC handler ─┘ ONE domain function (one DB)
outbound ──► webhook emitter ┘ (qty<=0 rule lives here)
each edge only TRANSLATES (parse/shape/status-map); none owns business rules
Versioning across paradigms: same goal, three mechanisms
Every contract will change; the skill is changing it without silently breaking consumers, and each paradigm evolves differently. The unifying principle is additive, backward-compatible change — but its shape differs:
- REST versions explicitly and coarsely: a URL prefix (
/v1/,/v2/) or a version header, plus additive changes within a version (add fields, never remove/rename/repurpose; never change a field's meaning). Public REST APIs lean on this because outside integrators need a stable, obvious contract and a long deprecation window (track 02's versioning discipline). - gRPC / Protobuf versions at the field-number level: you add new fields
with new tags and never reuse or renumber a tag — renumbering silently
corrupts data on the wire (the exact silent-contract bug the cumulative reviews
hammer). Removing a field means
reserved-ing its number so it's never reused. Compatibility is per-message, fine-grained, and enforced by the tag discipline, not by a URL. - GraphQL versions by evolving one schema additively — the community norm
is no versioned endpoints at all. You add types/fields freely; you deprecate
fields with
@deprecated(reason: ...)rather than removing them, and you watch which fields clients actually select before retiring anything. Removing or changing a field's type is the breaking change to avoid.
The through-line: additive is safe; removing/renaming/repurposing is a break in every paradigm — they just detect and manage it differently (a URL bump, a reserved tag, a deprecation directive). A change that looks harmless in one (bump the version) is catastrophic in another (renumber a tag) if you don't respect each mechanism.
The public/internal contract boundary
A recurring practical tension: your internal contracts (gRPC between your services) can evolve fast because you own both ends and deploy them together; your public contracts (REST/GraphQL for partners and apps) must evolve slowly and compatibly because you don't control the consumers. This asymmetry drives real decisions:
- Never leak internal shapes to the outside. Don't expose a gRPC-generated message straight to a partner — you'd couple their integration to your internal refactors. The BFF/gateway (module 06) is where you map internal shapes to stable external ones, so an internal rename doesn't break a client.
- Different change velocities, different processes. Internal gRPC can add a field and redeploy caller + callee together this afternoon. A public REST field change ships behind a version and a deprecation window measured in months. Treat the boundary as a firewall between "fast, coordinated change" and "slow, compatible change."
- The gateway owns the external-stability guarantees. Routing at the edge (module 06) means you can split, move, or rewrite an internal service without the external URL changing — the stable external surface over a changing internal topology.
Documentation strategy for a multi-paradigm system
Three contract styles need three kinds of docs, and the win is that each paradigm has a schema-first, generated option so docs don't rot into fiction:
- REST → OpenAPI. FastAPI generates it from your types; it drives Swagger UI, client SDKs, and contract tests. The doc is the schema (track 02).
- gRPC → the
.protofiles are the contract and the doc; tooling generates reference docs and, crucially, typed client stubs in every language, so the "documentation" is machine-checked and can't drift from the wire format. - GraphQL → introspection + the SDL. The schema is self-documenting; GraphiQL/Playground and tools like SpectaQL render it, and field descriptions live in the schema. Clients discover the graph directly.
- Above all three: a human-written map. Generated reference docs tell you what each endpoint is; they don't tell an integrator which paradigm to use for what, the auth model, rate limits, versioning policy, and the deprecation calendar. A short "how our API surface is organized" guide — the module-00 topology, written down — is the doc that prevents a partner from trying to speak gRPC to your public edge.
The principle: prefer generated, schema-derived docs (they stay honest) and
add one hand-written orientation doc that explains the multi-paradigm shape
no generator can infer. That's the same "the schema is the contract" instinct
running through gRPC's .proto, REST's OpenAPI, and GraphQL's SDL.
Choosing and evolving the mix over time
Systems aren't born multi-paradigm; they grow into it, and module 00's discipline governs the sequence. A healthy evolution, each step justified by a concrete pain:
- Start REST, one service. It serves everyone until a real limit bites.
- Add gRPC when internal service-to-service traffic gets hot enough that JSON/HTTP overhead and untyped payloads are a measured tax (modules 01–02).
- Add a GraphQL BFF when a rich client (or a second client) is suffering over-/under-fetch against the general API (modules 03–04, 06).
- Add webhooks/streaming when a consumer genuinely needs server-initiated updates (module 05, track 06).
- Add a gateway when edge policy (auth, rate limit, routing) is duplicated across enough services or a partner program needs central key management (module 06).
Each step is reversible reasoning: if the pain isn't concrete and present, you don't take it. The end state looks impressive, but it's the accumulation of justified decisions, never a big-bang "let's be a gRPC + GraphQL + gateway company." That accumulation-by-justification is the entire track's thesis.
Command reference
| Decision | REST | gRPC | GraphQL |
|---|---|---|---|
| Primary consumer | Public / partners | Internal services | Own apps (BFF) |
| Contract artifact | OpenAPI (generated) | .proto files |
SDL / introspection |
| Versioning unit | URL prefix /v1 |
Field number (tag) | One evolving schema |
| Safe change | Add fields | Add new tag | Add type/field |
| Breaking change | Remove/rename/repurpose | Reuse/renumber a tag | Remove/retype a field |
| Retire a field | New version + deprecation | reserved the tag |
@deprecated(reason:) |
| Docs tooling | Swagger UI | proto docs + stubs | GraphiQL / SpectaQL |
One domain function, three paradigm adapters (no forked business logic):
# domain/orders.py — the ONE place business rules live
def create_order(user_id: int, item_id: int, qty: int) -> Order:
if qty <= 0:
raise InvalidQuantity() # one rule, enforced everywhere
return orders_repo.create(user_id, item_id, qty)
# REST adapter (public) — translate HTTP <-> domain, map errors to status codes
@app.post("/v1/orders", status_code=201)
def rest_create_order(body: CreateOrderIn):
try:
return create_order(body.user_id, body.item_id, body.qty)
except InvalidQuantity:
raise HTTPException(422, "qty must be positive")
# gRPC adapter (internal) — translate proto <-> domain, map errors to gRPC status
class OrderService(orders_pb2_grpc.OrderServiceServicer):
def CreateOrder(self, request, context):
try:
o = create_order(request.user_id, request.item_id, request.qty)
return orders_pb2.Order(id=o.id, total=o.total)
except InvalidQuantity:
context.abort(grpc.StatusCode.INVALID_ARGUMENT, "qty must be positive")
# GraphQL adapter (BFF) — translate query <-> domain
@strawberry.mutation
def create_order(self, user_id: int, item_id: int, qty: int) -> OrderType:
try:
o = create_order(user_id, item_id, qty)
return OrderType(id=o.id, total=o.total)
except InvalidQuantity:
raise GraphQLError("qty must be positive")
Backward-compatible evolution, per paradigm (the safe move in each):
# REST: add a field within /v1 (additive, non-breaking); breaking changes go to /v2
class OrderOut(BaseModel):
id: int
total: float
currency: str = "USD" # NEW optional field — old clients ignore it, safe
// gRPC: add a NEW tag; never renumber 1/2; reserve a retired tag's number
message Order {
int64 id = 1;
double total = 2;
string currency = 3; // NEW field, NEW tag — old peers skip it, safe
reserved 4; // a removed field's tag is reserved, never reused
}
# GraphQL: add a field freely; deprecate rather than remove
type Order {
id: ID!
total: Float!
currency: String!
legacyTotalCents: Int @deprecated(reason: "Use total (major units)") # keep, don't delete
}
Hands-on exercises
Assemble a small multi-paradigm system from the earlier modules' pieces: a domain service, a public REST edge, an internal gRPC service, and a GraphQL BFF. The point is the system-level decisions, not new syntax.
1. Extract the shared domain
Take "create an order" and put its business rules in one create_order() domain
function. Have a REST route, a gRPC method, and a GraphQL mutation all call it,
each only translating in/out. Expected: the qty <= 0 rule exists in exactly one
place; all three paradigms reject a bad qty identically without duplicating the
check.
2. Map errors per paradigm
For the single InvalidQuantity domain error, map it to the right thing in each
edge: REST 422, gRPC INVALID_ARGUMENT, GraphQL error. Expected: one domain
error surfaces idiomatically in three protocols — same rule, three faithful
translations (modules 01/02 status codes, track 02 HTTP codes).
3. Assign paradigms to four consumers
For one product, assign a paradigm to each: a partner reselling your product, an
internal pricing-service called on every checkout, the web app's account
screen, and a "shipment.delivered" notification to a partner. Justify each with
module 00's framework. Expected: REST, gRPC, GraphQL BFF, webhook respectively.
4. Evolve a REST contract safely
Add a currency field to your /v1/orders response. Show that an old client
(expecting only id/total) still works. Then propose a change that would
force a /v2 and explain why it can't be additive. Expected: adding an optional
field is non-breaking within /v1; renaming/removing/retyping total forces a
new version + deprecation window.
5. Evolve a Protobuf contract safely
Add a currency field to your Order message with a new tag. Then show what
breaks if you instead renumber total from tag 2 to tag 3. Expected: the new
tag is backward-compatible (old peers skip it); renumbering silently misreads data
because the wire tag no longer matches — the classic silent gRPC contract bug.
6. Evolve a GraphQL schema safely
Add currency to your Order type, and deprecate an old legacyTotalCents
field with @deprecated instead of deleting it. Query it and confirm it still
resolves but is flagged deprecated in introspection. Expected: additive + deprecate
keeps every existing client working while steering new ones off the old field — no
versioned endpoint needed.
7. Write the orientation doc
Write the one-page "how our API surface is organized" guide: which paradigm each consumer uses, the auth model, rate limits, and the versioning/deprecation policy per paradigm. Expected: a human-readable map that a new integrator could read and immediately know to use REST (not gRPC) for the public API — the doc no generator produces.
8. Protect the public/internal boundary
You refactor an internal gRPC Order message (rename a field). Show that,
because the GraphQL BFF maps internal → external shapes, no client breaks. Then
show what would break if the BFF passed the gRPC message straight through.
Expected: with mapping, the internal rename is invisible to clients; without it,
the client contract breaks on an internal refactor — why you never leak internal
shapes.
9. Diagnose and fix
A team describes their system and is in pain. Name every problem and prescribe fixes:
"'Create order' has three implementations — one in the REST controller, one in the gRPC handler, one in the GraphQL mutation — and they've drifted: REST allows qty 0, gRPC doesn't. We version our public GraphQL API with
/v1/graphqland/v2/graphqlendpoints and maintain both. When we renamed an internal field wereserved-ed nothing and just changed the tag number, and a downstream service started reading garbage. Our public REST docs are a hand-maintained wiki that's months out of date. And we expose our internal gRPCUsermessage directly to the mobile app."
Solution
- Forked domain logic. Three "create order" implementations that drifted
(REST allows qty 0, gRPC doesn't) is the core anti-pattern. Extract one
create_order()domain function and make all three paradigms thin adapters that call it — business rules live in one place, translated per protocol. - Versioning GraphQL with endpoints.
/v1/graphql+/v2/graphqlfights GraphQL's model. Evolve one schema additively and use@deprecatedto retire fields; drop the versioned endpoints. (URL versioning is the REST mechanism, not GraphQL's.) - Renumbering a Protobuf tag. Changing a tag number silently corrupts the
wire format — exactly why a downstream service reads garbage. Add new fields
with new tags, never renumber, and
reservedany retired tag. This is a silent-contract bug, the most dangerous kind (cumulative-review theme). - Rotting hand-written REST docs. A stale wiki lies. Generate docs from OpenAPI (FastAPI does this from your types) so the doc is the schema and can't drift.
- Leaking an internal gRPC shape to a public client. Exposing the internal
Usermessage to mobile couples the client to internal refactors. Map internal → external shapes at the BFF/gateway so internal changes don't break clients (the public/internal boundary).
Lesson: a multi-paradigm system stays healthy only with one domain behind many adapters, paradigm-appropriate versioning (URL vs tag vs additive schema), generated docs, and a guarded public/internal boundary. Every failure here is one of those four disciplines skipped.
Independent challenge
No code given. Take the event-ticketing platform you've now designed the API
strategy (module 00), update mechanisms (module 05), and edge (module 06) for.
Produce its complete practical API design document: (1) the paradigm assigned
to every consumer edge and the one shared domain layer they all call; (2) the
versioning and deprecation policy for each paradigm in use (REST URL versions,
Protobuf tag discipline, GraphQL additive-with-@deprecated); (3) the
documentation strategy (which generated docs plus the hand-written orientation
guide); and (4) the growth sequence — which paradigm/edge you'd add first, second,
third as the platform scales, each gated on a concrete pain. Explicitly mark one
thing you're not building yet and the cost you avoid by waiting.
Hint
Anchor everything on one domain, many edges (this module) and the module-00
consumer-first assignment: partners → public REST (/v1, additive, long
deprecation window); internal service-to-service → gRPC (evolve by adding
tags, reserved retired ones, never renumber); web + mobile → a GraphQL BFF
(one additive schema, @deprecated to retire fields), calling the gRPC services
and mapping internal shapes to external ones so refactors don't leak; outbound
partner events → webhooks (module 05). Docs: OpenAPI for REST, .proto
for gRPC, SDL/introspection for GraphQL, plus one hand-written map of the whole
surface. The growth sequence is the module-00 discipline made concrete — REST
first, then gRPC when internal traffic is a measured tax, then the BFF when a
client suffers over-/under-fetch, then a gateway when edge policy duplicates. The
deliberate "not yet" is almost certainly GraphQL federation (module 04): don't
split the BFF into subgraphs until distinct teams own distinct parts — a single
schema is simpler until that multi-team pain is real.
Common mistakes & troubleshooting
- Forking domain logic behind each paradigm. Three implementations of "create order" drift into three inconsistent products. Keep business rules in one domain function; make REST/gRPC/GraphQL thin adapters that only translate.
- Applying one paradigm's versioning to another. URL versions are REST's;
Protobuf versions by tag; GraphQL evolves one additive schema. Versioning
GraphQL with
/v2/graphqlor renumbering a Protobuf tag are both category errors — one is redundant, one is a silent data-corruption bug. - Renumbering or reusing a Protobuf tag. The single most dangerous change in
the track — it silently corrupts the wire format with no error. Add new tags,
never renumber,
reservedretired ones. - Removing/renaming/retyping a public field. Breaking in every paradigm.
Additive changes are safe; retire fields via a new REST version, a
reservedgRPC tag, or a GraphQL@deprecateddirective with a window. - Leaking internal shapes to public clients. Exposing a gRPC-generated message straight to a partner/app couples their contract to your internal refactors. Map internal → external at the BFF/gateway.
- Hand-maintained docs that rot. A stale wiki is worse than no docs.
Generate from OpenAPI/
.proto/SDL so the doc is the schema; add only the hand-written orientation guide a generator can't produce. - Going multi-paradigm as an identity, not a response to pain. "Let's be a gRPC + GraphQL + gateway company" up front is the premature-abstraction trap. The impressive end state is an accumulation of individually justified steps (module 00).
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- State the "one domain, many edges" principle and the specific failure it prevents.
- How does each of REST, gRPC, and GraphQL version its contract, and what is the breaking change in each?
- Why is renumbering a Protobuf field tag categorically more dangerous than removing a field from a REST response?
- Why can internal (gRPC) contracts evolve faster than public (REST/GraphQL) ones, and what component guards the boundary between them?
- Describe the documentation strategy for a multi-paradigm system, including the one doc no generator can produce.
- Give the justified growth sequence from a single REST service to a full multi-paradigm system, with the pain that triggers each step.
Answers
- One domain, many edges: business logic lives in one place (a shared domain function) and each paradigm (REST/gRPC/GraphQL) is a thin adapter that only translates in/out. It prevents forking your domain — three drifting implementations of the same operation behind three protocols (e.g. REST allowing a qty the gRPC handler rejects).
- REST versions by URL prefix (
/v1→/v2) with additive changes within a version; breaking = remove/rename/repurpose a field. gRPC versions by field number/tag (add new tags, never renumber,reservedretired ones); breaking = reuse/renumber a tag. GraphQL evolves one additive schema (add freely,@deprecatedto retire); breaking = remove/retype a field. Additive is safe in all three. - Because renumbering a tag is a silent wire-format corruption — the code compiles, no error is raised, and peers misread data — whereas removing a REST field produces a visible absence a client can detect and a version/deprecation process manages. Silent breaks are the worst kind: nothing tells you until data is wrong downstream.
- Internal gRPC contracts have both ends owned and deployed together, so you can add a field and redeploy caller + callee the same afternoon; public contracts have consumers you don't control, so they must change slowly and compatibly behind versions and deprecation windows. The BFF/gateway guards the boundary by mapping internal shapes to stable external ones so internal refactors don't leak.
- Generate docs from each schema — OpenAPI for REST,
.proto(+ typed stubs) for gRPC, SDL/introspection for GraphQL — so docs can't drift from the contract. Then add the one hand-written orientation guide explaining the multi-paradigm shape (which consumer uses which paradigm, auth, rate limits, versioning policy) that no generator can infer. - REST first (one service serves everyone). Add gRPC when internal service-to-service traffic is a measured JSON/HTTP tax (modules 01–02). Add a GraphQL BFF when a rich or second client suffers over-/under-fetch (modules 03–04, 06). Add webhooks/streaming when a consumer needs server-initiated updates (module 05). Add a gateway when edge policy duplicates across services or a partner program needs central key management (module 06). Each step gated on a concrete, present pain (module 00).
Cumulative review
Closed-book. Don't reopen modules 00–07 while attempting these — this is the track's second cumulative review (the first covered 00–03), and the point is to find out what stuck across the whole arc: choosing paradigms, gRPC, GraphQL, update mechanisms, the edge, and putting it all together.
- A new product has: partners reselling your inventory, a web + mobile app
showing a data-rich "my account" screen, an internal
pricing-servicecalled ~10,000×/sec during checkout, and a requirement that buyers see inventory counts update live. Assign a paradigm and (where relevant) an update mechanism to each edge, and justify each against the module-00 framework. - Three changes ship on the same day: a REST field is renamed in
/v1, a Protobuf field is renumbered from tag 2 to tag 3, and a GraphQL field's type is changed fromInttoString. Rank them by danger, say which are silent vs loud, and give the compatible way to achieve each intended change. - Explain why authorization lives on the route in REST (track 02) but on the field/resolver in GraphQL (module 04), and how an API gateway (module 06) changes where authentication happens for both.
- A browser dashboard needs order status "within a minute"; a partner backend needs to know the instant an order ships; a trading UI needs live ticks. Name the update mechanism for each (module 05) and the one framework question that decides the partner-vs-browser difference.
- Contrast the N+1 problem (module 03) and a leaked gRPC stream (module 02): what each is, the interaction paradigm each occurs in, and the specific defense for each. Then name the one discipline both defenses share.
- A team runs six services, each re-validating JWTs and each with its own drifted rate limiter, and exposes a GraphQL endpoint to untrusted third parties. Diagnose against modules 00, 04, and 06, and prescribe the corrected edge.
- Trace a mobile app fetching an "order summary" through the full mature topology: name every hop (client → … → data), the paradigm/protocol on each hop, and what each layer is responsible for — and say which layer resolves the over-/under-fetch and which enforces the rate limit.
- Justify, as a growth sequence, why a two-person startup with one web client and a CRUD app should not start with gRPC + GraphQL + a gateway, and name the concrete pain that would later justify adding each of those three.
Answers
- Partners → REST (public/third-party: universal, cacheable, low integration
cost — framework Q1). The account screen for web + mobile → a GraphQL BFF
(own frontends, rich/variable relational data, collapses aggregation, serves two
clients with different shapes — Q1 + Q3).
pricing-serviceat 10,000×/sec → gRPC (internal, extreme throughput, low latency, typed stubs — Q1 internal + Q4 performance). Live inventory counts to buyers (browsers) → a push mechanism, SSE or a GraphQL subscription if they already talk to the BFF (server-initiated, browser can't be called inbound — module 05). - Danger: renumbering the Protobuf tag is worst (silent wire corruption, no
error), then the GraphQL
Int→Stringretype (breaks clients but surfaces as errors — loud-ish), then the REST rename (visibly absent field, managed by versioning — loud). Compatible versions: Protobuf — add a new field with a new tag andreservedthe old, never renumber; GraphQL — add a new correctly-typed field and@deprecatethe old, don't retype in place; REST — add the new field and retire the old across a/v2+ deprecation window, don't rename in/v1. - REST has a route per resource/operation, so auth can guard the route (a router dependency, track 02). GraphQL has one endpoint and one query can traverse from a public field into a sensitive one, so authorization must live per field/resolver using the current user from context (module 04). An API gateway hoists authentication to the edge — it validates the credential once and forwards verified identity — so services/BFF trust it instead of each re-validating; authorization (who-can-do-what) still lives in the service/field logic.
- Browser dashboard, ~1 min tolerance → polling (cheap, REST-native, bounded freshness is fine). Partner backend, "instant" on ship → webhook (server-to-server push). Trading UI, live ticks → SSE/WebSocket (persistent push to a browser). The deciding framework question for partner-vs-browser: can the consumer receive an inbound HTTP call? — the partner server can (so a webhook works), the browser can't (so it polls or holds a socket open).
- N+1 (module 03, GraphQL): a per-item field resolver fires one
data-source query per parent (100 books → 100 author lookups); defense = a
per-request DataLoader that batches the keyed loads into one query. Leaked
stream (module 02, gRPC server-streaming): the server keeps producing
after the client has disconnected; defense = check
context.is_active()and bound/exit the loop. Shared discipline: bound the work one request/connection can cause — batch it or stop it — rather than trusting the naive path to scale. - Against module 06: six copies of JWT validation and six drifted rate limiters are duplicated edge policy → introduce one API gateway that terminates auth once and enforces one rate-limit/quota policy, routing to services that trust forwarded identity. Against module 00 / 04: exposing a GraphQL endpoint to untrusted third parties is the wrong consumer for GraphQL — public/partner integrators should get REST (cacheable, low integration cost, smaller abuse surface); reserve GraphQL for your own apps as a BFF. Corrected edge: gateway (authN, rate limit, routing) → REST for partners, a GraphQL BFF for first-party apps → gRPC to internal services.
- Client (mobile) → API gateway → BFF → services → data. Client → gateway: over the public edge (HTTPS); the gateway terminates auth, enforces the rate limit, and routes. Gateway → BFF (GraphQL): the BFF aggregates and resolves the over-/under-fetch, shaping one screen response. BFF → services over gRPC (typed, fast, same datacenter); services own domain logic. Services → data (DB/cache). The BFF resolves over-/under-fetch; the gateway enforces the rate limit; the services own the domain.
- With one client and a CRUD app there's no concrete pain any of the three solves: gRPC's typing/throughput matters between chatty internal services they don't have; a GraphQL BFF's field-picking matters for rich/multiple clients they don't have; a gateway's centralized edge policy matters across many services/partners they don't have — each would add ops burden, a hop/SPOF, or a learning curve for a benefit that isn't yet needed (module 00). Later triggers: add gRPC when internal service-to-service traffic becomes a measured JSON/HTTP tax; add a GraphQL BFF when a rich or second client suffers over-/under-fetch; add a gateway when edge policy is duplicated across enough services or a partner program needs central key/quota management.
Further reading & sources
- Google — API design guide - a comprehensive reference for designing coherent, evolvable APIs across a system, including versioning and compatibility.
- Google AIP-180 — Backwards compatibility - the concrete rules for what counts as a breaking vs additive change, applicable across paradigms.
- Protocol Buffers — Proto3: updating message types - the authoritative rules on adding, reserving, and never renumbering field tags.
- GraphQL — Best practices: versioning & deprecation - why GraphQL evolves one additive schema with
@deprecatedinstead of versioned endpoints. - Apollo — Introduction to Apollo Federation - the multi-team composition option to reach for only once distinct teams own distinct subgraphs.
- Martin Fowler — Ports and Adapters (Hexagonal Architecture) - the "one domain, many adapters" pattern behind keeping business logic out of each paradigm edge.
Next
11-edge-computing-and-webassembly — one more edge to add to module 00's map: logic that runs neither in your origin server nor in the browser, but on infrastructure physically close to the request, in a WebAssembly sandbox instead of a traditional server process.