API Design
REST conventions, GraphQL schema design, gRPC service definitions — and when to pick which
API Design
An API is a contract. The server promises to accept certain inputs and return certain outputs. A well-designed API is predictable: a developer who has used three of your endpoints can guess the shape of the fourth.
This page covers the three dominant styles — REST, GraphQL, gRPC — with practical conventions for each. The goal is not to be exhaustive (the specs are long) but to cover the decisions that matter most day to day.
REST
REST is the default for public-facing HTTP APIs. Its strength is simplicity: standard HTTP methods, standard status codes, URLs as resource identifiers. Its weakness is that "REST" means different things to different teams — the conventions below are the pragmatic subset that most successful APIs follow.
Resource Naming
GET /users # list
GET /users/42 # single resource
POST /users # create
PATCH /users/42 # partial update
DELETE /users/42 # delete
GET /users/42/orders # sub-resource list
POST /users/42/orders # create sub-resourceRules:
- Plural nouns, not verbs.
/users, not/getUsers. - Lowercase, hyphen-separated for multi-word resources:
/order-items, not/orderItems. - Nest sparingly.
/users/42/ordersis fine./users/42/orders/7/items/3/variantsis a sign your resources need flattening. - Actions that don't map to CRUD get a verb sub-resource:
POST /orders/42/cancel. Don't fight the model — some operations are not CRUD.
Status Codes
Use them. The client should be able to branch on the status code without parsing the body.
| Code | When |
|---|---|
200 OK | Successful GET, PATCH, or action |
201 Created | Successful POST that created a resource (include Location header) |
204 No Content | Successful DELETE |
400 Bad Request | Validation error — malformed input |
401 Unauthorized | Missing or invalid authentication |
403 Forbidden | Authenticated but not authorized |
404 Not Found | Resource does not exist |
409 Conflict | State conflict (duplicate, version mismatch) |
422 Unprocessable Entity | Input is well-formed but semantically invalid |
429 Too Many Requests | Rate limited |
500 Internal Server Error | Unhandled server failure |
Do not return 200 with an error body. This is the single most common REST anti-pattern.
Pagination
For list endpoints, choose one strategy and use it everywhere:
// Offset-based (simple, bad for large datasets)
GET /users?page=3&per_page=20
// Cursor-based (stable, performant)
GET /users?cursor=eyJpZCI6NDJ9&limit=20
// Response envelope
{
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6NjJ9",
"has_more": true
}
}Cursor-based pagination is almost always better. Offset pagination breaks when rows are inserted or deleted between pages. The cursor is opaque to the client — usually a base64-encoded primary key or timestamp.
Versioning
When you must break compatibility:
| Strategy | Example | Trade-off |
|---|---|---|
| URL path | /v2/users | Simple, explicit; hard to share middleware |
| Header | Accept: application/vnd.api+json;version=2 | Clean URLs; easy to miss |
| Query param | /users?version=2 | Easy to test; feels wrong |
URL path versioning wins for simplicity. Version the entire API, not individual endpoints. And version rarely — additive changes (new fields, new endpoints) don't need a new version.
Error Format
Pick a consistent error envelope:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email is required",
"details": [
{ "field": "email", "issue": "required" }
]
}
}Machine-readable code, human-readable message, structured details for field-level errors. Every error endpoint returns this shape.
GraphQL
GraphQL shines when clients need flexibility over the shape of the response — mobile vs. web, different pages needing different slices of the same data. It is not a replacement for REST; it is an alternative that trades simplicity for flexibility.
When to Use It
- Multiple clients with different data needs. A mobile app wants 3 fields; the web dashboard wants 30. REST would need either over-fetching or multiple endpoints.
- Deeply nested, relational data. A single query can traverse relationships that REST would need 4 round trips to resolve.
- Rapid iteration on the frontend. Frontend developers can change what they fetch without backend changes.
When to Avoid It
- Simple CRUD APIs. REST is simpler and better tooled.
- File uploads. GraphQL can handle them, but it's clunky.
- Public APIs for external developers. REST is more widely understood and easier to document.
Schema Design
type User {
id: ID!
email: String!
name: String!
orders(first: Int, after: String): OrderConnection!
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
}
type OrderEdge {
cursor: String!
node: Order!
}Key patterns:
- Connection pattern for lists. Even if you don't need pagination now, the connection pattern (
edges,node,pageInfo) makes it backward-compatible to add later. - Non-nullable by default. Mark fields
!unless they are genuinely optional. This forces you to handle missing data at the schema level, not at the client. - Input types for mutations. Don't pass 10 arguments — wrap them in an
inputtype.
Mutations: Input And Payload Types
input CreateOrderInput {
userId: ID!
items: [OrderItemInput!]!
}
type CreateOrderPayload {
order: Order
userErrors: [UserError!]!
}
type UserError {
field: [String!]! # path to the offending input field
message: String!
code: ErrorCode!
}
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderPayload!
}- One
inputargument, onePayloadreturn — both dedicated object types, so you can add fields later without a breaking signature change. - Return the mutated entity so clients refresh their cache without a refetch.
- Expected, business-rule failures go in
userErrorsas data — "email already taken", "out of stock". Reserve the top-levelerrorsarray for unexpected/system failures. This is the single most useful mutation convention. - Name mutations
verbNoun(createOrder,cancelOrder), notorderCreate.
The N+1 Problem
The most common GraphQL performance issue:
query {
users { # 1 SQL query
orders { # N SQL queries (one per user)
items { ... } # N×M SQL queries
}
}
}Solutions:
- DataLoader — batches and caches database calls within a single request. This is non-optional in any serious GraphQL server.
- Query complexity analysis — reject queries that would cost too much before executing them.
- Persisted queries — allow only pre-approved query shapes in production.
// DataLoader example (Node.js)
const userLoader = new DataLoader(async (ids) => {
const users = await db.query('SELECT * FROM users WHERE id = ANY($1)', [ids]);
return ids.map(id => users.find(u => u.id === id));
});
// In resolver
resolve(parent) {
return userLoader.load(parent.userId);
}Securing A Public Endpoint
REST's fixed endpoints can't be asked for arbitrarily deep or expensive data. GraphQL hands the query planner to the caller, so you must bound it.
| Threat | Defense |
|---|---|
Deeply nested query (user → friends → friends → …) | Depth limiting — reject beyond N levels |
| Expensive but shallow query | Cost/complexity analysis — assign each field a cost, cap the total |
| Thousands of aliased fields in one request | Alias & field-count limits |
| Batched array of operations | Cap batch size, or disable query batching |
| Schema scraping / probing | Disable introspection in production; expose it only to authed internal tools |
| Arbitrary query shapes | Persisted-query allowlist — production accepts only hashes of pre-registered operations |
| Slow resolver chains | Per-request timeout / query deadline in context |
Persisted-query allowlisting is the strongest single control for a first-party API: if the only queries that run are the ones your own clients shipped, depth and cost attacks largely disappear.
Authorization Happens In Resolvers
Authentication (who is this?) belongs in middleware that populates context. Authorization (can they see this field?) belongs at the resolver, because one query can touch many types.
const resolvers = {
Order: {
// field-level: only the owner or an admin sees the cost breakdown
internalCost: (order, _args, ctx) => {
if (!ctx.user.isAdmin && order.userId !== ctx.user.id) return null;
return order.internalCost;
},
},
};- Never trust the query shape for authz. The client decides what to request; the server decides what to return.
- Guard the entity, not the query root. A user reachable via
order.customermust be checked the same as one fetched viauser(id:). - Schema directives (
@auth(requires: ADMIN)) centralize the rule, but enforcement still runs per resolver.
Server-Side Errors
A response can carry both data and errors — resolve 9 of 10 fields and you return the 9 plus an error for the 10th. Design for partial success.
{
"data": { "order": { "id": "1", "customer": null } },
"errors": [{
"message": "Forbidden",
"path": ["order", "customer"],
"extensions": { "code": "FORBIDDEN" }
}]
}- Put a machine-readable
codeinextensions— clients branch onextensions.code, never onmessagetext. - Mask internal errors in production. A raw stack trace or SQL string in
messageis an information leak; map unexpected errors to a genericINTERNAL_SERVER_ERRORand log the detail server-side with a correlation id. - Nullability controls blast radius. An error in a non-null field bubbles up and nulls the nearest nullable parent — an over-eager
!can turn one failed field into a null'd whole screen.
Schema Evolution & Deprecation
GraphQL is versionless by design — you evolve one schema instead of shipping /v2.
type User {
name: String! @deprecated(reason: "Use displayName; removal 2026-09")
displayName: String!
}- Additive changes are safe: new fields, new types, new optional arguments.
- Breaking changes: removing/renaming a field, making a nullable field non-null, adding a required argument. Deprecate first, watch field-usage analytics, remove only when traffic hits zero.
- Run a schema-diff check in CI (e.g.
graphql-inspector) that fails the build on a breaking change unless explicitly acknowledged.
Federation: One Graph, Many Teams
When multiple teams own different domains, a monolith schema becomes a merge bottleneck. Federation lets each team own a subgraph; a gateway composes them into one graph the client sees as a single endpoint.
# Orders subgraph extends the User type owned by the Accounts subgraph
type User @key(fields: "id") {
id: ID!
orders: [Order!]! # resolved by the Orders team
}@keydeclares how an entity is identified across subgraphs, so the gateway can stitch aUserfrom Accounts to itsordersfrom Orders.- The gateway resolves cross-subgraph references with extra hops — watch the fan-out; it's the federated version of N+1.
- Prefer federation over older schema stitching for new systems: the composition is declarative and validated at build time.
gRPC
gRPC uses Protocol Buffers for schema definition and HTTP/2 for transport. It is the default for internal service-to-service communication when performance matters.
When to Use It
- Internal microservice communication. Strong typing, code generation, and binary serialization make it fast and safe.
- Streaming. Server-side, client-side, and bidirectional streaming are first-class.
- Polyglot environments. One
.protofile generates clients in Go, Java, Python, TypeScript, and more.
Protobuf Definition
syntax = "proto3";
package orders;
service OrderService {
rpc CreateOrder(CreateOrderRequest) returns (Order);
rpc GetOrder(GetOrderRequest) returns (Order);
rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
rpc StreamOrderUpdates(StreamRequest) returns (stream OrderUpdate);
}
message CreateOrderRequest {
string user_id = 1;
repeated OrderItem items = 2;
}
message Order {
string id = 1;
string user_id = 2;
repeated OrderItem items = 3;
OrderStatus status = 4;
google.protobuf.Timestamp created_at = 5;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_CONFIRMED = 2;
ORDER_STATUS_SHIPPED = 3;
}Conventions:
- Field numbers are forever. Once a field number is assigned, never reuse it — even if you remove the field.
- Enum zero value = UNSPECIFIED. This catches unset fields.
repeatedfor lists, not wrapper types.- Use
google.protobuf.Timestampfor times, not int64.
Streaming
// Server streaming — server sends many, client receives
rpc StreamOrderUpdates(StreamRequest) returns (stream OrderUpdate);
// Client streaming — client sends many, server receives
rpc UploadChunks(stream Chunk) returns (UploadResult);
// Bidirectional streaming
rpc Chat(stream Message) returns (stream Message);gRPC streaming over HTTP/2 is more efficient than WebSocket for structured, typed data between services.
Comparison
| Dimension | REST | GraphQL | gRPC |
|---|---|---|---|
| Transport | HTTP/1.1 or 2 | HTTP/1.1 or 2 | HTTP/2 |
| Format | JSON | JSON | Protobuf (binary) |
| Schema | OpenAPI (optional) | SDL (required) | Protobuf (required) |
| Typing | Loose | Strong | Strong |
| Browser support | Native | Native | Needs grpc-web proxy |
| Streaming | SSE or WebSocket | Subscriptions | Native (4 patterns) |
| Caching | HTTP caching works | Hard (POST bodies) | Manual |
| Best for | Public APIs, simple CRUD | Flexible client queries | Internal services, streaming |
| Tooling maturity | Excellent | Good | Good |
| Learning curve | Low | Medium | Medium-High |
The Decision
Is it a public API for external developers?
→ REST (with OpenAPI docs)
Is it internal, between services you control?
→ gRPC (unless your team is all-JS — then REST or tRPC)
Do multiple clients need different data shapes from the same endpoint?
→ GraphQL
Is it a simple CRUD web app with one client?
→ REST (don't over-engineer)Most production systems use more than one style. REST for the public API, gRPC between internal services, GraphQL for the client-facing BFF (Backend for Frontend). That is fine. The mistake is using one style where another clearly fits better because "we already use X."