API Design Patterns — REST, gRPC, and GraphQL in Practice
The Wrong Question
“Should we use REST, GraphQL, or gRPC?” is the wrong question. The right question is: what does your system actually need?
Every API style exists because it solves a specific set of problems well. The mistake most teams make is picking one pattern for everything — or worse, picking based on what’s trending on Hacker News.
Here’s a decision framework based on real project experience, not blog hype.
REST: The Default That’s Rarely Wrong
REST is the HTTP-native choice. It’s built on top of standards everyone already knows: URLs for resources, HTTP methods for actions, status codes for outcomes. That’s its superpower.
GET /api/v1/users/42
POST /api/v1/users
PUT /api/v1/users/42
DELETE /api/v1/users/42
When REST is the right call:
- Public-facing APIs where developer experience matters
- CRUD-heavy domains (content management, admin panels, e-commerce)
- Teams that need to onboard developers quickly
- Systems where caching matters (HTTP caching is free with REST)
When REST falls short:
- Complex, nested data requirements (the classic over-fetching/under-fetching problem)
- High-performance internal service communication
- Scenarios where bandwidth matters more than developer ergonomics
The OpenAPI Specification is your best friend here. Define your API contract first, generate clients and server stubs from it. Documentation-first API design eliminates the “the API does something slightly different than what we agreed on” class of bugs entirely.
Error Handling in REST
Most REST APIs get error handling wrong. They return 200 for everything and stuff error codes in the body. Don’t do that.
// Bad: 200 OK with an error inside
{ "success": false, "error": "User not found" }
// Good: proper HTTP semantics
// 404 Not Found
{
"type": "https://api.example.com/errors/not-found",
"title": "User not found",
"status": 404,
"detail": "No user with ID 42 exists.",
"instance": "/api/v1/users/42"
}
That second format follows RFC 9457 (Problem Details for HTTP APIs). It’s a standard. Use it.
gRPC: When Performance Is Non-Negotiable
gRPC uses HTTP/2 and Protocol Buffers for binary serialization. It’s fast — significantly faster than JSON over REST for service-to-service communication.
service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc ListUsers (ListUsersRequest) returns (stream User);
}
message GetUserRequest {
string id = 1;
}
message User {
string id = 1;
string name = 2;
string email = 3;
}
When gRPC is the right call:
- Internal microservice communication where latency matters
- Streaming use cases (real-time data feeds, log streaming)
- Polyglot environments — gRPC generates clients in dozens of languages
- Mobile clients on constrained networks (binary is smaller than JSON)
When gRPC falls short:
- Browser clients (gRPC-Web exists but adds complexity)
- Simple CRUD APIs where the Protocol Buffers overhead isn’t worth it
- Teams unfamiliar with protobuf tooling
The contract-first nature of .proto files is a major advantage. Your API contract is the protobuf definition — it compiles to type-safe clients, and breaking changes are caught at build time.
GraphQL: When Clients Drive the Data
GraphQL flips the control model. Instead of the server deciding what data to return, the client specifies exactly what it needs.
query {
user(id: "42") {
name
email
orders(last: 5) {
id
total
items {
name
}
}
}
}
One request. Exactly the data the UI needs. No over-fetching.
When GraphQL is the right call:
- Complex frontends with varied data requirements (dashboards, mobile apps)
- Multiple client types consuming the same backend (web, iOS, Android each want different fields)
- Rapid frontend iteration where backend shouldn’t be the bottleneck
When GraphQL falls short:
- Simple APIs with predictable data shapes
- File uploads (possible but awkward)
- Caching (no HTTP caching by default — you need persisted queries or a CDN layer)
- Authorization at the field level gets complex fast
The N+1 problem is GraphQL’s biggest operational trap. Every resolver can trigger additional database queries. Use DataLoader patterns or tools like graphql-batch to batch requests.
The Hybrid Approach: What Production Systems Actually Look Like
In practice, most serious systems use more than one pattern. Here’s a common split:
| Layer | Pattern | Why |
|---|---|---|
| Public API | REST + OpenAPI | Developer experience, caching, broad tooling |
| Internal services | gRPC | Performance, type safety, streaming |
| Frontend gateway | GraphQL | Client-driven queries, aggregation |
This isn’t over-engineering — it’s using the right tool for each layer. The API Gateway pattern ties them together, presenting a unified interface while each backend service uses whatever protocol makes sense internally.
Versioning: The Part Nobody Wants to Think About
APIs evolve. Clients break. Versioning is how you manage this tension.
Three common strategies:
- URI versioning (
/v1/users,/v2/users) — simple, visible, but leads to URL proliferation - Header versioning (
Accept: application/vnd.api+json;version=2) — cleaner URLs, but harder to test in a browser - Query parameter (
/users?version=2) — easy to implement, but feels like a hack
Our take: URI versioning for major breaking changes, additive changes without versioning.
The key insight from Stripe’s API versioning approach is that most changes should be backward-compatible additions. New fields, new endpoints, new optional parameters. Reserve version bumps for genuine breaking changes.
Practical rules:
- Adding a field to a response is never a breaking change
- Adding an optional request parameter is never a breaking change
- Removing or renaming a field IS a breaking change
- Changing a field’s type IS a breaking change
Documentation-First: The Pattern That Prevents Pain
Write the API spec before writing the code. Use OpenAPI for REST, .proto files for gRPC, or SDL for GraphQL.
This isn’t about bureaucracy. It’s about catching design problems before they become code problems. It’s cheaper to argue about a YAML file than to refactor three services and five clients.
Tools that make this practical:
- Redocly for OpenAPI linting and documentation
- buf for protobuf linting and breaking change detection
- GraphQL Inspector for schema change tracking
The Decision Checklist
Before picking a pattern, answer these:
- Who consumes the API? Public developers → REST. Internal services → gRPC. Complex frontend → GraphQL.
- What’s the data shape? Predictable → REST. Varied per client → GraphQL. Binary/streaming → gRPC.
- What’s the team’s experience? Go with what the team knows, unless there’s a compelling technical reason not to.
- What’s the performance requirement? Sub-millisecond inter-service calls → gRPC. Standard web latency → REST or GraphQL.
- How will it evolve? Plan for versioning from day one. Additive changes are always safer than breaking changes.
Bottom Line
There’s no universal “best” API pattern. There’s only the best pattern for your specific constraints. REST is the safe default. gRPC wins on performance. GraphQL wins on flexibility. Most production systems use a mix.
Pick based on requirements, not hype. Document the contract first. Version from day one. Ship it.