Use when building or extending REST APIs with Hono + @hono/zod-openapi. Covers schema-first route declaration with createRoute, feature-grouped composable routers, a class-based service layer behind interfaces, the strategy pattern for multi-event webhooks, typed request validation, and auto-generated OpenAPI docs.
Scanned 9/6/2026
Install to Claude Code
npx -y skills add whimzyLive/nightshift-ai --skill hono-api --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Hono Api?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/whimzylive-hono-api)More formats (shields.io, HTML) on the badges page.
---
name: hono-api
description: Use when building or extending REST APIs with Hono + @hono/zod-openapi. Covers schema-first route declaration with createRoute, feature-grouped composable routers, a class-based service layer behind interfaces, the strategy pattern for multi-event webhooks, typed request validation, and auto-generated OpenAPI docs.
---
# Hono REST API — Composable Architecture
A scalable structure for Hono APIs using `@hono/zod-openapi`: routes are schema-first, business
logic lives in a class-based service layer, and the OpenAPI document is generated from the route
definitions — never written by hand.
## Core principle
Routes are **schema-first**: declare the request/response shape with Zod via `createRoute`, then write
the handler. `c.req.valid(...)` returns typed, validated input, and the OpenAPI spec is derived from
the same definitions.
## Folder structure
Group by **feature**, co-locate each route/service with its own schema, and keep one source root:
```
src/
├── app.ts # OpenAPIHono root — mounts routers + docs + error handler
├── index.ts # runtime entry (server / serverless adapter)
├── routes/
│ └── health/
│ ├── route.ts # createRoute + handler
│ └── schema.ts # Zod schemas for this route only
└── services/
└── orders/
├── service.interface.ts # the contract
├── service.ts # class implements the interface
└── schema.ts # Zod schemas for this service only
```
Rules:
- **Group by feature** — each route and service gets its own directory; files are `route.ts` /
`schema.ts` / `service.ts` (no repeated prefix — the directory name gives context).
- **Schema co-location** — a `schema.ts` lives in the same directory as its `route.ts` / `service.ts`.
Only cross-cutting shared schemas move to a shared location.
- **One source root** — keep a single `src/`; never nest another `src/` inside a feature directory.
## Route + schema pattern
```typescript
// routes/health/schema.ts
import { z } from 'zod';
export const HealthResponseSchema = z.object({
status: z.literal('ok'),
uptime: z.number(),
});
export type HealthResponse = z.infer<typeof HealthResponseSchema>;
```
```typescript
// routes/health/route.ts
import { OpenAPIHono, createRoute } from '@hono/zod-openapi';
import { HealthResponseSchema } from './schema';
const healthRoute = createRoute({
method: 'get',
path: '/health', // relative — the prefix is added by app.route()
responses: {
200: {
content: { 'application/json': { schema: HealthResponseSchema } },
description: 'Liveness probe',
},
},
});
export const healthRouter = new OpenAPIHono();
healthRouter.openapi(healthRoute, (c) => {
return c.json({ status: 'ok' as const, uptime: process.uptime() });
});
```
## Service layer — class-based, noun-based, interface-first
Business logic lives in **service classes** grouped by the **noun they talk to** (orders, payments,
notifications) — never by verb. Each noun directory holds `service.interface.ts` (the contract) and
`service.ts` (the `class … implements I<Noun>Service`). Consumers depend on the interface type.
```typescript
// services/orders/service.interface.ts
import type { Order, CreateOrder } from './schema';
export interface IOrderService {
get(id: string): Promise<Order | null>;
create(input: CreateOrder): Promise<Order>;
}
```
```typescript
// services/orders/service.ts
import type { IOrderService } from './service.interface';
export class OrderService implements IOrderService {
async get(id: string): Promise<Order | null> { /* ... */ return null; }
async create(input: CreateOrder): Promise<Order> { /* ... */ }
}
// Export a singleton instance — import this (typed as the interface), not the class.
export const orderService = new OrderService();
```
- **Interface first, class implements it** — compile-time enforcement and a clear extension contract.
- **All I/O lives in the service** (DB, queues, outbound HTTP, signature checks) — keep it out of
routes. Routes stay thin: validate → call a service → serialise the result.
- **Services stay pure of Hono** — no `Context`, no `c`. They take plain inputs and return plain data,
so they are trivially unit-testable against the interface with a stub.
- Constructor injection of dependencies is fine where it aids testing.
## Multi-event webhooks — strategy pattern
When **one endpoint** receives many event types from a provider, accept all valid events and dispatch
by type. Never expose one endpoint per event, and never grow an `if`/`switch` over event types in the
handler.
**Rule:** one endpoint → one dispatcher → one strategy class per event type, each implementing a
shared interface. A new event is a new strategy class registered in one place; the route and
dispatcher stay untouched.
```typescript
// routes/webhook/strategies/strategy.interface.ts
export interface IEventStrategy<TPayload, TResult> {
readonly event: string; // matched against the payload's event-type field
handle(payload: TPayload): Promise<TResult>;
}
```
```typescript
// routes/webhook/strategies/dispatcher.ts
import type { IEventStrategy } from './strategy.interface';
import { NoopStrategy } from './noop.strategy';
export class WebhookDispatcher<TPayload, TResult> {
private readonly registry = new Map<string, IEventStrategy<TPayload, TResult>>();
constructor(
strategies: IEventStrategy<TPayload, TResult>[],
private readonly fallback: IEventStrategy<TPayload, TResult> = new NoopStrategy(),
private readonly keyOf: (p: TPayload) => string,
) {
for (const s of strategies) this.registry.set(s.event, s);
}
dispatch(payload: TPayload): Promise<TResult> {
return (this.registry.get(this.keyOf(payload)) ?? this.fallback).handle(payload);
}
}
```
- **A typed `NoopStrategy` is the default** — a valid event with no registered strategy resolves to it
and returns **200** (acknowledge, no side effect). Never return 4xx for an event you simply do not
act on yet — that triggers provider retry storms.
- Strategy classes hold **flow logic only**; all I/O is delegated to the service classes above.
- The route does only: verify the signature on the raw body → parse → `dispatcher.dispatch(payload)`
→ serialise the result as a 200 body.
## Root app — mount routers, docs, error handler
```typescript
// app.ts
import { OpenAPIHono } from '@hono/zod-openapi';
import { HTTPException } from 'hono/http-exception';
import { healthRouter } from './routes/health/route';
export const app = new OpenAPIHono();
app.route('/', healthRouter); // mount feature routers with their prefix
app.doc('/openapi.json', {
openapi: '3.0.0',
info: { title: 'API', version: '1.0.0' },
});
app.onError((err, c) => {
if (err instanceof HTTPException) {
return c.json({ success: false, message: err.message }, err.status);
}
return c.json({ success: false, message: 'Internal Server Error' }, 500);
});
```
Hono is runtime-agnostic. Pick the matching entry adapter, e.g.:
```typescript
// index.ts — Node server
import { serve } from '@hono/node-server';
import { app } from './app';
serve(app);
// index.ts — AWS Lambda
import { handle } from 'hono/aws-lambda';
import { app } from './app';
export const handler = handle(app);
```
## Typed request validation
`c.req.valid(...)` returns typed, Zod-validated input — never parse manually:
```typescript
const route = createRoute({
method: 'post',
path: '/orders',
request: { body: { content: { 'application/json': { schema: CreateOrderSchema } } } },
responses: {
201: { content: { 'application/json': { schema: OrderSchema } }, description: 'Created' },
},
});
router.openapi(route, async (c) => {
const body = c.req.valid('json'); // typed as CreateOrder
return c.json(await orderService.create(body), 201);
});
```
## Quick reference
| Task | Pattern |
|------|---------|
| New domain | `routes/<name>/route.ts` + `routes/<name>/schema.ts`; mount in `app.ts` |
| New route | `createRoute({})` → `router.openapi(route, handler)` |
| Route / service schema | `schema.ts` inside the same feature directory |
| New service | `services/<noun>/service.interface.ts` + `class … implements I<Noun>Service`; export a singleton; group by noun, not verb |
| Multi-event webhook | One endpoint → `WebhookDispatcher` → one strategy class per event; `NoopStrategy` default |
| Path / query params | declare in `request` on `createRoute`; read via `c.req.valid('param' | 'query')` |
| Error | `throw new HTTPException(status, { message })` |
| OpenAPI | auto-generated from routes — never hand-write YAML/JSON |
## Additional resources
Portable bash scaffolders (coreutils only, never overwrite existing files) live in `scripts/`. Run
them from a consumer project's root:
- `scripts/new-route.sh <name>` — scaffolds `src/routes/<name>/route.ts` + `schema.ts` following the
route + schema pattern (`createRoute` + `OpenAPIHono` + a co-located Zod schema). Use when adding a
new feature route; then mount the exported router in `app.ts`.
- `scripts/new-service.sh <noun>` — scaffolds `src/services/<noun>/service.interface.ts` + `service.ts`
+ `schema.ts` following the class-based noun-service pattern (`class <Noun>Service implements
I<Noun>Service`, exported as an interface-typed singleton). Use when adding business logic for a new
noun, kept out of the routes.
## Common mistakes
| Mistake | Fix |
|---------|-----|
| Schema defined inside the route file | Separate `schema.ts` — enables reuse and testing |
| `c.req.json()` instead of `c.req.valid('json')` | `valid()` returns typed + validated data |
| Routes registered directly on the root `app` | Feature routers + `app.route()` — keeps `app.ts` thin |
| Hand-writing OpenAPI | `createRoute` + `app.doc()` generates it |
| Hono imports inside services | Services stay pure — no `Context`, no `c` |
| Service as loose exported functions | `class … implements I<Noun>Service` — interface first |
| Verb-named service directories | Name by noun (`services/orders/`, not `services/create-order/`) |
| One endpoint per webhook event type | Single endpoint + `WebhookDispatcher` keyed on event type |
| 4xx for an unhandled-but-valid event | `NoopStrategy` → 200; avoids provider retry storms |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!