Frontend Architecture
Part 5 of 11API Design Principles That Stand the Test of Time (REST vs GraphQL vs tRPC)
API Design Principles That Stand the Test of Time (REST vs GraphQL vs tRPC)
Introduction
Every few years, a new API paradigm promises to solve all our problems. REST was going to bring order to the chaos of SOAP. GraphQL was going to fix REST's over-fetching. tRPC was going to eliminate the API layer entirely.
Each delivered on some promises while creating new challenges. REST became the dominant pattern but spawned endless debates about "proper" REST. GraphQL solved the flexibility problem but introduced complexity that many teams weren't ready for. tRPC offers remarkable developer experience but limits you to TypeScript monorepos.
Here's what nobody tells you at the conference talks: the principles of good API design transcend any specific technology. Whether you're building REST endpoints, GraphQL schemas, or tRPC routers, the same fundamental questions apply:
- Is this API easy to understand?
- Will it evolve gracefully?
- Does it handle errors well?
- Can clients get what they need efficiently?
This guide covers the timeless principles first, then evaluates REST, GraphQL, and tRPC through that lens. The goal isn't to declare a winner—it's to help you choose the right tool for your specific situation and use it well.
Timeless API Design Principles
The Fundamentals
PRINCIPLES THAT APPLY TO EVERY API:
════════════════════════════════════════════════════════════════════
┌─────────────────────────────────────────────────────────────────┐
│ │
│ 1. CONSISTENCY │
│ ───────────────────────────────────────────────────────────── │
│ Similar things should work similarly. │
│ │
│ If GET /users returns { data: [...], meta: {...} } │
│ Then GET /posts should return { data: [...], meta: {...} } │
│ Not { posts: [...], pagination: {...} } │
│ │
│ Consistency in: │
│ • Naming conventions (camelCase vs snake_case) │
│ • Response structure │
│ • Error format │
│ • Authentication pattern │
│ • Pagination approach │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 2. PREDICTABILITY │
│ ───────────────────────────────────────────────────────────── │
│ Behavior should be unsurprising. │
│ │
│ A developer should be able to guess: │
│ • What an endpoint does from its name │
│ • What parameters it accepts │
│ • What it returns │
│ • How errors are communicated │
│ │
│ Bad: POST /api/v2/doTheThing │
│ Good: POST /api/v2/orders │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 3. EVOLVABILITY │
│ ───────────────────────────────────────────────────────────── │
│ APIs must change without breaking clients. │
│ │
│ Strategies: │
│ • Additive changes only (new fields, new endpoints) │
│ • Deprecation before removal │
│ • Versioning when breaking changes necessary │
│ • Never remove or rename without warning │
│ │
│ Future you will thank present you. │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 4. CLARITY OF ERRORS │
│ ───────────────────────────────────────────────────────────── │
│ When things go wrong, help the developer fix it. │
│ │
│ Bad: │
│ { "error": "Bad request" } │
│ │
│ Good: │
│ { │
│ "error": { │
│ "code": "VALIDATION_ERROR", │
│ "message": "Invalid request parameters", │
│ "details": [ │
│ { │
│ "field": "email", │
│ "issue": "Must be a valid email address", │
│ "received": "not-an-email" │
│ } │
│ ] │
│ } │
│ } │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 5. APPROPRIATE GRANULARITY │
│ ───────────────────────────────────────────────────────────── │
│ Not too coarse, not too fine. │
│ │
│ Too coarse: │
│ GET /everything → Returns entire database │
│ │
│ Too fine: │
│ GET /users/123/firstName │
│ GET /users/123/lastName │
│ GET /users/123/email │
│ (Three requests for one user!) │
│ │
│ Just right: │
│ GET /users/123 → Returns user with reasonable fields │
│ GET /users/123?include=posts,comments → Expandable │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 6. DOCUMENTATION AS A FIRST-CLASS CITIZEN │
│ ───────────────────────────────────────────────────────────── │
│ An undocumented API is an unusable API. │
│ │
│ Every endpoint needs: │
│ • What it does (plain English) │
│ • What parameters it accepts │
│ • What it returns (with examples) │
│ • What errors it can return │
│ • Authentication requirements │
│ │
│ Auto-generate when possible (OpenAPI, GraphQL introspection). │
│ │
└─────────────────────────────────────────────────────────────────┘
The Contract Mindset
AN API IS A CONTRACT:
════════════════════════════════════════════════════════════════════
You're not just writing code. You're making promises.
┌─────────────────────────────────────────────────────────────────┐
│ │
│ PROMISES YOU'RE MAKING: │
│ │
│ "If you send me X, I will return Y" │
│ "I will not remove fields you depend on" │
│ "Errors will always look like this" │
│ "This endpoint will exist at this URL" │
│ │
│ Every promise you make is a promise you must keep. │
│ Every feature you add is a feature you must maintain. │
│ │
└─────────────────────────────────────────────────────────────────┘
IMPLICATIONS:
1. SHIP LESS, NOT MORE
─────────────────────
Easy to add fields later.
Painful to remove fields ever.
Start minimal. Expand based on real needs.
2. THINK ABOUT CLIENTS YOU DON'T CONTROL
─────────────────────────────────────
Internal API? You can update all clients.
Public API? You can't.
Mobile app? Users don't update.
Design for the client you can't force to upgrade.
3. VERSIONING IS A LAST RESORT
───────────────────────────
Versions fragment your ecosystem.
Supporting v1 and v2 is twice the work.
Prefer:
• Additive changes (no version needed)
• Deprecation warnings (client can migrate)
• Version only for truly breaking changes
4. CONSIDER THE ERROR CASES FIRST
─────────────────────────────
Happy path is easy.
Error handling reveals design quality.
For every operation, ask:
• What if the resource doesn't exist?
• What if the user isn't authorized?
• What if the input is invalid?
• What if a dependent service is down?
• What if the operation partially succeeds?
Resource Modeling
GOOD RESOURCE MODELING:
════════════════════════════════════════════════════════════════════
THINK IN NOUNS, NOT VERBS
─────────────────────────
Resources are things. Operations act on things.
Bad (verb-oriented):
/getUser
/createUser
/updateUser
/deleteUser
/sendUserNotification
/resetUserPassword
/getUserOrders
/processUserRefund
Good (noun-oriented):
/users/{id} GET, POST, PUT, DELETE
/users/{id}/notifications POST
/users/{id}/password-reset POST
/users/{id}/orders GET
/orders/{id}/refund POST
FIND NATURAL HIERARCHIES
────────────────────────
Resources have relationships. Model them.
/organizations/{org}/teams/{team}/members/{member}
But don't go too deep (3 levels max):
Bad: /orgs/1/teams/2/projects/3/tasks/4/comments/5/reactions/6
Good: /comments/5/reactions (comments have their own ID)
COLLECTIONS VS ITEMS
────────────────────
Collection: /users → Multiple users
Item: /users/123 → One specific user
Collection operations:
GET /users → List users
POST /users → Create user
Item operations:
GET /users/123 → Get user
PUT /users/123 → Replace user
PATCH /users/123 → Update user
DELETE /users/123 → Delete user
AVOID RESOURCE ALIASING
───────────────────────
One resource should live at one URL.
Bad:
/users/123/profile → User's profile
/profiles/456 → Same profile, different URL
Good:
/users/123 → User with profile embedded
or
/profiles/456 → Profile (linked from user)
REST: The Workhorse
What REST Actually Is
REST (Representational State Transfer):
════════════════════════════════════════════════════════════════════
Constraints that define REST (from Roy Fielding's thesis):
1. CLIENT-SERVER
Separation of concerns. UI and data storage are separate.
2. STATELESS
Each request contains all information needed.
Server doesn't store client context between requests.
3. CACHEABLE
Responses must define themselves as cacheable or not.
Enables client and intermediary caching.
4. UNIFORM INTERFACE
Standardized way to interact with resources.
• Resource identification (URLs)
• Resource manipulation through representations
• Self-descriptive messages
• Hypermedia as the engine of application state (HATEOAS)
5. LAYERED SYSTEM
Client can't tell if connected directly to server.
Enables load balancers, caches, security layers.
6. CODE ON DEMAND (optional)
Server can extend client functionality (JavaScript).
THE REALITY:
────────────
Most "REST" APIs are actually "HTTP APIs using JSON."
They ignore HATEOAS, sometimes violate statelessness.
That's okay. Pragmatism > Purity.
What matters: predictable, well-designed HTTP APIs.
REST Design Patterns
// REST API DESIGN PATTERNS:
// ═══════════════════════════════════════════════════════════════
// ─────────────────────────────────────────────────────────────────
// CRUD OPERATIONS
// ─────────────────────────────────────────────────────────────────
// Create
POST /api/users
Content-Type: application/json
{
"email": "user@example.com",
"name": "John Doe"
}
// Response: 201 Created
{
"id": "123",
"email": "user@example.com",
"name": "John Doe",
"createdAt": "2024-01-15T10:30:00Z"
}
// Read (single)
GET /api/users/123
// Response: 200 OK
{
"id": "123",
"email": "user@example.com",
"name": "John Doe"
}
// Read (collection with pagination)
GET /api/users?page=2&limit=20&sort=-createdAt
// Response: 200 OK
{
"data": [
{ "id": "123", "name": "John Doe", ... },
{ "id": "124", "name": "Jane Doe", ... }
],
"meta": {
"page": 2,
"limit": 20,
"total": 156,
"totalPages": 8
}
}
// Update (partial)
PATCH /api/users/123
Content-Type: application/json
{
"name": "John Smith"
}
// Response: 200 OK
{
"id": "123",
"email": "user@example.com",
"name": "John Smith",
"updatedAt": "2024-01-15T11:00:00Z"
}
// Update (full replacement)
PUT /api/users/123
Content-Type: application/json
{
"email": "john@example.com",
"name": "John Smith"
}
// Delete
DELETE /api/users/123
// Response: 204 No Content
// ─────────────────────────────────────────────────────────────────
// FILTERING, SORTING, SEARCHING
// ─────────────────────────────────────────────────────────────────
// Filtering
GET /api/products?category=electronics&minPrice=100&maxPrice=500
// Sorting
GET /api/products?sort=price // Ascending
GET /api/products?sort=-price // Descending
GET /api/products?sort=-price,name // Multiple fields
// Searching
GET /api/products?search=laptop
// Field selection (sparse fieldsets)
GET /api/users/123?fields=id,name,email
// Combined
GET /api/products?category=electronics&sort=-price&fields=id,name,price&page=1&limit=20
// ─────────────────────────────────────────────────────────────────
// RELATIONSHIPS AND EXPANSION
// ─────────────────────────────────────────────────────────────────
// Default: Just IDs
GET /api/posts/123
{
"id": "123",
"title": "Hello World",
"authorId": "456"
}
// With expansion
GET /api/posts/123?include=author
{
"id": "123",
"title": "Hello World",
"authorId": "456",
"author": {
"id": "456",
"name": "John Doe"
}
}
// Multiple expansions
GET /api/posts/123?include=author,comments,tags
// ─────────────────────────────────────────────────────────────────
// NON-CRUD OPERATIONS
// ─────────────────────────────────────────────────────────────────
// Actions on resources (use POST with action noun)
POST /api/orders/123/cancel
POST /api/users/123/password-reset
POST /api/posts/123/publish
// Bulk operations
POST /api/users/bulk-delete
{
"ids": ["123", "124", "125"]
}
// Or use batch endpoint
POST /api/batch
{
"operations": [
{ "method": "DELETE", "path": "/users/123" },
{ "method": "DELETE", "path": "/users/124" }
]
}
REST Error Handling
// CONSISTENT ERROR RESPONSES:
// ═══════════════════════════════════════════════════════════════
// Error response structure
interface ApiError {
error: {
code: string; // Machine-readable code
message: string; // Human-readable message
details?: ErrorDetail[]; // Field-level errors
requestId?: string; // For debugging/support
};
}
interface ErrorDetail {
field: string;
code: string;
message: string;
}
// HTTP Status Codes (use correctly!)
// ─────────────────────────────────────────────────────────────────
// 2xx Success
200 OK // General success
201 Created // Resource created (POST)
204 No Content // Success, no body (DELETE)
// 4xx Client Errors
400 Bad Request // Invalid syntax, validation errors
401 Unauthorized // Authentication required
403 Forbidden // Authenticated but not authorized
404 Not Found // Resource doesn't exist
409 Conflict // State conflict (duplicate, version mismatch)
422 Unprocessable // Valid syntax, invalid semantics
429 Too Many Reqs // Rate limited
// 5xx Server Errors
500 Internal Error // Unexpected server error
502 Bad Gateway // Upstream service error
503 Unavailable // Service temporarily down
504 Gateway Timeout // Upstream timeout
// Example error responses
// ─────────────────────────────────────────────────────────────────
// 400 Validation Error
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Must be a valid email address"
},
{
"field": "age",
"code": "OUT_OF_RANGE",
"message": "Must be between 18 and 120"
}
],
"requestId": "req_abc123"
}
}
// 401 Unauthorized
{
"error": {
"code": "UNAUTHORIZED",
"message": "Authentication required. Please provide a valid API key.",
"requestId": "req_abc123"
}
}
// 404 Not Found
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "User with ID '999' not found",
"requestId": "req_abc123"
}
}
// 409 Conflict
{
"error": {
"code": "CONFLICT",
"message": "Email already registered",
"details": [
{
"field": "email",
"code": "ALREADY_EXISTS",
"message": "A user with this email already exists"
}
]
}
}
// 429 Rate Limited
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please retry after 60 seconds.",
"retryAfter": 60
}
}
// Also include: Retry-After: 60 header
// 500 Internal Error
{
"error": {
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred. Please try again.",
"requestId": "req_abc123" // Important for debugging!
}
}
// Never expose stack traces or internal details to clients
When REST Shines
REST IS EXCELLENT FOR:
════════════════════════════════════════════════════════════════════
✓ PUBLIC APIS
─────────────────────────────────────────────────────────────────
• Everyone understands HTTP
• Tooling is universal (curl, Postman, any language)
• Caching works out of the box (HTTP caching)
• No special client libraries required
Examples: Stripe, Twilio, GitHub
✓ SIMPLE CRUD APPLICATIONS
─────────────────────────────────────────────────────────────────
• Natural mapping: resources → endpoints
• HTTP verbs map to operations
• Straightforward to implement
• Easy to document (OpenAPI)
✓ CACHEABLE DATA
─────────────────────────────────────────────────────────────────
• HTTP caching is mature and well-understood
• CDNs work naturally
• ETags, Cache-Control, conditional requests
• Great for read-heavy workloads
✓ MULTI-CLIENT SCENARIOS
─────────────────────────────────────────────────────────────────
• Web, mobile, third-party all use same API
• No client library versioning issues
• Any HTTP client works
• Language agnostic
REST PAIN POINTS:
════════════════════════════════════════════════════════════════════
✗ OVER-FETCHING
─────────────────────────────────────────────────────────────────
GET /users/123 returns 50 fields.
Mobile only needs 3.
Workarounds: ?fields=id,name,email (custom implementation)
✗ UNDER-FETCHING (N+1 problem)
─────────────────────────────────────────────────────────────────
Get user → Get user's posts → Get each post's comments
Multiple round trips.
Workarounds: ?include=posts.comments (custom implementation)
✗ RIGID STRUCTURE
─────────────────────────────────────────────────────────────────
Different clients need different shapes.
Mobile wants compact, admin wants detailed.
Either multiple endpoints or bloated responses.
✗ NO STANDARD FOR REAL-TIME
─────────────────────────────────────────────────────────────────
REST is request-response.
Subscriptions need WebSockets, SSE, or polling.
Not part of REST itself.
✗ DOCUMENTATION BURDEN
─────────────────────────────────────────────────────────────────
Must maintain OpenAPI spec.
Easy to get out of sync.
Types not enforced.
GraphQL: The Flexible Query Language
What GraphQL Actually Is
GraphQL: A QUERY LANGUAGE FOR YOUR API:
════════════════════════════════════════════════════════════════════
Core ideas:
1. SINGLE ENDPOINT
All queries go to POST /graphql
No more /users, /posts, /comments endpoints
2. CLIENT SPECIFIES SHAPE
Client asks for exactly what it needs
No over-fetching, no under-fetching
3. STRONGLY TYPED SCHEMA
Schema defines what's possible
Self-documenting, introspectable
4. HIERARCHICAL
Query shape matches response shape
Natural for graph-like data
REQUEST:
────────
POST /graphql
{
query: `
query GetUser($id: ID!) {
user(id: $id) {
id
name
posts(first: 5) {
title
comments {
author { name }
content
}
}
}
}
`,
variables: { id: "123" }
}
RESPONSE:
─────────
{
"data": {
"user": {
"id": "123",
"name": "John Doe",
"posts": [
{
"title": "Hello World",
"comments": [
{
"author": { "name": "Jane" },
"content": "Great post!"
}
]
}
]
}
}
}
One request. Exactly what was asked for. Nothing more.
GraphQL Schema Design
# GRAPHQL SCHEMA DESIGN:
# ═══════════════════════════════════════════════════════════════
# ─────────────────────────────────────────────────────────────────
# TYPE DEFINITIONS
# ─────────────────────────────────────────────────────────────────
type User {
id: ID!
email: String!
name: String!
avatar: String
createdAt: DateTime!
# Relationships
posts(first: Int, after: String): PostConnection!
followers: [User!]!
following: [User!]!
}
type Post {
id: ID!
title: String!
content: String!
published: Boolean!
publishedAt: DateTime
createdAt: DateTime!
# Relationships
author: User!
comments(first: Int, after: String): CommentConnection!
tags: [Tag!]!
}
type Comment {
id: ID!
content: String!
createdAt: DateTime!
author: User!
post: Post!
}
type Tag {
id: ID!
name: String!
posts: [Post!]!
}
# ─────────────────────────────────────────────────────────────────
# PAGINATION (Relay-style Connections)
# ─────────────────────────────────────────────────────────────────
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# ─────────────────────────────────────────────────────────────────
# QUERIES
# ─────────────────────────────────────────────────────────────────
type Query {
# Single item
user(id: ID!): User
post(id: ID!): Post
# Collections
users(
first: Int
after: String
filter: UserFilter
orderBy: UserOrderBy
): UserConnection!
posts(
first: Int
after: String
filter: PostFilter
orderBy: PostOrderBy
): PostConnection!
# Search
search(query: String!, type: SearchType): SearchResults!
# Current user
me: User
}
input UserFilter {
email: String
nameLike: String
createdAfter: DateTime
}
enum UserOrderBy {
CREATED_AT_ASC
CREATED_AT_DESC
NAME_ASC
NAME_DESC
}
# ─────────────────────────────────────────────────────────────────
# MUTATIONS
# ─────────────────────────────────────────────────────────────────
type Mutation {
# User mutations
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
# Post mutations
createPost(input: CreatePostInput!): CreatePostPayload!
updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
publishPost(id: ID!): PublishPostPayload!
deletePost(id: ID!): DeletePostPayload!
# Authentication
login(email: String!, password: String!): AuthPayload!
logout: LogoutPayload!
}
# Input types (what client sends)
input CreateUserInput {
email: String!
name: String!
password: String!
}
input UpdateUserInput {
name: String
avatar: String
}
# Payload types (what server returns)
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type UserError {
field: String
message: String!
code: ErrorCode!
}
enum ErrorCode {
VALIDATION_ERROR
NOT_FOUND
UNAUTHORIZED
CONFLICT
}
# ─────────────────────────────────────────────────────────────────
# SUBSCRIPTIONS (Real-time)
# ─────────────────────────────────────────────────────────────────
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
userTyping(conversationId: ID!): TypingIndicator!
}
type TypingIndicator {
user: User!
isTyping: Boolean!
}
GraphQL Resolver Patterns
// GRAPHQL RESOLVER IMPLEMENTATION:
// ═══════════════════════════════════════════════════════════════
// ─────────────────────────────────────────────────────────────────
// BASIC RESOLVERS
// ─────────────────────────────────────────────────────────────────
const resolvers = {
Query: {
user: async (_, { id }, context) => {
return context.dataSources.users.findById(id);
},
posts: async (_, { first, after, filter, orderBy }, context) => {
return context.dataSources.posts.findMany({
first,
after,
filter,
orderBy,
});
},
me: async (_, __, context) => {
if (!context.user) return null;
return context.dataSources.users.findById(context.user.id);
},
},
User: {
// Field resolver - called when 'posts' is requested on User
posts: async (user, { first, after }, context) => {
return context.dataSources.posts.findByAuthor(user.id, { first, after });
},
followers: async (user, _, context) => {
return context.dataSources.users.getFollowers(user.id);
},
},
Post: {
author: async (post, _, context) => {
return context.dataSources.users.findById(post.authorId);
},
comments: async (post, { first, after }, context) => {
return context.dataSources.comments.findByPost(post.id, { first, after });
},
},
};
// ─────────────────────────────────────────────────────────────────
// DATALOADER (N+1 Prevention)
// ─────────────────────────────────────────────────────────────────
import DataLoader from 'dataloader';
// Without DataLoader:
// Query: user.posts[0].author, user.posts[1].author, user.posts[2].author
// = 3 separate database queries for authors
// With DataLoader:
// = 1 batched query: SELECT * FROM users WHERE id IN (1, 2, 3)
function createLoaders(db) {
return {
userLoader: new DataLoader(async (ids) => {
const users = await db.users.findMany({
where: { id: { in: ids } },
});
// Must return in same order as requested ids
return ids.map((id) => users.find((u) => u.id === id));
}),
postsByAuthorLoader: new DataLoader(async (authorIds) => {
const posts = await db.posts.findMany({
where: { authorId: { in: authorIds } },
});
// Group by author
return authorIds.map((authorId) =>
posts.filter((p) => p.authorId === authorId)
);
}),
};
}
// In context setup:
const context = ({ req }) => ({
user: authenticateRequest(req),
loaders: createLoaders(db),
});
// In resolvers:
const resolvers = {
Post: {
author: (post, _, { loaders }) => {
return loaders.userLoader.load(post.authorId);
},
},
User: {
posts: (user, _, { loaders }) => {
return loaders.postsByAuthorLoader.load(user.id);
},
},
};
// ─────────────────────────────────────────────────────────────────
// MUTATION WITH VALIDATION
// ─────────────────────────────────────────────────────────────────
const resolvers = {
Mutation: {
createPost: async (_, { input }, context) => {
// Authentication check
if (!context.user) {
return {
post: null,
errors: [{
code: 'UNAUTHORIZED',
message: 'You must be logged in to create a post',
}],
};
}
// Validation
const errors = [];
if (!input.title || input.title.length < 3) {
errors.push({
field: 'title',
code: 'VALIDATION_ERROR',
message: 'Title must be at least 3 characters',
});
}
if (errors.length > 0) {
return { post: null, errors };
}
// Create
const post = await context.dataSources.posts.create({
...input,
authorId: context.user.id,
});
return { post, errors: [] };
},
},
};
When GraphQL Shines
GRAPHQL IS EXCELLENT FOR:
════════════════════════════════════════════════════════════════════
✓ DIVERSE CLIENTS WITH DIFFERENT NEEDS
─────────────────────────────────────────────────────────────────
Mobile: Needs minimal data (bandwidth sensitive)
Web: Needs full data
Admin: Needs everything + metadata
Each requests exactly what they need.
No multiple endpoints, no versioning.
✓ DEEPLY NESTED / GRAPH-LIKE DATA
─────────────────────────────────────────────────────────────────
Social graphs, organizational hierarchies, content with relations
One query:
user → posts → comments → author → followers
vs REST: Multiple requests or complex includes
✓ RAPID ITERATION / PROTOTYPING
─────────────────────────────────────────────────────────────────
Frontend can request new fields without backend changes
(If field exists in schema)
Less coordination between teams.
Backend exposes capabilities, frontend uses what it needs.
✓ REAL-TIME FEATURES
─────────────────────────────────────────────────────────────────
Subscriptions are first-class.
Same type system for queries and subscriptions.
Client uses same patterns for both.
✓ STRONG TYPING ACROSS STACK
─────────────────────────────────────────────────────────────────
Schema generates TypeScript types.
Frontend and backend share contracts.
Catch errors at build time.
GRAPHQL PAIN POINTS:
════════════════════════════════════════════════════════════════════
✗ COMPLEXITY
─────────────────────────────────────────────────────────────────
More concepts: schemas, resolvers, dataloaders, fragments
Steeper learning curve
More infrastructure (Apollo, code generation)
✗ CACHING IS HARDER
─────────────────────────────────────────────────────────────────
Every query is POST to /graphql
HTTP caching doesn't work naturally
Need normalized caching (Apollo Client)
✗ N+1 PROBLEM REQUIRES ATTENTION
─────────────────────────────────────────────────────────────────
Easy to write resolvers that cause N+1 queries
DataLoader is essential but adds complexity
Must think about query patterns
✗ SECURITY CONCERNS
─────────────────────────────────────────────────────────────────
Malicious queries: { user { posts { author { posts { author... }}}}}
Need query depth limiting, complexity analysis
Rate limiting is harder (all requests look the same)
✗ FILE UPLOADS
─────────────────────────────────────────────────────────────────
Not part of spec
Requires multipart spec extension or separate REST endpoint
✗ ERROR HANDLING INCONSISTENCY
─────────────────────────────────────────────────────────────────
Partial success is possible (some fields resolve, others error)
200 OK doesn't mean full success
Requires careful client-side error handling
tRPC: End-to-End Type Safety
What tRPC Actually Is
tRPC: TYPESCRIPT REMOTE PROCEDURE CALL:
════════════════════════════════════════════════════════════════════
Core idea: Use TypeScript inference instead of code generation.
┌─────────────────────────────────────────────────────────────────┐
│ │
│ TRADITIONAL API FLOW: │
│ │
│ Backend ──► OpenAPI/GraphQL Schema ──► Generate Types ──► Client
│ │
│ • Write API │
│ • Generate schema │
│ • Generate client types │
│ • Keep them in sync │
│ • Runtime validation needed │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ tRPC FLOW: │
│ │
│ Backend Router ◄──── TypeScript ────► Client │
│ │
│ • Write API once │
│ • Types flow automatically │
│ • No code generation │
│ • No schema to maintain │
│ • Compile-time type safety │
│ │
└─────────────────────────────────────────────────────────────────┘
THE MAGIC:
──────────
// Server
const appRouter = router({
user: router({
byId: procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
return db.user.findUnique({ where: { id: input.id } });
}),
}),
});
export type AppRouter = typeof appRouter;
// Client (in same monorepo)
import type { AppRouter } from '../server/router';
import { createTRPCReact } from '@trpc/react-query';
const trpc = createTRPCReact<AppRouter>();
// Full autocomplete and type checking!
const user = trpc.user.byId.useQuery({ id: '123' });
// ^ knows exact return type
// Typo in procedure name? Compile error.
// Wrong input type? Compile error.
// Access non-existent field on response? Compile error.
tRPC Implementation
// tRPC IMPLEMENTATION:
// ═══════════════════════════════════════════════════════════════
// ─────────────────────────────────────────────────────────────────
// SERVER SETUP
// ─────────────────────────────────────────────────────────────────
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { Context } from './context';
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
// Middleware for authentication
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in',
});
}
return next({
ctx: {
...ctx,
user: ctx.user, // Now typed as non-null
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
export const userRouter = router({
// Public query
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: input.id },
select: {
id: true,
name: true,
email: true,
avatar: true,
},
});
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `User ${input.id} not found`,
});
}
return user;
}),
// List with pagination
list: publicProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(),
})
)
.query(async ({ input, ctx }) => {
const users = await ctx.db.user.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
let nextCursor: string | undefined;
if (users.length > input.limit) {
const nextItem = users.pop();
nextCursor = nextItem?.id;
}
return { users, nextCursor };
}),
// Protected mutation
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(100).optional(),
avatar: z.string().url().optional(),
})
)
.mutation(async ({ input, ctx }) => {
return ctx.db.user.update({
where: { id: ctx.user.id },
data: input,
});
}),
// Current user
me: protectedProcedure.query(({ ctx }) => {
return ctx.db.user.findUnique({
where: { id: ctx.user.id },
});
}),
});
// server/routers/post.ts
export const postRouter = router({
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
return ctx.db.post.findUnique({
where: { id: input.id },
include: { author: true },
});
}),
create: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
published: z.boolean().default(false),
})
)
.mutation(async ({ input, ctx }) => {
return ctx.db.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
}),
publish: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
const post = await ctx.db.post.findUnique({
where: { id: input.id },
});
if (!post) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
if (post.authorId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return ctx.db.post.update({
where: { id: input.id },
data: { published: true, publishedAt: new Date() },
});
}),
});
// server/router.ts - Root router
import { router } from './trpc';
import { userRouter } from './routers/user';
import { postRouter } from './routers/post';
export const appRouter = router({
user: userRouter,
post: postRouter,
});
export type AppRouter = typeof appRouter;
// ─────────────────────────────────────────────────────────────────
// CLIENT SETUP
// ─────────────────────────────────────────────────────────────────
// client/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/router';
export const trpc = createTRPCReact<AppRouter>();
// client/app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from './trpc';
const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
headers: () => ({
authorization: getAuthToken(),
}),
}),
],
});
function App() {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<MyApp />
</QueryClientProvider>
</trpc.Provider>
);
}
// ─────────────────────────────────────────────────────────────────
// USING tRPC IN COMPONENTS
// ─────────────────────────────────────────────────────────────────
// Queries
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = trpc.user.byId.useQuery(
{ id: userId },
{ enabled: !!userId }
);
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <div>{user?.name}</div>; // user is fully typed!
}
// Mutations
function CreatePost() {
const utils = trpc.useUtils();
const createPost = trpc.post.create.useMutation({
onSuccess: () => {
// Invalidate queries
utils.post.list.invalidate();
},
});
return (
<button
onClick={() => createPost.mutate({
title: 'Hello',
content: 'World',
})}
disabled={createPost.isPending}
>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>
);
}
// Infinite queries
function PostList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
trpc.post.list.useInfiniteQuery(
{ limit: 20 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
return (
<div>
{data?.pages.flatMap((page) =>
page.posts.map((post) => <PostCard key={post.id} post={post} />)
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
Load More
</button>
)}
</div>
);
}
When tRPC Shines
tRPC IS EXCELLENT FOR:
════════════════════════════════════════════════════════════════════
✓ FULL-STACK TYPESCRIPT MONOREPOS
─────────────────────────────────────────────────────────────────
Next.js, Remix, T3 Stack, Turborepo
Types flow end-to-end without code generation
Rename a field → see all usages instantly
✓ RAPID DEVELOPMENT
─────────────────────────────────────────────────────────────────
No schema to maintain
No code generation step
No sync issues
Change server → client types update instantly
✓ SMALL TO MEDIUM TEAMS
─────────────────────────────────────────────────────────────────
Same team owns frontend and backend
Tight collaboration
Shared repo makes types work
✓ INTERNAL TOOLS / ADMIN PANELS
─────────────────────────────────────────────────────────────────
No external clients
TypeScript everywhere
DX is priority over flexibility
✓ WHEN YOU DON'T NEED REST/GRAPHQL FEATURES
─────────────────────────────────────────────────────────────────
No need for HTTP caching
No need for public API
No need for multi-language clients
tRPC LIMITATIONS:
════════════════════════════════════════════════════════════════════
✗ TYPESCRIPT ONLY
─────────────────────────────────────────────────────────────────
Client must be TypeScript
Can't use from Python, Go, Ruby, etc.
Can't expose as public API (easily)
✗ MONOREPO REQUIRED (for magic)
─────────────────────────────────────────────────────────────────
Types flow via imports
Separate repos = need to publish types package
Loses some of the magic
✗ NO STANDARD SCHEMA
─────────────────────────────────────────────────────────────────
Can't generate OpenAPI
Can't share schema with non-TS clients
No standard introspection
✗ RPC OVER REST
─────────────────────────────────────────────────────────────────
Everything is POST (or GET for queries)
HTTP semantics don't apply
No HTTP caching
✗ TIGHTLY COUPLED
─────────────────────────────────────────────────────────────────
Frontend and backend must deploy together
Server changes require client updates
Less flexibility for independent teams
✗ LESS ECOSYSTEM
─────────────────────────────────────────────────────────────────
Fewer tools than REST or GraphQL
Smaller community
Less documentation / examples
Comparison Matrix
Feature Comparison
FEATURE COMPARISON:
════════════════════════════════════════════════════════════════════
│ REST │ GraphQL │ tRPC
────────────────────────┼───────────┼───────────┼───────────
Learning curve │ Low │ High │ Medium
────────────────────────┼───────────┼───────────┼───────────
Type safety │ Manual* │ Good** │ Excellent
────────────────────────┼───────────┼───────────┼───────────
Flexibility │ Limited │ High │ Medium
────────────────────────┼───────────┼───────────┼───────────
HTTP caching │ Excellent │ Poor │ Poor
────────────────────────┼───────────┼───────────┼───────────
Real-time │ Add-on │ Built-in │ Add-on
────────────────────────┼───────────┼───────────┼───────────
Multi-language clients │ Yes │ Yes │ TS only
────────────────────────┼───────────┼───────────┼───────────
Public API suitability │ Excellent │ Good │ Poor
────────────────────────┼───────────┼───────────┼───────────
Tooling maturity │ Excellent │ Good │ Growing
────────────────────────┼───────────┼───────────┼───────────
Bundle size (client) │ 0KB*** │ ~50KB │ ~15KB
────────────────────────┼───────────┼───────────┼───────────
Server complexity │ Low │ High │ Low
────────────────────────┼───────────┼───────────┼───────────
N+1 prevention │ Manual │ DataLoader│ N/A
────────────────────────┼───────────┼───────────┼───────────
Over-fetching │ Common │ Solved │ Solved****
────────────────────────┼───────────┼───────────┼───────────
Under-fetching │ Common │ Solved │ Manual
* With OpenAPI + codegen
** With codegen (graphql-codegen)
*** Using fetch
**** By design (you write exactly what's returned)
OPERATIONAL COMPARISON:
════════════════════════════════════════════════════════════════════
│ REST │ GraphQL │ tRPC
────────────────────────┼───────────┼───────────┼───────────
Monitoring │ Standard │ Custom │ Standard
────────────────────────┼───────────┼───────────┼───────────
Rate limiting │ Easy │ Complex │ Easy
────────────────────────┼───────────┼───────────┼───────────
CDN integration │ Natural │ Difficult │ Limited
────────────────────────┼───────────┼───────────┼───────────
Debugging │ Easy │ Medium │ Easy
────────────────────────┼───────────┼───────────┼───────────
Versioning │ URL/Header│ Schema │ Types
────────────────────────┼───────────┼───────────┼───────────
Documentation │ OpenAPI │ Introspect│ Types
────────────────────────┼───────────┼───────────┼───────────
Security surface │ Standard │ Larger │ Standard
Use Case Fit
USE CASE FIT MATRIX:
════════════════════════════════════════════════════════════════════
USE CASE │ REST │ GraphQL │ tRPC
────────────────────────────────────┼──────┼─────────┼──────
Public API │ ★★★ │ ★★☆ │ ☆☆☆
Mobile app backend │ ★★☆ │ ★★★ │ ★★☆
Web app (same team) │ ★★☆ │ ★★☆ │ ★★★
Microservices (polyglot) │ ★★★ │ ★★☆ │ ☆☆☆
Microservices (all TypeScript) │ ★★☆ │ ★★☆ │ ★★★
Internal tools │ ★★☆ │ ★★☆ │ ★★★
Third-party integrations │ ★★★ │ ★★☆ │ ☆☆☆
High-traffic cacheable │ ★★★ │ ★☆☆ │ ★☆☆
Complex nested data │ ★☆☆ │ ★★★ │ ★★☆
Real-time features │ ★☆☆ │ ★★★ │ ★★☆
Rapid prototyping │ ★★☆ │ ★★☆ │ ★★★
Large organization │ ★★★ │ ★★☆ │ ★☆☆
Small startup │ ★★☆ │ ★☆☆ │ ★★★
★★★ = Excellent fit
★★☆ = Good fit
★☆☆ = Possible but not ideal
☆☆☆ = Not recommended
Decision Framework
The Decision Tree
WHICH API STYLE SHOULD YOU USE?
════════════════════════════════════════════════════════════════════
START HERE
│
▼
┌───────────────────────────────────┐
│ Is this a public API for │
│ external developers? │
└───────────────────────────────────┘
│
├── YES ───────────────────────────────────────► REST
│ Standard, documented, any language can consume
│
└── NO ────┐
│
▼
┌───────────────────────────────────┐
│ Are clients non-TypeScript? │
│ (Mobile native, other languages) │
└───────────────────────────────────┘
│
├── YES ───┐
│ │
│ ▼
│ ┌───────────────────────────────────┐
│ │ Do clients have very different │
│ │ data needs? (mobile vs web) │
│ └───────────────────────────────────┘
│ │
│ ├── YES ───────────────────────► GraphQL
│ │ Flexible queries, client decides shape
│ │
│ └── NO ────────────────────────► REST
│ Simpler, good enough
│
└── NO (all TypeScript) ────┐
│
▼
┌───────────────────────────────────┐
│ Is it a monorepo or can you │
│ share types easily? │
└───────────────────────────────────┘
│
├── YES ───────────────────────────────────────► tRPC
│ Best DX, end-to-end type safety
│
└── NO ────┐
│
▼
┌───────────────────────────────────┐
│ Do you need complex nested data │
│ queries? (graph-like data) │
└───────────────────────────────────┘
│
├── YES ───────────────────────────────────────► GraphQL
│ Natural for graph traversal
│
└── NO ────────────────────────────────────────► REST
Simple, cacheable, well-understood
SUMMARY BY SCENARIO:
════════════════════════════════════════════════════════════════════
"We're building a public API"
→ REST (universal compatibility, HTTP caching)
"We have a mobile app and web app with different needs"
→ GraphQL (clients request what they need)
"We're a TypeScript full-stack team"
→ tRPC (unmatched DX, type safety)
"We're building microservices"
→ REST (polyglot) or tRPC (TypeScript ecosystem)
"We don't know what clients will need"
→ GraphQL (flexibility) or REST (simplicity)
"Performance and caching are critical"
→ REST (HTTP caching is mature)
"We need real-time features"
→ GraphQL subscriptions or REST + WebSockets
Hybrid Approaches
YOU DON'T HAVE TO CHOOSE JUST ONE:
════════════════════════════════════════════════════════════════════
PATTERN 1: REST for Public + tRPC for Internal
───────────────────────────────────────────────
┌─────────────────────────────────────────────────────────────────┐
│ │
│ External Clients Internal Frontend │
│ (Mobile, Partners) (React App) │
│ │ │ │
│ │ REST API │ tRPC │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ REST │ │ tRPC │ │
│ │ Handlers │ │ Routers │ │
│ └────┬─────┘ └────┬─────┘ │
│ │ │ │
│ └────────────────┬───────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Shared │ │
│ │ Business │ │
│ │ Logic │ │
│ └────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
PATTERN 2: GraphQL Gateway + REST Microservices
───────────────────────────────────────────────
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Clients │
│ │ │
│ │ GraphQL │
│ ▼ │
│ ┌──────────────────┐ │
│ │ GraphQL Gateway │ (Apollo Federation, Schema Stitching) │
│ └────────┬─────────┘ │
│ │ │
│ ┌──────┼──────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │User│ │Post│ │Cart│ (REST microservices) │
│ │Svc │ │Svc │ │Svc │ │
│ └────┘ └────┘ └────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
GraphQL provides flexible frontend queries.
Backend teams use simple REST between services.
PATTERN 3: tRPC with OpenAPI Export
───────────────────────────────────
Use tRPC for development.
Generate OpenAPI spec for documentation / external access.
// tRPC router
const appRouter = router({ ... });
// Generate OpenAPI
import { generateOpenApiDocument } from 'trpc-openapi';
const openApiDocument = generateOpenApiDocument(appRouter, {
title: 'My API',
version: '1.0.0',
baseUrl: 'https://api.example.com',
});
Best of both: tRPC DX + REST compatibility when needed.
Common Patterns Across All Styles
Authentication
// AUTHENTICATION PATTERNS:
// ═══════════════════════════════════════════════════════════════
// ─────────────────────────────────────────────────────────────────
// REST
// ─────────────────────────────────────────────────────────────────
// Bearer token in header
GET /api/users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
// Server middleware
app.use('/api', (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
req.user = verifyToken(token);
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
});
// ─────────────────────────────────────────────────────────────────
// GraphQL
// ─────────────────────────────────────────────────────────────────
// Same header, context extraction
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '');
const user = token ? verifyToken(token) : null;
return { user };
},
});
// In resolvers
const resolvers = {
Query: {
me: (_, __, { user }) => {
if (!user) throw new AuthenticationError('Must be logged in');
return user;
},
},
};
// ─────────────────────────────────────────────────────────────────
// tRPC
// ─────────────────────────────────────────────────────────────────
// Context creation
const createContext = ({ req }: { req: Request }) => {
const token = req.headers.get('authorization')?.replace('Bearer ', '');
const user = token ? verifyToken(token) : null;
return { user };
};
// Protected procedure middleware
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
export const protectedProcedure = t.procedure.use(isAuthed);
Pagination
// PAGINATION PATTERNS:
// ═══════════════════════════════════════════════════════════════
// ─────────────────────────────────────────────────────────────────
// OFFSET-BASED (Simple, has issues at scale)
// ─────────────────────────────────────────────────────────────────
// REST
GET /api/posts?page=3&limit=20
{
"data": [...],
"meta": {
"page": 3,
"limit": 20,
"total": 156,
"totalPages": 8
}
}
// Problem: If items are added/removed, pages shift
// Page 3 might show items you already saw or skip items
// ─────────────────────────────────────────────────────────────────
// CURSOR-BASED (Better for real-time data)
// ─────────────────────────────────────────────────────────────────
// REST
GET /api/posts?limit=20&cursor=eyJpZCI6MTIzfQ
{
"data": [...],
"meta": {
"nextCursor": "eyJpZCI6MTQzfQ",
"hasMore": true
}
}
// GraphQL (Relay-style)
query {
posts(first: 20, after: "cursor123") {
edges {
node { id, title }
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
// tRPC
const posts = trpc.post.list.useInfiniteQuery(
{ limit: 20 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
// Load more
posts.fetchNextPage();
// ─────────────────────────────────────────────────────────────────
// CURSOR IMPLEMENTATION
// ─────────────────────────────────────────────────────────────────
// Cursor is opaque to client, encodes position
function encodeCursor(id: string, createdAt: Date): string {
return Buffer.from(JSON.stringify({ id, createdAt })).toString('base64');
}
function decodeCursor(cursor: string): { id: string; createdAt: Date } {
return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8'));
}
// Query
async function getPosts(limit: number, cursor?: string) {
const decoded = cursor ? decodeCursor(cursor) : null;
const posts = await db.post.findMany({
take: limit + 1, // Fetch one extra to check hasMore
where: decoded
? {
OR: [
{ createdAt: { lt: decoded.createdAt } },
{
createdAt: decoded.createdAt,
id: { lt: decoded.id },
},
],
}
: undefined,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
const hasMore = posts.length > limit;
if (hasMore) posts.pop();
const nextCursor = hasMore
? encodeCursor(posts[posts.length - 1].id, posts[posts.length - 1].createdAt)
: null;
return { posts, nextCursor, hasMore };
}
Versioning
// VERSIONING STRATEGIES:
// ═══════════════════════════════════════════════════════════════
// ─────────────────────────────────────────────────────────────────
// REST: URL Versioning (Most common)
// ─────────────────────────────────────────────────────────────────
// Version in URL
/api/v1/users
/api/v2/users
// Pros: Clear, cacheable, easy to route
// Cons: Fragments API, hard to deprecate
// ─────────────────────────────────────────────────────────────────
// REST: Header Versioning
// ─────────────────────────────────────────────────────────────────
GET /api/users
Accept: application/vnd.myapi.v2+json
// Pros: Clean URLs, content negotiation
// Cons: Hidden, harder to test
// ─────────────────────────────────────────────────────────────────
// GraphQL: Schema Evolution (Preferred)
// ─────────────────────────────────────────────────────────────────
// Don't version! Evolve the schema.
// Add fields (non-breaking)
type User {
id: ID!
name: String!
displayName: String! # New field, clients can ignore
}
// Deprecate before removing
type User {
id: ID!
name: String! @deprecated(reason: "Use displayName instead")
displayName: String!
}
// After migration period, remove
type User {
id: ID!
displayName: String!
}
// ─────────────────────────────────────────────────────────────────
// tRPC: Type Evolution
// ─────────────────────────────────────────────────────────────────
// Add fields (clients see them immediately)
// Remove fields (TypeScript errors show all usages)
// For breaking changes, new procedure:
const userRouter = router({
getUser: procedure... // Old
getUserV2: procedure... // New
// Or namespace:
v1: router({
getUser: procedure...
}),
v2: router({
getUser: procedure...
}),
});
Quick Reference
┌─────────────────────────────────────────────────────────────────────┐
│ API DESIGN QUICK REFERENCE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ UNIVERSAL PRINCIPLES │
│ ───────────────────────────────────────────────────────────────── │
│ 1. Consistency - Similar things work similarly │
│ 2. Predictability - Behavior is unsurprising │
│ 3. Evolvability - Can change without breaking clients │
│ 4. Clear errors - Help developers fix problems │
│ 5. Appropriate granularity - Not too coarse, not too fine │
│ 6. Documentation - First-class citizen │
│ │
│ WHEN TO USE WHAT │
│ ───────────────────────────────────────────────────────────────── │
│ REST: │
│ • Public APIs │
│ • Multi-language clients │
│ • HTTP caching needed │
│ • Simple CRUD operations │
│ │
│ GraphQL: │
│ • Diverse clients with different data needs │
│ • Deeply nested/graph-like data │
│ • Real-time subscriptions │
│ • Rapid frontend iteration │
│ │
│ tRPC: │
│ • Full-stack TypeScript │
│ • Monorepo setup │
│ • Internal tools │
│ • Maximum type safety DX │
│ │
│ HTTP STATUS CODES (REST) │
│ ───────────────────────────────────────────────────────────────── │
│ 200 OK │ Success │
│ 201 Created │ Resource created │
│ 204 No Content │ Success, no body │
│ 400 Bad Request │ Invalid input │
│ 401 Unauthorized │ Auth required │
│ 403 Forbidden │ Not allowed │
│ 404 Not Found │ Resource missing │
│ 409 Conflict │ State conflict │
│ 422 Unprocessable│ Validation failed │
│ 429 Too Many │ Rate limited │
│ 500 Server Error │ Unexpected error │
│ │
│ PAGINATION │
│ ───────────────────────────────────────────────────────────────── │
│ Offset: ?page=3&limit=20 (simple, shifts on changes) │
│ Cursor: ?cursor=abc&limit=20 (stable, better for feeds) │
│ │
│ COMMON MISTAKES │
│ ───────────────────────────────────────────────────────────────── │
│ ✗ Inconsistent naming (camelCase + snake_case mixed) │
│ ✗ Vague errors ("Bad request") │
│ ✗ Wrong HTTP status codes │
│ ✗ Removing fields without deprecation │
│ ✗ Overly nested resources (/a/1/b/2/c/3/d/4) │
│ ✗ No versioning strategy │
│ ✗ Exposing internal implementation details │
│ │
│ DECISION SHORTCUT │
│ ───────────────────────────────────────────────────────────────── │
│ Public API? → REST │
│ TypeScript monorepo? → tRPC │
│ Complex client data needs? → GraphQL │
│ Don't know yet? → REST (most flexible later) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Conclusion
The best API is the one that serves your specific needs—not the one that won the latest Twitter debate.
Timeless principles matter more than technology choice:
Regardless of REST, GraphQL, or tRPC, the fundamentals apply: consistency, predictability, evolvability, clear errors, and good documentation. A well-designed REST API beats a poorly-designed GraphQL API every time.
Match the tool to the situation:
-
REST when you need universal compatibility, HTTP caching, or a public API. It's the safest default when you're unsure.
-
GraphQL when clients have genuinely different data needs, when you're dealing with graph-like data, or when you need real-time subscriptions as a first-class feature.
-
tRPC when you're building a TypeScript full-stack application in a monorepo and want the best possible developer experience. The type safety is unmatched, but the constraints are real.
Don't let technology choice become ideology:
You can use multiple approaches in one system. REST for your public API, tRPC for your internal admin tools, GraphQL for your mobile app—if that's what serves each use case best.
The real work is in the design:
Resource modeling, error handling, pagination, authentication, versioning—these challenges exist regardless of protocol. Spend your energy on getting these right rather than debating REST vs GraphQL.
Start simple. Evolve based on real needs. The best API is the one your developers can understand and your clients can consume without frustration.
And remember: you can always change later. REST to GraphQL migrations happen all the time. What you can't easily change is a poorly modeled domain or an inconsistent error strategy. Get the fundamentals right, and the protocol becomes a detail.
What did you think?