API Design Principles That Age Well: REST, gRPC, GraphQL, and tRPC Trade-Offs in Real Systems
That 'quick' API you shipped 5 years ago now blocks every change. APIs are promises—bad ones compound pain. Learn tool-agnostic principles, REST/gRPC/GraphQL/tRPC trade-offs, TypeScript patterns, versioning strategies, and the pre-merge checklist for APIs that last 10 years. Updated for 2026.

TL;DR
APIs are promises that teams depend on forever. Design for predictability, discoverability, evolvability, and hard-to-misuse interfaces. Choose REST for simplicity, gRPC for performance, GraphQL for flexibility, or tRPC for end-to-end type safety—but understand the trade-offs. In 2026, TypeScript-first approaches with tools like tRPC and Zod enable APIs that are harder to misuse. APIs that age well are versioned from day one and designed for change, not just launch.
API Design Principles That Age Well: REST, gRPC, and GraphQL Trade-Offs in Real Systems
The API You Now Regret Supporting Forever
Five years ago, you shipped an endpoint in 20 minutes: GET /users?data=all. It returned everything about a user in one massive JSON blob. Perfect for the iOS app you were building that week.
Today, that endpoint serves 8 different clients across web, mobile, and internal tools. It returns 47 fields, but most clients only need 3. It can't be paginated. Changing the response shape breaks everyone. Adding authentication would break half your integrations. You've wanted to deprecate it for 3 years, but the migration plan is terrifying.
APIs are promises. Once you ship them, teams depend on them, clients integrate with them, and documentation points to them. Bad promises compound pain over time. Every new integration makes the API harder to change. Every workaround becomes "how it's always been done."
The good news: design principles that age well aren't complicated. They're just intentional. You make explicit trade-offs between flexibility, performance, and simplicity. You think about the lifecycle, not just the launch.
Let's talk about how to design APIs that you won't regret supporting in 5 years.
What 'Good' Looks Like for APIs That Last
Before diving into REST vs gRPC vs GraphQL, let's define what we're optimizing for. Good APIs share these characteristics:
1. Predictable and Consistent
Developers can guess how to use your API without reading every endpoint's documentation. If GET /users returns a list, so should GET /orders. If you use snake_case for fields, you use it everywhere, not sometimes camelCase.
Consistency reduces cognitive load. When patterns are predictable, integration is faster and bugs are fewer.
2. Discoverable
Engineers can explore your API and understand what's possible. This means:
- Clear documentation (OpenAPI, Protobuf definitions, GraphQL schema).
- Descriptive names for resources and fields.
- Examples in the docs that work copy-paste.
If someone has to Slack you to figure out how to call your API, it's not discoverable enough.
3. Versionable and Evolvable
You can add new features without breaking existing clients. When you do need to make breaking changes, you have a clear deprecation and migration path.
APIs that age well are designed for change from day one.
4. Hard to Misuse
Good APIs make correct usage easy and incorrect usage obvious. Required fields are actually required. Auth checks happen server-side, not relying on client behavior. Error messages tell you what you did wrong and how to fix it.
If your API depends on clients "just knowing" to do something, someone will get it wrong.
The Primary Consumer Is Other Engineers
Remember: people use your API, not just code. The engineer integrating at 11pm deserves clear contracts, helpful errors, and logical structure. Design for human understanding, not just machine parsing.
REST, gRPC, GraphQL, tRPC: Pick the Right Tool for the Job
There's no universally "best" API style. Each has trade-offs. In 2026, we have a new major player: tRPC, which brings end-to-end type safety without code generation. Choose based on your context.
tRPC: End-to-End Type Safety (The 2026 Game-Changer)
What it is: TypeScript-first RPC framework that provides type safety from frontend to backend without code generation or schema files.
Strengths:
- No code generation: Types inferred directly from your server code
- End-to-end type safety: Frontend knows exact server function signatures
- Excellent DX: Autocomplete, refactoring, compile-time errors
- Lightweight: No extra build step, just TypeScript
- Integrates with existing tools: Works with React Query, Zod validation
Trade-offs:
- TypeScript only: Both client and server must be TypeScript
- Not for public APIs: Only works when you control both ends
- Node.js/Bun focused: Backend must be JavaScript/TypeScript
When to use tRPC:
- Full-stack TypeScript applications (Next.js, React + Express)
- Internal APIs where you control client and server
- Want best-in-class DX without GraphQL complexity
- Team is TypeScript-first and values type safety
Example:
// Backend: Define your API with full type safety
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
// Input validation with Zod
getUser: t.procedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
// TypeScript knows input.id is a string
const user = await db.user.findUnique({ where: { id: input.id } });
if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
return user; // Return type inferred
}),
createOrder: t.procedure
.input(z.object({
userId: z.string().uuid(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().positive(),
})),
}))
.mutation(async ({ input }) => {
// Fully type-safe mutations
return await db.order.create({ data: input });
}),
});
export type AppRouter = typeof appRouter;
// Frontend: Call API with full type safety
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from './server';
const trpc = createTRPCReact<AppRouter>();
function UserProfile({ userId }: { userId: string }) {
// TypeScript knows exact return type, input validation
const { data: user } = trpc.getUser.useQuery({ id: userId });
// ^? User | undefined (fully typed!)
const createOrder = trpc.createOrder.useMutation();
// TypeScript will error if you pass wrong shape
const handleOrder = () => {
createOrder.mutate({
userId,
items: [{ productId: 'abc', quantity: 2 }],
// TypeScript would error if you add invalid field
});
};
return <div>{user?.name}</div>;
}
Why tRPC is winning in 2026: No GraphQL complexity, no OpenAPI code generation, just pure TypeScript type inference. Perfect for modern full-stack TypeScript teams.
REST: Simple, Ubiquitous, Works Everywhere
What it is: HTTP-based, resource-oriented API using standard methods (GET, POST, PUT, DELETE).
Strengths:
- Simplicity: Easy to understand, test with curl, debug in browser devtools.
- Ubiquity: Every language has HTTP clients. Works through proxies, CDNs, browser fetch.
- Caching: HTTP caching semantics are well-understood and well-supported.
- Public APIs: Perfect when you don't control the client.
- 2026 Update: TypeScript + Zod validation provides runtime type safety.
Trade-offs:
- Over-fetching: You get the whole resource even if you need 2 fields.
- Under-fetching: Sometimes requires multiple round-trips (get user, then get user's orders).
- Manual contract maintenance: Without OpenAPI, types can drift.
When to use REST:
- Public APIs consumed by third parties.
- Simple CRUD operations on well-defined resources.
- When you want maximum compatibility and simplicity.
- Internal APIs that don't have extreme performance needs.
2026 Example with TypeScript + Zod:
// Modern REST API with TypeScript and runtime validation
import express, { Request, Response } from 'express';
import { z } from 'zod';
// Define schemas with Zod for runtime validation
const OrderSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().positive(),
price: z.number().positive(),
})),
total: z.number(),
status: z.enum(['pending', 'completed', 'cancelled']),
createdAt: z.string().datetime(),
});
type Order = z.infer<typeof OrderSchema>;
const app = express();
// Type-safe route handlers
app.get('/api/orders/:id', async (
req: Request<{ id: string }>,
res: Response<Order | { error: string }>
) => {
try {
// Validate params
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const order = await db.order.findUnique({ where: { id } });
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
// Validate response matches schema
const validatedOrder = OrderSchema.parse(order);
res.json(validatedOrder);
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({ error: error.message });
}
}
});
Example: Stripe API, GitHub API, Twilio API. These work everywhere, are easy to integrate, and prioritize developer experience over raw performance.
gRPC: Strongly Typed, High Performance, Service-to-Service
What it is: Protocol Buffers (protobuf) over HTTP/2, with code generation for clients and servers.
Strengths:
- Performance: Binary protocol, HTTP/2 multiplexing, smaller payloads than JSON.
- Strong typing: Protobuf schemas enforce contracts at compile time.
- Bidirectional streaming: Server can push data to client, great for real-time needs.
- Code generation: TypeScript clients generated automatically from schema.
- 2026 Update: Excellent tooling for TypeScript with ts-proto, grpc-web for browsers.
Trade-offs:
- Not browser-native: Requires grpc-web or proxy to work in browsers.
- Debugging is harder: Binary payloads, can't just curl and read.
- Steeper learning curve: More setup than REST.
When to use gRPC:
- Internal service-to-service communication in microservices.
- High-throughput, low-latency requirements.
- When you control both client and server.
- Real-time bidirectional streaming needs.
2026 TypeScript Example:
// user.proto
syntax = "proto3";
package user;
service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc StreamUsers (StreamRequest) returns (stream User);
}
message GetUserRequest {
string id = 1;
}
message User {
string id = 1;
string name = 2;
string email = 3;
repeated Order orders = 4;
}
// Generated TypeScript types (ts-proto)
import { UserServiceClient } from './generated/user';
import type { User, GetUserRequest } from './generated/user';
// Fully type-safe client
const client = new UserServiceClient('localhost:50051');
// TypeScript knows the exact request/response types
const request: GetUserRequest = { id: '123' };
const user: User = await client.getUser(request);
// Streaming with full type safety
const stream = client.streamUsers({ limit: 100 });
for await (const user of stream) {
// user is fully typed as User
console.log(user.name);
}
Example: Your payment service calling your fraud detection service 10,000 times per second. You need speed, strong contracts, and both sides are internal services you control.
GraphQL: Flexible Queries, Client-Driven, Complex Data Needs
What it is: Query language where clients specify exactly what data they need in a single request.
Strengths:
- No over-fetching: Client requests only the fields it needs.
- No under-fetching: Can request nested resources in one query.
- Introspectable: Schema is discoverable, GraphQL Playground/GraphiQL for exploration.
- Great for complex UIs: Mobile/web apps with varied data needs.
- 2026 Update: Pothos, TypeGraphQL provide excellent TypeScript support.
Trade-offs:
- Operational complexity: Query complexity limits, N+1 problems, caching is harder.
- Server implementation is heavier: Resolvers, dataloaders, complexity analysis.
- Less HTTP caching: Query params in POST bodies, harder to cache than REST.
When to use GraphQL:
- Complex frontend applications (mobile/web) with diverse data needs.
- When you want to avoid versioning by letting clients request what they need.
- When multiple client teams need different subsets of data.
2026 TypeScript Example with Pothos:
// Modern GraphQL with full TypeScript type safety using Pothos
import SchemaBuilder from '@pothos/core';
// Define types in TypeScript
interface User {
id: string;
name: string;
email: string;
orders: Order[];
}
interface Order {
id: string;
total: number;
status: 'pending' | 'completed' | 'cancelled';
}
// Builder infers types from TypeScript
const builder = new SchemaBuilder<{
Objects: { User: User; Order: Order };
}>({});
// Define GraphQL schema with full type safety
builder.objectType('User', {
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
email: t.exposeString('email'),
// Resolver has full type safety
orders: t.field({
type: ['Order'],
resolve: async (user) => {
// TypeScript knows user is User
return await db.order.findMany({ where: { userId: user.id } });
},
}),
}),
});
builder.queryType({
fields: (t) => ({
user: t.field({
type: 'User',
args: { id: t.arg.string({ required: true }) },
resolve: async (_, args) => {
// args.id is typed as string
return await db.user.findUnique({ where: { id: args.id } });
},
}),
}),
});
// Client-side with TypeScript codegen
import { gql, useQuery } from '@apollo/client';
import type { GetUserQuery, GetUserQueryVariables } from './generated/graphql';
const GET_USER = gql`
query GetUser($id: String!) {
user(id: $id) {
id
name
orders {
id
total
status
}
}
}
`;
function UserProfile({ userId }: { userId: string }) {
// Fully typed query results
const { data } = useQuery<GetUserQuery, GetUserQueryVariables>(GET_USER, {
variables: { id: userId },
});
// TypeScript knows exact shape of data.user
return <div>{data?.user?.name}</div>;
}
Example: Your mobile app needs user profile + last 3 orders + notification count, but web app needs user profile + full order history. GraphQL lets each client request exactly what it needs without building custom endpoints.
The Real Answer: Probably a Mix
Most organizations end up with:
- tRPC for full-stack TypeScript applications (Next.js, T3 stack)
- REST for public APIs and simple internal services.
- gRPC for critical internal service-to-service calls.
- GraphQL as a BFF (Backend for Frontend) aggregating data for complex UIs.
Don't pick one and force it everywhere. Use the right tool for each job.
2026 Recommendation Chart
const apiStyleRecommendations = {
fullStackTypeScript: {
recommended: 'tRPC',
why: 'End-to-end type safety, no codegen, best DX',
alternative: 'REST + Zod validation',
},
publicAPI: {
recommended: 'REST',
why: 'Universal compatibility, easy integration',
tooling: 'OpenAPI for docs, Zod for validation',
},
microservicesInternal: {
recommended: 'gRPC',
why: 'Performance, strong contracts, streaming',
alternative: 'tRPC if TypeScript everywhere',
},
complexFrontendNeeds: {
recommended: 'GraphQL',
why: 'Flexible queries, no over-fetching',
tooling: 'Pothos for TypeScript, Apollo Client',
},
mobileBFF: {
recommended: 'GraphQL or custom REST endpoints',
why: 'Tailored responses, minimize round trips',
consideration: 'tRPC if React Native + Node backend',
},
};
Core Design Principles That Age Well
Regardless of REST/gRPC/GraphQL, these principles help your API age gracefully:
1. Clear Resource and Domain Modeling
Use nouns for resources, not verbs. Use domain language, not technical jargon.
Good:
GET /orders/{id}
POST /orders
GET /customers/{id}/orders
Bad:
GET /getOrder?id=123
POST /createNewOrder
GET /fetchCustomerOrderData
Your API should reflect real concepts in your domain. If your business talks about "orders" and "customers," your API should too, not "entities" and "records."
2. Consistent Naming and Status Codes
Pick conventions and stick to them:
- Field naming:
snake_caseorcamelCase, never mixed. - Date formats: ISO 8601 (
2025-11-14T21:00:00Z), always. - Pagination:
pageandlimit, oroffsetandlimit—same everywhere.
Use HTTP status codes correctly (for REST):
200 OK: Success with response body.201 Created: New resource created.204 No Content: Success, no response body.400 Bad Request: Client error, invalid input.401 Unauthorized: Authentication required/failed.403 Forbidden: Authenticated but not allowed.404 Not Found: Resource doesn't exist.500 Internal Server Error: Server-side failure.
Don't return 200 OK with {"error": "failed"} in the body. Use proper status codes.
3. Explicit Contracts
Document your API with machine-readable schemas:
- REST: OpenAPI (Swagger) with TypeScript codegen
- gRPC: Protocol Buffers (.proto files) with ts-proto
- GraphQL: GraphQL schema (SDL) with GraphQL Codegen
- tRPC: No schema needed (types inferred from code)
This gives you:
- Auto-generated documentation.
- Client code generation with full TypeScript types.
- Validation at build time or runtime.
- AI Copilot understands your API contracts better.
Example OpenAPI with TypeScript codegen (2026):
# openapi.yaml
openapi: 3.1.0
info:
title: Order API
version: 1.0.0
paths:
/orders/{orderId}:
get:
summary: Get order by ID
operationId: getOrder
parameters:
- name: orderId
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Order found
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'404':
description: Order not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Order:
type: object
required: [id, userId, items, total, status]
properties:
id:
type: string
format: uuid
userId:
type: string
format: uuid
items:
type: array
items:
$ref: '#/components/schemas/OrderItem'
total:
type: number
format: double
status:
type: string
enum: [pending, completed, cancelled]
OrderItem:
type: object
required: [productId, quantity, price]
properties:
productId:
type: string
quantity:
type: integer
minimum: 1
price:
type: number
format: double
// Generated TypeScript types (using openapi-typescript)
import type { operations, components } from './generated/api';
type Order = components['schemas']['Order'];
type OrderItem = components['schemas']['OrderItem'];
type GetOrderResponse = operations['getOrder']['responses']['200']['content']['application/json'];
// Now your API client is fully typed
import createClient from 'openapi-fetch';
import type { paths } from './generated/api';
const client = createClient<paths>({ baseUrl: 'https://api.example.com' });
// Fully type-safe API calls
const { data, error } = await client.GET('/orders/{orderId}', {
params: { path: { orderId: '123e4567-e89b-12d3-a456-426614174000' } },
});
if (data) {
// TypeScript knows exact shape of data
console.log(data.status); // 'pending' | 'completed' | 'cancelled'
}
Explicit schemas prevent "I thought this field was a string" bugs. In 2026, TypeScript codegen makes this even more powerful.
4. Backwards Compatibility
Additive changes are safe:
- Adding new endpoints.
- Adding optional fields to requests.
- Adding new fields to responses (clients should ignore unknown fields).
Breaking changes:
- Removing fields.
- Renaming fields.
- Changing field types.
- Changing endpoint URLs.
When you need breaking changes, version your API (more on this below).
5. Error Design That Helps Developers Debug
Bad error:
{
"error": "Invalid request"
}
What's invalid? How do I fix it?
Good error (2026 TypeScript approach):
// Define error types with discriminated unions
type ApiError =
| {
code: 'VALIDATION_ERROR';
message: string;
details: Array<{
field: string;
issue: string;
value?: unknown;
}>;
}
| {
code: 'NOT_FOUND';
message: string;
resource: string;
id: string;
}
| {
code: 'RATE_LIMIT_EXCEEDED';
message: string;
retryAfter: number; // seconds
limit: number;
}
| {
code: 'UNAUTHORIZED';
message: string;
reason: 'invalid_token' | 'expired_token' | 'missing_token';
};
// Example error response
const error: ApiError = {
code: 'VALIDATION_ERROR',
message: 'Invalid request parameters',
details: [
{
field: 'email',
issue: 'Email format is invalid',
value: 'not-an-email',
},
{
field: 'age',
issue: 'Age must be at least 18',
value: 15,
},
],
};
// With Zod, errors are automatically formatted
import { z } from 'zod';
const UserSchema = z.object({
email: z.string().email('Email format is invalid'),
age: z.number().min(18, 'Age must be at least 18'),
});
try {
UserSchema.parse({ email: 'not-an-email', age: 15 });
} catch (error) {
if (error instanceof z.ZodError) {
// Zod gives structured errors automatically
const apiError: ApiError = {
code: 'VALIDATION_ERROR',
message: 'Invalid request parameters',
details: error.errors.map(e => ({
field: e.path.join('.'),
issue: e.message,
value: e.input,
})),
};
}
}
Structure errors with:
- Machine-readable code (
VALIDATION_ERROR,RATE_LIMIT_EXCEEDED). - Human-readable message.
- Details about what went wrong and how to fix it.
- TypeScript types for error responses (client knows what to expect).
Errors are part of your API contract. Design them well.
Versioning and Change Management
Eventually, you'll need to make breaking changes. How do you handle this without chaos?
Versioning Strategies
1. URI Versioning (most common for REST):
GET /v1/orders
GET /v2/orders
Pros: Explicit, easy to route, easy to deploy different versions side-by-side.
Cons: URL changes, clients must update.
2. Header-Based Versioning:
GET /orders
Accept: application/vnd.myapi.v2+json
Pros: URL stays the same.
Cons: Less visible, harder to test in browser.
3. Evolution Without Explicit Versions:
Add new fields, keep old ones. Use feature flags. Let clients opt into new behavior with query params or request fields.
Pros: No version management complexity.
Cons: API surface grows over time, harder to remove old behavior.
Recommendation: For public APIs, use URI versioning (/v1, /v2). For internal APIs with controlled clients, evolution without versions can work if you're disciplined about backwards compatibility.
Deprecation Policy
When you deprecate an endpoint or version:
- Announce early: 6–12 months before shutdown.
- Provide migration guide: Clear steps to move to new version.
- Give a sunset date: Specific date when old version stops working.
- Monitor usage: Track who's still using deprecated APIs, reach out proactively.
Example deprecation notice:
v1 of the Orders API will be deprecated on June 1, 2026.
Please migrate to v2. [Migration guide here].
v2 adds pagination and better error handling.
Need help? Contact api-support@example.com.
Don't surprise your clients. Give them time and support to migrate.
Designing for Clients: Mobile, Web, Internal Services
Different clients have different needs. Design with empathy for their constraints.
Mobile Clients
Constraints:
- Intermittent connectivity: Requests may fail, retry logic is critical.
- Battery and bandwidth: Minimize payload size, reduce round trips.
- App store release cycles: Can't update clients instantly, need backwards compatibility for months.
Design considerations:
- Use pagination to avoid huge payloads.
- Support conditional requests (
If-Modified-Since,ETag) to save bandwidth. - Combine data in single endpoints where it makes sense (avoid 5 round trips for one screen).
Web Clients
Constraints:
- Frequent deploys: Can update clients quickly.
- Browser security: CORS, CSP, same-origin policy.
Design considerations:
- Flexibility: Web apps change fast, GraphQL or flexible REST endpoints work well.
- Authentication: Secure token handling (httpOnly cookies or secure storage).
Internal Service-to-Service
Constraints:
- High throughput: Thousands of requests per second.
- Low latency: Every millisecond matters.
- Strong contracts: Breaking changes are painful.
Design considerations:
- Use gRPC for performance.
- Enforce strict schemas (protobuf, strict OpenAPI validation).
- Avoid chatty APIs: Batch operations where possible.
Backend for Frontend (BFF) Pattern
If clients have very different needs, consider a BFF: a thin API layer per client type that aggregates backend services.
Example:
- Mobile BFF: Returns compact payloads, combines user + orders + notifications in one call.
- Web BFF: Returns more detailed data, supports richer queries.
- Internal Admin BFF: Returns admin-specific fields, different auth.
BFFs let you optimize per client without polluting your core APIs with client-specific logic.
API Testing in 2026: Type-Safe Approaches
With TypeScript everywhere, API testing becomes more reliable:
Contract Testing with TypeScript
// Using Vitest + MSW for API contract testing
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Order } from './types';
// Mock API responses with full type safety
const server = setupServer(
http.get('/api/orders/:id', ({ params }) => {
const order: Order = {
id: params.id as string,
userId: 'user-123',
items: [],
total: 100,
status: 'completed',
};
return HttpResponse.json(order);
})
);
beforeAll(() => server.listen());
afterAll(() => server.close());
it('fetches order with correct type', async () => {
const response = await fetch('/api/orders/123');
const order: Order = await response.json();
// TypeScript ensures we're testing the right shape
expect(order.status).toBe('completed');
});
End-to-End API Testing
// Using Playwright for E2E API testing
import { test, expect } from '@playwright/test';
import type { Order, ApiError } from './types';
test('GET /api/orders/:id returns order', async ({ request }) => {
const response = await request.get('/api/orders/123');
expect(response.ok()).toBeTruthy();
const order: Order = await response.json();
expect(order).toMatchObject({
id: expect.any(String),
status: expect.stringMatching(/^(pending|completed|cancelled)$/),
});
});
test('GET /api/orders/:id returns 404 for invalid id', async ({ request }) => {
const response = await request.get('/api/orders/invalid');
expect(response.status()).toBe(404);
const error: ApiError = await response.json();
expect(error.code).toBe('NOT_FOUND');
});
API Review and Governance
How do you keep consistency across teams without bureaucracy?
Lightweight API Review Process
Before implementing a new API:
- Write a design doc: Context, endpoints, request/response shapes, error cases.
- Schema review: Share OpenAPI/protobuf/GraphQL schema with a tech lead or architect.
- Check against guidelines: Naming, auth, pagination, errors consistent?
- Approve and merge: Once feedback addressed, ship it.
Avoid: Heavy committees, weeks of review, design-by-consensus paralysis.
Goal: Catch mistakes early (before clients integrate), ensure consistency, share knowledge.
Shared API Guidelines
Document your org's conventions:
Example guidelines:
- Naming:
snake_casefor JSON fields, plural nouns for collections (/orders, not/order). - Pagination: Use
page(1-indexed) andlimit(default 20, max 100). - Errors: Always return
error.code,error.message,error.details. - Auth: All endpoints require
Authorization: Bearer <token>unless explicitly public. - Dates: ISO 8601 format, UTC timezone.
Link to these guidelines in every API doc. Reference them in code reviews.
This isn't bureaucracy—it's shared language that makes cross-team integration easier.
Closing: APIs as Long-Term Contracts
When you ship an API, you're making a promise: "This is how my system works. You can depend on this."
Design APIs as if you'll support them for 10 years. Because you might.
This doesn't mean over-engineering. It means:
- Think about clients, not just your immediate needs.
- Be explicit: schemas, errors, versioning policies.
- Be consistent: patterns that repeat, not snowflakes.
- Plan for change: additive changes, deprecation paths, clear migration guides.
- 2026 Advantage: TypeScript makes APIs harder to misuse with compile-time safety.
Good APIs compound value over time. Bad APIs compound pain.
Checklist: Before You Merge That New API (2026 Edition)
- Clear purpose: Does this endpoint solve a real client need?
- TypeScript types: Are request/response types defined and exported?
- Runtime validation: Using Zod or similar for input validation?
- Consistent naming: Matches existing conventions (fields, resources, URLs)?
- Explicit schema: OpenAPI / protobuf / GraphQL schema / tRPC router documented?
- Authentication and authorization: Proper auth checks on all sensitive operations?
- Error handling: Structured errors with codes, messages, details?
- Backwards compatibility: Additive changes only, or versioned if breaking?
- Documentation: Examples, expected responses, error cases covered?
- AI-friendly: Clear types and schemas for AI code assistants?
- Tests: Unit tests and contract tests passing?
- Reviewed: Tech lead or architect reviewed design and schema?
Run this checklist before every new API. It takes 10 minutes. It saves months of pain later.
2026 API Technology Decision Matrix
interface ApiDecisionFactors {
clientControl: 'full' | 'partial' | 'none';
typeScript: boolean;
publicApi: boolean;
performance: 'critical' | 'important' | 'moderate';
complexity: 'low' | 'medium' | 'high';
}
function recommendApiStyle(factors: ApiDecisionFactors): string {
// Full TypeScript stack with client control → tRPC wins
if (factors.typeScript && factors.clientControl === 'full' && !factors.publicApi) {
return 'tRPC - Best DX, end-to-end type safety';
}
// Public API → REST is the safe choice
if (factors.publicApi) {
return 'REST with OpenAPI - Universal compatibility';
}
// Performance critical, internal services → gRPC
if (factors.performance === 'critical' && factors.clientControl !== 'none') {
return 'gRPC - High performance, strong contracts';
}
// Complex frontend needs, partial control → GraphQL
if (factors.complexity === 'high' && factors.clientControl === 'partial') {
return 'GraphQL - Flexible queries, no over-fetching';
}
// Default: REST with TypeScript
return 'REST with Zod validation - Simple, reliable';
}
Good API design isn't glamorous. It's boring, consistent, well-documented, and thoughtfully versioned. It ages quietly, supports teams you've never met, and rarely needs emergency fixes.
In 2026, TypeScript makes good API design easier with compile-time safety, better tooling, and AI assistants that understand your contracts. Use it.
That's the goal. Design APIs that are so well-behaved, nobody has to think about them.
