TL;DR
- API design is long-term contract design, not just route shape and JSON naming.
- I choose REST, GraphQL, or gRPC by consumer shape, caching needs, latency path, and who owns both sides of the call.
- Idempotency, additive evolution, pagination, and deprecation telemetry are the patterns that keep APIs survivable at scale.
- Principal-level API work is mostly about safe evolution and failure handling, not about inventing clever endpoint structures.
Context
APIs are one of the fastest ways to create multi-team debt. Once a client depends on a contract, every shortcut becomes somebody else’s migration burden later.
I usually separate the API discussion into three layers:
- edge-facing APIs for web or mobile clients
- internal service-to-service APIs
- asynchronous contracts carried by events
Each one wants different guarantees, different ergonomics, and different operational habits.
The Approach
1. Match the protocol to the consumer and the path
My default heuristics:
- REST for public or edge-facing APIs where HTTP semantics and caching matter
- GraphQL when clients need flexible aggregation across many domains and I want one composable query surface
- gRPC for hot internal service-to-service paths where strong typing, streaming, and lower serialization overhead matter
I do not pick one protocol globally for ideology. I pick the protocol that fits the boundary.
2. Design writes for retry from day one
For orders, payments, and any action with irreversible side effects, I want explicit idempotency:
POST /orders
Idempotency-Key: 42de8e0d-...
The behaviors I care about:
- same key + same body → replay previous result
- same key + different body → reject as client error
- same key while in-flight → document whether I block, poll, or return conflict
The point is to make client retries safe even during network failure, timeouts, or user double-submit behavior.
3. Prefer additive evolution over version churn
I assume contract removal is expensive. My default evolution policy is:
- add optional fields first
- keep old fields while clients migrate
- add deprecation signals and telemetry
- remove only when usage data says it is safe
Versioning is the escape hatch, not the primary design strategy.
4. Choose pagination for the data shape, not the UI slogan
My simple rule:
- offset pagination for internal tools and small datasets
- cursor/keyset pagination for feeds, timelines, or constantly mutating high-volume datasets
Offset is easy to explain but degrades badly on deep pages. Keyset is more scalable but requires stable indexed sort keys and a more opinionated contract.
5. Use BFFs for orchestration, not domain ownership
I like a backend-for-frontend when the UI needs:
- auth-aware shaping
- fan-out to many downstream services
- fewer client round trips
- client-specific projection
But I keep durable business rules out of the BFF. If pricing, entitlement, or order state rules live there permanently, I am usually compensating for the wrong backend contract.
6. Treat API telemetry as part of the design
At principal level, I want the API to tell me:
- which version clients are calling
- which deprecated fields are still in use
- where latency and error rates cluster
- which consumers break when I evolve the contract
Without that, deprecation and compatibility work becomes guesswork.
Trade-offs
- REST is operationally simple and cache-friendly, but it can push composition cost to clients or BFFs.
- GraphQL improves client flexibility, but it moves complexity into schema governance, resolver efficiency, and field ownership.
- gRPC is excellent for internal contracts, but it is a poor direct browser-facing default.
- Idempotency and additive evolution make APIs safer, but they add storage, policy, and operational discipline requirements.
References
- Related: Next.js Route Handlers & BFF
- Related: Next.js Server Actions
- Related: Frontend System Design
- Google API Design Guide
- GraphQL Best Practices