uuid(),
errorCode: z.string(),
userMessage: z.string(),
fieldErrors: z.array(z.object({
path: z.string(),
constraint: z.string(),
})).optional(),
});
export type ApiErrorEnvelope = z.infer<typeof ApiErrorEnvelope>;
### Step 2: Implement REST with Hono & Standardized Headers
REST excels when clients expect predictable HTTP semantics. Use Hono for its runtime-agnostic design and lightweight middleware pipeline.
```typescript
// rest/invoiceRouter.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { PaginationParams, ApiErrorEnvelope } from '../shared/validation';
const invoiceApp = new Hono();
invoiceApp.get('/invoices', zValidator('query', PaginationParams), async (c) => {
const { page, limit } = c.req.valid('query');
// Simulate data fetch
const records = await db.invoices.findMany({ skip: (page - 1) * limit, take: limit });
// Standardized rate limiting headers
c.header('X-RateLimit-Limit', '1000');
c.header('X-RateLimit-Remaining', '987');
c.header('X-RateLimit-Reset', String(Math.floor(Date.now() / 1000) + 3600));
return c.json({ data: records, meta: { page, limit, total: 1420 } });
});
invoiceApp.onError((err, c) => {
const envelope: ApiErrorEnvelope = {
traceId: crypto.randomUUID(),
errorCode: 'VALIDATION_FAILURE',
userMessage: err.message,
fieldErrors: err instanceof z.ZodError ? err.issues.map(i => ({ path: i.path.join('.'), constraint: i.message })) : undefined,
};
return c.json(envelope, 400);
});
Architecture Rationale: URL versioning (/v1/invoices) is reserved exclusively for breaking contract changes. Additive changes use query parameters or optional fields. Rate limit headers follow the IETF draft standard, enabling client-side backoff without custom logic. Error envelopes include a traceId that maps directly to structured logs, eliminating debugging guesswork.
Step 3: Implement GraphQL with Pothos & DataLoader
GraphQL requires strict schema discipline to prevent performance degradation. Pothos provides a type-safe builder pattern that avoids string-heavy schema definitions.
// graphql/schemaBuilder.ts
import SchemaBuilder from '@pothos/core';
import { createLoader } from '../shared/dataLoader';
const builder = new SchemaBuilder({});
builder.queryType({
fields: (t) => ({
catalog: t.field({
type: 'CatalogConnection',
args: { first: t.arg.int({ required: false }), after: t.arg.string({ required: false }) },
resolve: async (_, args, ctx) => {
const items = await ctx.loaders.catalogLoader.loadMany(args.after ? ctx.loaders.getCursorIds(args.after) : []);
return { edges: items.map(i => ({ node: i })), pageInfo: { hasNextPage: items.length > 0 } };
},
}),
}),
});
builder.objectType('CatalogConnection', {
fields: (t) => ({
edges: t.field({ type: ['CatalogEdge'] }),
pageInfo: t.field({ type: 'PageInfo' }),
aggregateStock: t.int({ resolve: () => 0 }), // Pre-computed via background job
}),
});
builder.objectType('CatalogEdge', {
fields: (t) => ({
node: t.field({ type: 'Product' }),
}),
});
builder.objectType('Product', {
fields: (t) => ({
id: t.id(),
sku: t.string(),
stockLevel: t.int({ resolve: (parent) => parent.stockLevel }), // Avoids live COUNT queries
}),
});
Architecture Rationale: The aggregateStock and stockLevel fields are pre-computed or cached, preventing expensive COUNT(*) or join queries on every request. DataLoader batches and caches database calls, eliminating N+1 query patterns. Pagination uses cursor-based connections, which are stable under concurrent mutations. GraphQL Yoga 5 handles execution, while Pothos enforces type safety during schema construction.
Step 4: Implement tRPC with Procedure Composition
tRPC shines in TypeScript monorepos where the client and server share a type graph. The key is isolating procedures from infrastructure concerns.
// trpc/workspaceRouter.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import { db } from '../shared/db';
const t = initTRPC.context<{ requestId: string }>().create();
const publicProcedure = t.procedure;
export const workspaceRouter = t.router({
listProjects: publicProcedure
.input(z.object({ teamId: z.string().uuid(), archived: z.boolean().default(false) }))
.query(async ({ input, ctx }) => {
const projects = await db.project.findMany({
where: { teamId: input.teamId, isArchived: input.archived },
orderBy: { createdAt: 'desc' },
});
return projects;
}),
archiveProject: publicProcedure
.input(z.object({ projectId: z.string().uuid() }))
.mutation(async ({ input, ctx }) => {
const updated = await db.project.update({
where: { id: input.projectId },
data: { isArchived: true, archivedAt: new Date() },
});
return updated;
}),
});
export type WorkspaceRouter = typeof workspaceRouter;
Architecture Rationale: tRPC procedures are thin wrappers around business logic. The context object carries request-scoped data (like requestId) without polluting procedure signatures. TanStack Query handles client-side caching, retries, and background refetching, making tRPC behave like a typed RPC layer over HTTP. Kysely provides compile-time SQL safety, ensuring database queries align with the inferred types.
Pitfall Guide
1. Over-Versioning REST Endpoints
Explanation: Teams version every minor change (/v1, /v2, /v3), fragmenting documentation and breaking client compatibility.
Fix: Reserve URL versioning for breaking contract changes. Use query parameters, optional fields, and additive enums for backward-compatible updates. Deprecate fields via Sunset headers instead of creating new versions.
2. N+1 Query Explosion in GraphQL
Explanation: Resolvers execute independently, causing a database query per nested field. A single dashboard query can trigger hundreds of round trips.
Fix: Implement DataLoader for batching and caching. Pre-compute aggregate fields. Enforce query depth limits and complexity scoring at the gateway level.
3. tRPC Monorepo Coupling
Explanation: Business logic gets tangled with tRPC procedure definitions, making it impossible to reuse logic in background workers, CLI tools, or external APIs.
Fix: Extract domain logic into a shared package. Treat tRPC routers as thin transport adapters that call service layers. Keep validation schemas in a shared boundary package.
4. Ignoring Standardized Rate Limit Headers
Explanation: Custom rate limit responses force clients to parse proprietary JSON, increasing integration friction and client-side complexity.
Fix: Implement X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and Retry-After headers. Use middleware to attach them consistently. Clients can then implement exponential backoff using standard HTTP semantics.
5. Schema Bloat in GraphQL
Explanation: Adding every possible field to the schema to satisfy edge cases, resulting in a massive, unmaintainable type graph and slow introspection.
Fix: Enforce field-level permissions. Use schema composition or module federation only when necessary. Prefer explicit, narrow queries over catch-all types. Audit schema usage via Studio telemetry to remove unused fields.
6. Exposing tRPC to External Consumers
Explanation: tRPC relies on TypeScript inference and custom HTTP headers. External clients in other languages cannot consume it without heavy client generation or protocol translation.
Fix: Reserve tRPC for internal full-stack applications. Expose REST or GraphQL for partner APIs, public SDKs, and third-party integrations. Use an API gateway to route external traffic to the appropriate contract.
7. Inconsistent Error Envelopes
Explanation: Mixing stack traces, raw database errors, and custom JSON shapes across endpoints, making client error handling brittle.
Fix: Standardize on a single error envelope with traceId, errorCode, userMessage, and optional fieldErrors. Map framework-specific errors to this envelope in a global error handler. Never leak internal stack traces to clients.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Public/Partner API | REST | Universal HTTP compatibility, mature SDK generation, straightforward caching | Low infrastructure overhead, high documentation maintenance |
| TypeScript Monorepo / Internal Tools | tRPC | End-to-end type safety, zero codegen, rapid iteration | High TS expertise requirement, zero multi-language support |
| Multi-Client Dashboard (Web + Mobile + IoT) | GraphQL | Client-driven queries, schema contract reduces coordination | Higher initial schema design cost, requires DataLoader & caching strategy |
| Polyglot Microservices | REST | Language-agnostic, simple contract testing, easy load balancing | Moderate overfetching risk, requires careful versioning |
| High-Throughput CRUD / Event Ingestion | REST | Predictable HTTP semantics, efficient CDN caching, simple rate limiting | Low flexibility, rigid response shapes |
Configuration Template
// gateway/apiConfig.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { requestId } from 'hono/request-id';
import { logger } from 'hono/logger';
import { rateLimit } from 'hono-rate-limit';
import { invoiceApp } from './rest/invoiceRouter';
import { createYoga } from 'graphql-yoga';
import { schema } from './graphql/schemaBuilder';
import { createTRPCContext } from './trpc/context';
import { appRouter } from './trpc/rootRouter';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
const app = new Hono();
// Global middleware
app.use('*', requestId());
app.use('*', logger());
app.use('*', cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || [] }));
// Rate limiting for public routes
app.use('/api/v1/*', rateLimit({
windowMs: 15 * 60 * 1000,
limit: 1000,
standardHeaders: 'draft-6',
keyGenerator: (c) => c.req.header('x-forwarded-for') || c.req.ip || 'anonymous',
}));
// REST routes
app.route('/api/v1', invoiceApp);
// GraphQL endpoint
app.use('/graphql', async (c) => {
const yoga = createYoga({
schema,
context: () => ({ requestId: c.get('requestId') }),
graphiql: process.env.NODE_ENV === 'development',
});
return yoga.handle(c.req.raw, c.env);
});
// tRPC endpoint
app.use('/trpc/*', async (c) => {
return fetchRequestHandler({
endpoint: '/trpc',
req: c.req.raw,
router: appRouter,
createContext: () => createTRPCContext({ requestId: c.get('requestId') }),
});
});
export default app;
Quick Start Guide
- Scaffold the boundary layer: Initialize a new project with Hono, Zod, and your preferred database client. Create a shared validation package for schemas and error envelopes.
- Define the contract: Choose REST, GraphQL, or tRPC based on the Decision Matrix. Implement the router/procedure layer with strict input validation and standardized error handling.
- Add observability: Attach
requestId to every request. Configure structured logging to include trace IDs, response times, and error codes. Set up rate limit headers and monitor usage via API dashboard.
- Deploy & validate: Push to a staging environment. Run contract tests against the schema. Verify error envelopes, rate limit behavior, and type inference. Promote to production once client integration tests pass.