← All Posts

API Design Patterns — REST, gRPC, and GraphQL in Practice

Matthias Bruns · · 6 min read
api backend architecture rest grpc graphql

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:

LayerPatternWhy
Public APIREST + OpenAPIDeveloper experience, caching, broad tooling
Internal servicesgRPCPerformance, type safety, streaming
Frontend gatewayGraphQLClient-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:

  1. URI versioning (/v1/users, /v2/users) — simple, visible, but leads to URL proliferation
  2. Header versioning (Accept: application/vnd.api+json;version=2) — cleaner URLs, but harder to test in a browser
  3. 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:

  1. Who consumes the API? Public developers → REST. Internal services → gRPC. Complex frontend → GraphQL.
  2. What’s the data shape? Predictable → REST. Varied per client → GraphQL. Binary/streaming → gRPC.
  3. What’s the team’s experience? Go with what the team knows, unless there’s a compelling technical reason not to.
  4. What’s the performance requirement? Sub-millisecond inter-service calls → gRPC. Standard web latency → REST or GraphQL.
  5. 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.

Reader settings

Font size