API Design & Standards (Hono + Zod)
When to Use This Skill
- Defining new HTTP endpoints for a bounded context
- Reviewing, auditing, or refactoring existing API routes for consistency
- Establishing API standards for a TypeScript/Clean Architecture backend
- Designing contracts for frontend, SDK, CLI, MCP, or third-party consumers
- Defining or updating API contracts in a shared contracts package (e.g. )
- Enforcing consistent route registration and generated OpenAPI docs
Choose the API Style First
This skill supports
two mutually exclusive conventions. Lock onto exactly one per project before designing or auditing any endpoint, and never mix them within the same API surface. The shared core below (Clean Architecture, Zod as source of truth,
+
, the error helper, generated OpenAPI) applies to both styles. Only paths, methods, parameter placement, and response shapes differ.
| Style | Shape | Read |
|---|
| Standard REST | Resource paths + HTTP verbs (////); params in path/query/body; responses return the resource/collection directly | |
| POST-only action-based | Every endpoint is POST /<context>/<entity>/<action>
; JSON body only; responses use the / envelope | references/style-post-only.md
|
Determine the style in this order:
- Project docs win. Check , ADRs, and design docs. If they specify a style (or conflict with anything in this skill), the project documents take precedence.
- Infer from existing routes. Grep for and inspect / : varied verbs with paths means REST; every route with suffixes means POST-only.
- Greenfield or ambiguous: ask the user which style the project uses. Default to standard REST for new public APIs unless the project has already standardized on POST-only.
Once chosen, read only that style's reference file and apply it consistently. If a project already uses one style, do not introduce endpoints in the other.
Core Principles (both styles)
- Contracts in a shared package: Request/response schemas live in a central package when shared across backend, frontend, SDKs, or tests.
- Zod is the single source of truth: Every schema drives runtime validation, TypeScript types (), and the OpenAPI spec at once. Never duplicate hand-written OpenAPI YAML/JSON.
- Every endpoint uses +
app.openapi(route, handler)
: Never register handlers directly with , , etc., or the endpoint vanishes from . The call must list every possible response status code in .
- Error mapping goes through a single shared helper: Typed application errors map to HTTP in one place (e.g.
mapApplicationErrorToResponse(context, error)
). Never inline return context.json({ error, details: { code } }, status)
in a controller, use case, or route handler. If the helper doesn't cover a new error type, extend the helper — do not branch inline.
- is generated, never authored: The spec is produced by from registered schemas and routes. If the spec is wrong, fix the Zod schema, not the spec. Never commit a generated spec.
- Scalar UI consumes the generated spec: Interactive docs render from the same the clients consume. No separate documentation artifact exists.
- Clean Architecture: Controllers are thin adapters over application use cases. Design starts from domain use cases and application DTOs, then maps to HTTP.
Design Workflow (Top-Down)
- Start from the use case
- Identify the bounded context and use case (e.g. , ).
- Define clear input/output DTOs in the application layer.
- Map to the chosen style
- Apply the path, method, parameter, and response conventions from your style's reference file.
- Decide status codes and error shapes.
- Map HTTP ↔ use case
- The route/controller parses validated HTTP input into application DTOs, calls the use case, and maps the result back to HTTP.
- Apply cross-cutting concerns consistently
- Auth, authorization, validation, logging, idempotency, rate limits, and error handling follow shared helpers.
Audit Workflow
When auditing an existing route or API surface, first confirm the project's style, then walk the §API Design Checklist for
each endpoint under review and report pass/fail per item — including schema-level concerns like
metadata on every request/response schema, not just method/path. A route that uses the right method and shape but inlines Zod schemas (missing
) is still non-compliant and must be flagged. So is any endpoint that uses the wrong style for the project.
Validate Both Directions (request AND response)
+
validate the
request (params, query, body) at runtime, but they do
not validate the
response — Hono sends whatever the handler returns, even if it drifts from the declared response schema. Declaring response schemas in
and asserting them in tests is not the same as guaranteeing them in production. Validate the outgoing body at runtime too, so the API can never silently return an off-contract response that breaks generated SDKs/MCP/CLI tools.
Do this through one shared response helper that parses the payload through the response schema before sending. A mismatch throws and is caught by the global error handler (mapped to
) rather than shipping a malformed body:
ts
// src/infra/http/respond.ts — shared, used by every controller in both styles
import type { Context } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import type { z } from "@hono/zod-openapi";
export function respond<S extends z.ZodTypeAny>(
context: Context,
schema: S,
payload: z.input<S>,
status: ContentfulStatusCode
) {
return context.json(schema.parse(payload), status); // runtime-validated against the contract
}
Keep
always-on in non-production and at least sampled in production if the parse cost matters; never skip it entirely, or the response contract is unenforced. Request-side: register a
on the
instance so failed request validation becomes the shared error envelope (
) instead of Hono's default body — see
references/openapi-generation.md
.
Controller Pattern (both styles)
Controllers self-register routes via
app.openapi(route, handler)
.
validates the request — no separate
call is needed. On success the controller returns through
so the body is validated against the declared response schema; the success shape differs by style (the resource schema directly for REST, the
response schema for POST-only). The error path is identical and goes through the shared error helper.
ts
import type { OpenAPIHono } from "@hono/zod-openapi";
import { someRoute } from "../routes/some.route";
import { SomeResponseSchema } from "@/api-contracts";
import { mapApplicationErrorToResponse } from "@/infra/http/error-handling";
import { respond } from "@/infra/http/respond";
export class SomeController {
constructor(
private readonly app: OpenAPIHono,
private readonly useCase: SomeUseCase
) {
this.app.openapi(someRoute, async context => {
const input = context.req.valid("json"); // or "query" / "param"
const credential = context.get("credential");
const result = await this.useCase.execute({ ...input, workspaceId: credential.workspaceId });
if (!result.ok) return mapApplicationErrorToResponse(context, result.error);
// REST: respond(context, SomeResourceSchema, result.value, 200)
// POST-only: respond(context, SomeResponseSchema, { data: result.value, message: "Success" }, 200)
return respond(context, SomeResponseSchema, result.value, 200);
});
}
}
See your style's reference file for the exact success shape and concrete
examples.
Cross-Cutting Concerns (both styles)
Bake these into the contract, not just the prose — generated SDK/MCP/CLI consumers depend on them being machine-readable.
- Idempotency for unsafe operations. Creation and state-changing operations must be safely retryable — clients and gateways retry on timeouts, and a naive retry double-charges or double-creates. Accept an (REST header, or an body field in POST-only) on create/state-change endpoints; persist the key with its first result for a dedup window; a replay with the same key returns the original response, the same key with a different payload returns . Reads are naturally idempotent and need no key.
- Versioning & breaking changes. Version the surface from day one ( prefix for REST; a version segment or header for POST-only). Within a version make only additive, backward-compatible changes: add optional fields, never remove or repurpose existing ones, never tighten validation on existing inputs. A breaking change requires a new version plus a deprecation path — signal removal with and response headers and a migration window. Otherwise generated consumers break silently.
- Rate limiting. Declare and make limits observable: emit , , and on responses, plus on a , and declare them in the route's headers so codegen clients can back off. Scope limits per credential/workspace, not per IP, for authenticated APIs.
Shared Conventions
Credential model, authorization semantics, and field formats apply to both styles and are detailed in
references/api-conventions.md
— read it when shaping contracts, credentials, or money/date fields. Key defaults:
- Credentials carry (what) + (where). Never infer broad access; whole-tenant access needs an explicit boundary. MCP tokens are a separate credential type. Declare via OpenAPI so codegen/MCP tooling can model them.
- Authorization: out-of-scope resource → ; exists-but-forbidden → ; query filters narrow but never expand a credential's boundary.
- Field formats: camelCase JSON fields/query params; money as a decimal string + ISO 4217 (never floats); instants ISO 8601 with timezone, business dates ; temporal filters name their dimension (, not ).
- Async operations: + a status resource with an explicit state enum, polled via .
Status Codes
- — successful reads and updates with a body
- — successful creation; include when practical
- — async job accepted (REST resource-style operations)
- — successful delete/revoke/disconnect with no body
- — conditional GET with unchanged representation (REST)
- — malformed input or validation error at the HTTP boundary
- — missing/invalid authentication
- — authenticated but not allowed
- — resource not found
- — duplicates, state conflicts, business invariants
- — domain validation failure distinct from HTTP validation
- — rate limit
500 Internal Server Error
— unhandled errors
Error Envelope (both styles)
Errors use the same structured body in both styles.
is a human-readable string safe for UI; everything machine-readable lives inside
.
json
{
"error": "Human-readable message",
"details": {
"code": "RESOURCE_NOT_FOUND",
"requestId": "req_01HZ..."
}
}
Rules:
- — always a string; never an object, never holds the code.
- — always required; stable, machine-readable, .
- — required in production for traceability.
- Additional optional context goes inside : , , , etc.
- Never put at the top level, never nest
{ error: { code, message } }
, never return free-form error strings.
OpenAPI Spec Generated From Zod
The OpenAPI specification is a build artifact of the Zod schemas, never hand-written.
text
Zod schema → .openapi("RefName") → createRoute({ ... })
→ app.openapi(route, handler) → /openapi.json
→ Scalar UI + generated clients
Non-negotiable rules:
- Every reused schema must call so it appears in .
- Every endpoint must be declared with and mounted with
app.openapi(route, handler)
.
- Every response status code the endpoint can emit must be listed in .
- Auth must be declared via OpenAPI , registered once at bootstrap with
registerComponent("securitySchemes", ...)
.
- Do not commit generated OpenAPI specs.
For the full walkthrough (schema annotation, bootstrap wiring, client codegen, common mistakes), read
references/openapi-generation.md
. For the route-declaration examples in your convention, read your style's reference file.
Testing Strategy
- Contracts: unit test Zod schemas with valid/invalid payloads.
- Controllers: integration test Hono routes — status codes, auth, validation, error mapping, and the response shape for your style.
- OpenAPI: boot the server, fetch in tests, and verify endpoints, methods, schemas, response codes, and security.
- E2E: exercise key flows through the public API or a generated client.
API Design Checklist
Before merging an API change:
- The endpoint follows the project's chosen style consistently (no mixing REST and POST-only).
- Contracts are defined in the shared package and annotated with .
- Route uses and is mounted with , declaring every success and error status code plus the correct entry.
- Parameter placement matches the style (REST: path/query/body; POST-only: JSON body only).
- Response shape matches the style (REST: resource directly; POST-only: envelope). Error responses use
{ error, details: { code } }
.
- Controller is thin, self-registering, and delegates to a use case.
- Typed application errors map through the shared HTTP error helper.
- Auth and authorization are machine-readable in OpenAPI and enforced in code.
- regenerates at boot and reflects the change — verified by fetching the spec.
- Tests cover success, validation failure, auth failure, authorization failure, and domain conflicts.
- Cross-cutting concerns are addressed: unsafe (create/state-change) operations are idempotent via ; the versioning posture is stated with additive-only/breaking-change rules; rate-limit headers ( / ) are declared. For a read-only endpoint, note idempotency as N/A rather than omitting it.
- Shared conventions hold (see
references/api-conventions.md
): credentials declare + ; authorization uses vs correctly and filters never expand scope; field formats follow camelCase / money-as-decimal-string + ISO 4217 / ISO 8601 instants / dates / named temporal filters; async operations return + a status resource.
Pitfalls (both styles)
- Do not mirror table names blindly; paths represent public product resources, not the database schema.
- Do not bypass OpenAPI route registration with / .
- Do not hide auth requirements in free-form text; use .
- Do not make breaking response changes without versioning.
- Do not inline error responses; route them through the shared helper.
Style-specific naming rules and pitfalls live in each style's reference file.