Steven's Knowledge

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-resource

Rules:

  • Plural nouns, not verbs. /users, not /getUsers.
  • Lowercase, hyphen-separated for multi-word resources: /order-items, not /orderItems.
  • Nest sparingly. /users/42/orders is fine. /users/42/orders/7/items/3/variants is 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.

CodeWhen
200 OKSuccessful GET, PATCH, or action
201 CreatedSuccessful POST that created a resource (include Location header)
204 No ContentSuccessful DELETE
400 Bad RequestValidation error — malformed input
401 UnauthorizedMissing or invalid authentication
403 ForbiddenAuthenticated but not authorized
404 Not FoundResource does not exist
409 ConflictState conflict (duplicate, version mismatch)
422 Unprocessable EntityInput is well-formed but semantically invalid
429 Too Many RequestsRate limited
500 Internal Server ErrorUnhandled 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:

StrategyExampleTrade-off
URL path/v2/usersSimple, explicit; hard to share middleware
HeaderAccept: application/vnd.api+json;version=2Clean URLs; easy to miss
Query param/users?version=2Easy 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 input type.

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);
}

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 .proto file 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.
  • repeated for lists, not wrapper types.
  • Use google.protobuf.Timestamp for 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

DimensionRESTGraphQLgRPC
TransportHTTP/1.1 or 2HTTP/1.1 or 2HTTP/2
FormatJSONJSONProtobuf (binary)
SchemaOpenAPI (optional)SDL (required)Protobuf (required)
TypingLooseStrongStrong
Browser supportNativeNativeNeeds grpc-web proxy
StreamingSSE or WebSocketSubscriptionsNative (4 patterns)
CachingHTTP caching worksHard (POST bodies)Manual
Best forPublic APIs, simple CRUDFlexible client queriesInternal services, streaming
Tooling maturityExcellentGoodGood
Learning curveLowMediumMedium-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."

On this page