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.

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 input argument, one Payload return — 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 userErrors as data — "email already taken", "out of stock". Reserve the top-level errors array for unexpected/system failures. This is the single most useful mutation convention.
  • Name mutations verbNoun (createOrder, cancelOrder), not orderCreate.

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.

ThreatDefense
Deeply nested query (user → friends → friends → …)Depth limiting — reject beyond N levels
Expensive but shallow queryCost/complexity analysis — assign each field a cost, cap the total
Thousands of aliased fields in one requestAlias & field-count limits
Batched array of operationsCap batch size, or disable query batching
Schema scraping / probingDisable introspection in production; expose it only to authed internal tools
Arbitrary query shapesPersisted-query allowlist — production accepts only hashes of pre-registered operations
Slow resolver chainsPer-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.customer must be checked the same as one fetched via user(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 code in extensions — clients branch on extensions.code, never on message text.
  • Mask internal errors in production. A raw stack trace or SQL string in message is an information leak; map unexpected errors to a generic INTERNAL_SERVER_ERROR and 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
}
  • @key declares how an entity is identified across subgraphs, so the gateway can stitch a User from Accounts to its orders from 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 .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