DOMAIN:BACKEND:API_DESIGN¶
OWNER: urszula (Team Alfa), maxim (Team Bravo)
UPDATED: 2026-03-24
SCOPE: REST API design for all GE client projects
FRAMEWORK: Hono (primary), Fastify (legacy)
API:DESIGN_PRINCIPLES¶
PRINCIPLE_1: APIs are contracts — breaking changes require versioning
PRINCIPLE_2: spec-first with OpenAPI — define before implement
PRINCIPLE_3: consistent error envelopes — clients parse ONE format
PRINCIPLE_4: type-safe end-to-end — Zod on server, inferred types on client
PRINCIPLE_5: pagination by default — never return unbounded lists
PRINCIPLE_6: idempotency for mutations — safe to retry
API:ERROR_ENVELOPE¶
RULE: every API response uses the same envelope structure
RULE: never return raw errors, stack traces, or database messages to clients
RULE: HTTP status codes are correct — 400 for validation, 401 for auth, 403 for authz, 404 for not found, 409 for conflict, 422 for business rule, 500 for unexpected
STANDARD_ENVELOPE¶
// types/api.ts
import { z } from 'zod';
// Success envelope
interface ApiSuccess<T> {
success: true;
data: T;
meta?: {
page?: number;
pageSize?: number;
total?: number;
hasMore?: boolean;
};
}
// Error envelope
interface ApiError {
success: false;
error: {
code: string; // machine-readable: "VALIDATION_ERROR", "NOT_FOUND"
message: string; // human-readable summary
details?: unknown[]; // Zod issues or field-level errors
requestId?: string; // correlation ID for debugging
};
}
type ApiResponse<T> = ApiSuccess<T> | ApiError;
HONO_ERROR_HANDLER¶
// middleware/error-handler.ts
import { ErrorHandler } from 'hono';
import { ZodError } from 'zod';
import { HTTPException } from 'hono/http-exception';
export const errorHandler: ErrorHandler = (err, c) => {
const requestId = c.get('requestId') ?? crypto.randomUUID();
if (err instanceof ZodError) {
return c.json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed',
details: err.issues, // Zod v4: .issues not .errors
requestId,
},
}, 400);
}
if (err instanceof HTTPException) {
return c.json({
success: false,
error: {
code: err.message.toUpperCase().replace(/\s+/g, '_'),
message: err.message,
requestId,
},
}, err.status);
}
// Unexpected error — log full details, return generic message
console.error({ err, requestId }, 'Unhandled error');
return c.json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
requestId,
},
}, 500);
};
ANTI_PATTERN: returning different error shapes from different endpoints
FIX: use shared errorHandler middleware on the Hono app instance
ANTI_PATTERN: leaking database error messages (e.g., "unique constraint violated on users.email")
FIX: map database errors to domain errors before returning
API:PAGINATION¶
RULE: every list endpoint supports pagination
RULE: use cursor-based pagination for large/dynamic datasets, offset-based for small/static
RULE: default page size = 20, maximum page size = 100
OFFSET_PAGINATION¶
// Good for: admin dashboards, small datasets, when total count matters
import { z } from 'zod';
const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
// In handler
const { page, pageSize } = paginationSchema.parse(c.req.query());
const offset = (page - 1) * pageSize;
const [items, countResult] = await Promise.all([
db.select().from(users).limit(pageSize).offset(offset).orderBy(users.createdAt),
db.select({ count: count() }).from(users),
]);
return c.json({
success: true,
data: items,
meta: {
page,
pageSize,
total: countResult[0].count,
hasMore: offset + items.length < countResult[0].count,
},
});
CURSOR_PAGINATION¶
// Good for: infinite scroll, real-time feeds, large datasets
const cursorSchema = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
// In handler
const { cursor, limit } = cursorSchema.parse(c.req.query());
const query = db.select().from(posts).orderBy(desc(posts.createdAt)).limit(limit + 1);
if (cursor) {
query.where(lt(posts.createdAt, new Date(cursor)));
}
const items = await query;
const hasMore = items.length > limit;
if (hasMore) items.pop();
return c.json({
success: true,
data: items,
meta: {
cursor: hasMore ? items[items.length - 1].createdAt.toISOString() : null,
hasMore,
},
});
ANTI_PATTERN: using offset pagination for real-time feeds — rows shift as new data arrives
FIX: use cursor pagination keyed on a stable, indexed column (createdAt + id)
ANTI_PATTERN: not limiting page size — client can request pageSize=999999
FIX: always clamp with z.coerce.number().max(100)
API:FILTERING_AND_SORTING¶
RULE: use query parameters for filtering, not request body on GET
RULE: validate and whitelist all filter/sort fields — never pass raw column names to Drizzle
// Safe filtering pattern
const filterSchema = z.object({
status: z.enum(['active', 'inactive', 'suspended']).optional(),
search: z.string().max(200).optional(),
sortBy: z.enum(['createdAt', 'name', 'email']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
// Map validated fields to Drizzle columns
const sortColumns = {
createdAt: users.createdAt,
name: users.name,
email: users.email,
} as const;
const { status, search, sortBy, sortOrder } = filterSchema.parse(c.req.query());
const conditions: SQL[] = [];
if (status) conditions.push(eq(users.status, status));
if (search) conditions.push(ilike(users.name, `%${search}%`));
const items = await db.select()
.from(users)
.where(and(...conditions))
.orderBy(sortOrder === 'asc' ? asc(sortColumns[sortBy]) : desc(sortColumns[sortBy]))
.limit(pageSize)
.offset(offset);
ANTI_PATTERN: db.select().from(users).orderBy(sql.raw(req.query.sortBy)) — SQL injection
FIX: whitelist sort fields in Zod enum, map to Drizzle column references
API:VERSIONING¶
RULE: version via URL prefix: /api/v1/, /api/v2/
RULE: never break v1 — add v2 alongside, deprecate v1 with sunset header
RULE: header-based versioning (Accept-Version) is too invisible — URL prefix is clearer
// Versioned route structure in Hono
const v1 = new Hono();
v1.route('/users', usersV1);
v1.route('/projects', projectsV1);
const v2 = new Hono();
v2.route('/users', usersV2);
const app = new Hono();
app.route('/api/v1', v1);
app.route('/api/v2', v2);
// Sunset header middleware for deprecated versions
app.use('/api/v1/*', async (c, next) => {
await next();
c.header('Sunset', 'Sat, 01 Jul 2026 00:00:00 GMT');
c.header('Deprecation', 'true');
c.header('Link', '</api/v2>; rel="successor-version"');
});
API:RATE_LIMITING¶
RULE: rate limit all public endpoints
RULE: use sliding window counter in Redis for distributed rate limiting
RULE: return 429 with Retry-After header
// middleware/rate-limit.ts
import { createMiddleware } from 'hono/factory';
import { redis } from '../lib/redis';
export const rateLimit = (opts: { window: number; max: number }) =>
createMiddleware(async (c, next) => {
const key = `ratelimit:${c.req.path}:${c.req.header('x-forwarded-for') ?? 'unknown'}`;
const current = await redis.incr(key);
if (current === 1) await redis.expire(key, opts.window);
c.header('X-RateLimit-Limit', String(opts.max));
c.header('X-RateLimit-Remaining', String(Math.max(0, opts.max - current)));
if (current > opts.max) {
c.header('Retry-After', String(opts.window));
return c.json({
success: false,
error: { code: 'RATE_LIMITED', message: 'Too many requests' },
}, 429);
}
await next();
});
// Usage
app.use('/api/v1/*', rateLimit({ window: 60, max: 100 }));
API:OPENAPI_SPEC_FIRST¶
RULE: define OpenAPI spec BEFORE implementing endpoints
RULE: use @hono/zod-openapi for automatic spec generation from Zod schemas
WHY: spec-first prevents drift between docs and implementation
// Using @hono/zod-openapi
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
const getUserRoute = createRoute({
method: 'get',
path: '/users/{id}',
request: {
params: z.object({ id: z.string().uuid() }),
},
responses: {
200: {
content: { 'application/json': { schema: userResponseSchema } },
description: 'User found',
},
404: {
content: { 'application/json': { schema: errorSchema } },
description: 'User not found',
},
},
});
const app = new OpenAPIHono();
app.openapi(getUserRoute, async (c) => {
const { id } = c.req.valid('param');
const user = await db.query.users.findFirst({ where: eq(users.id, id) });
if (!user) return c.json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } }, 404);
return c.json({ success: true, data: user }, 200);
});
// Serve OpenAPI spec
app.doc('/openapi.json', { openapi: '3.1.0', info: { title: 'API', version: '1' } });
API:REST_VS_GRAPHQL_VS_TRPC¶
DECISION_MATRIX¶
CHECK: is this a public API consumed by external clients?
IF: yes THEN: REST with OpenAPI — widest compatibility, cacheable, tooling-rich
IF: no, internal frontend-to-backend only THEN: consider Hono RPC or tRPC
CHECK: does the client need flexible field selection across many entities?
IF: yes, complex data graph with varying client needs THEN: GraphQL may be justified
IF: no, predictable data shapes THEN: REST or RPC — simpler, more performant
CHECK: is this a monorepo with shared TypeScript frontend and backend?
IF: yes THEN: Hono RPC (built-in, zero extra deps) or tRPC
IF: separate repos THEN: REST with OpenAPI-generated client
GE_DEFAULT¶
RULE: Hono RPC for internal frontend-to-backend communication (monorepo)
RULE: REST with OpenAPI for any API that external systems consume
RULE: GraphQL only if client has existing GraphQL infrastructure — never for greenfield
HONO_RPC_PATTERN¶
// Server: export the app type
const app = new Hono()
.get('/users', async (c) => {
const users = await db.query.users.findMany();
return c.json({ success: true, data: users });
})
.post('/users', zValidator('json', createUserSchema), async (c) => {
const body = c.req.valid('json');
const user = await db.insert(users).values(body).returning();
return c.json({ success: true, data: user[0] }, 201);
});
export type AppType = typeof app;
// Client: full type inference
import { hc } from 'hono/client';
import type { AppType } from '../server';
const client = hc<AppType>('http://localhost:3000');
const res = await client.users.$get(); // typed response
const data = await res.json(); // { success: true, data: User[] }
API:IDEMPOTENCY¶
RULE: all POST/PUT endpoints that create resources should support idempotency keys
RULE: use Idempotency-Key header for POST requests
WHY: safe retries, prevents duplicate resource creation on network failures
// middleware/idempotency.ts
export const idempotency = createMiddleware(async (c, next) => {
if (c.req.method !== 'POST') return next();
const key = c.req.header('Idempotency-Key');
if (!key) return next();
const cached = await redis.get(`idempotency:${key}`);
if (cached) return c.json(JSON.parse(cached));
await next();
// Cache the response for 24 hours
const body = await c.res.clone().text();
await redis.set(`idempotency:${key}`, body, 'EX', 86400);
});
API:REQUEST_VALIDATION¶
RULE: validate ALL inputs — params, query, body, headers
RULE: use Hono's built-in zValidator middleware
RULE: validate at the edge (middleware), not inside business logic
RULE: Zod v4 uses .issues not .errors on ZodError
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const createProjectSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
clientId: z.string().uuid(),
budget: z.number().positive().optional(),
});
app.post(
'/projects',
zValidator('json', createProjectSchema),
async (c) => {
const body = c.req.valid('json'); // fully typed
// body.name is string, body.clientId is string, etc.
const project = await projectService.create(body);
return c.json({ success: true, data: project }, 201);
}
);
ANTI_PATTERN: validating inside the handler with manual if/else checks
FIX: use zValidator middleware — centralizes validation, returns consistent 400 errors
ANTI_PATTERN: trusting URL params without validation (e.g., assuming :id is a UUID)
FIX: validate params too: zValidator('param', z.object({ id: z.string().uuid() }))
API:CORS_AND_SECURITY_HEADERS¶
RULE: configure CORS explicitly — never use origin: '*' in production
RULE: set security headers on all responses
import { cors } from 'hono/cors';
import { secureHeaders } from 'hono/secure-headers';
app.use('*', cors({
origin: ['https://app.client-domain.com'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowHeaders: ['Content-Type', 'Authorization', 'Idempotency-Key'],
maxAge: 86400,
}));
app.use('*', secureHeaders());
API:HEALTH_CHECKS¶
RULE: every API has /health (liveness) and /ready (readiness) endpoints
RULE: /health returns 200 if process is alive — no dependency checks
RULE: /ready returns 200 only if all dependencies (DB, Redis) are reachable
app.get('/health', (c) => c.json({ status: 'ok' }));
app.get('/ready', async (c) => {
try {
await Promise.all([
db.execute(sql`SELECT 1`),
redis.ping(),
]);
return c.json({ status: 'ready', dependencies: { db: 'ok', redis: 'ok' } });
} catch (err) {
return c.json({ status: 'not_ready', error: String(err) }, 503);
}
});
API:AGENTIC_CHECKLIST¶
ON_CREATING_API:
1. CHECK: does OpenAPI spec exist? IF not, define it first
2. CHECK: are all inputs validated with Zod schemas?
3. CHECK: does error handler use standard envelope?
4. CHECK: are list endpoints paginated?
5. CHECK: are filter/sort fields whitelisted?
6. CHECK: is rate limiting applied?
7. CHECK: are CORS origins explicit?
8. CHECK: do /health and /ready endpoints exist?
9. CHECK: is idempotency supported for POST endpoints?
10. RUN: vitest to verify all routes return expected shapes