Skip to content

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