OpenAPI/Swagger is always available at /docs for any Express backend
generated by dot. You pick how the spec is built:
- Decorator runtime (default) — TypeScript class decorators declare routes, validate inputs with Zod, and the OpenAPI v3 document is built from the metadata Express sees at runtime.
- Classic JSDoc — handlers stay plain functions; the spec is assembled at
boot from
@openapiJSDoc blocks insrc/**/*.tsviaswagger-jsdoc.
This page focuses on the decorator path. The classic path needs no
configuration: every handler shipped by dot already carries a @openapi
JSDoc block, and you keep using the same syntax for new endpoints.
The decorator system is built around a small RouterAdapter interface so it
can be extended to other frameworks (Fastify, etc.) without rewriting the
decorators.
dot init
# Answer the survey:
# Library / Framework → Express
# Choose your architecture → Clean Architecture | MVC
# Use decorator-based validation… → Yes (or No for the classic JSDoc path)
# Validation library → Zod (only when Yes)
# ...
pnpm install
pnpm dev
# Open http://localhost:3000/docs — the Swagger UI is served regardless of
# which path you chose above.The scaffold ships with one decorated example controller you can copy or
delete. Its routes appear under /api/example and in the OpenAPI document at
/docs/openapi.json. The DecoratorRouter is mounted at the root of the app
(not under /api), so each controller declares its own full prefix
('/api/example', '/auth', …) — there is no implicit base path to remember.
import type { Request, Response } from 'express';
import {
ApiResponse,
Body,
Controller,
Get,
Params,
Post,
} from '../shared/decorators';
import { z } from '../shared/openapi/registry';
const userParams = z.object({ id: z.string().uuid() });
const createUser = z.object({ email: z.string().email(), name: z.string().min(1) });
const userResponse = z.object({ id: z.string().uuid(), email: z.string(), name: z.string() });
@Controller({ tag: 'users', prefix: '/users', description: 'User management' })
export class UsersController {
@Get(':id')
@Params(userParams)
@ApiResponse(200, 'User fetched', userResponse)
@ApiResponse(404, 'User not found')
get(req: Request, res: Response): void {
res.json({ id: req.params.id, email: 'demo@example.com', name: 'Demo' });
}
@Post('/')
@Body(createUser)
@ApiResponse(201, 'User created', userResponse)
create(req: Request, res: Response): void {
res.status(201).json({ id: '…', ...req.body });
}
}Behind the scenes:
@Controllerrecords the OpenAPI tag and the route prefix.@Get/@Post/@Put/@Patch/@Deletedeclare an HTTP method and path.@Body/@Query/@Paramsinstall validation middleware that runs before the handler. On success the parsed value replacesreq.body|query|params, so the handler sees coerced, typed input. On failure the request is rejected with a400and a structured payload listing every Zod issue.@ApiResponse(status, description, schema?)adds a response definition to the OpenAPI spec. It can be stacked to document multiple status codes.@Auth()marks the route as protected. The route gets aBearerAuthsecurity entry in OpenAPI, and (when anauthMiddlewareis registered on the adapter) the middleware runs before the handler.@RequiredHeaders([...])installs a pre-handler check that returns400if any of the named headers are missing.
import 'reflect-metadata';
import express from 'express';
import {
DecoratorRouter,
ExpressRouterAdapter,
} from './shared/decorators';
import { buildOpenApiSpec, createRegistry, mountSwagger } from './shared/openapi';
import { UsersController } from './controllers/users.controller';
const app = express();
app.use(express.json());
const router = new DecoratorRouter(new ExpressRouterAdapter())
.registerController(new UsersController());
app.use(router.build());
const spec = buildOpenApiSpec({
info: { title: 'My API', version: '1.0.0' },
servers: [{ url: '/' }],
routes: router.routes(),
registry: createRegistry(),
});
mountSwagger(app, spec);DecoratorRouter walks each controller's metadata, pushes a RouteRegistration
into the adapter, and remembers what it registered so the OpenAPI generator
sees exactly the routes that are wired.
Pass an Express middleware to the adapter and the @Auth() decorator will
gate the route with it:
import { authMiddleware } from './shared/middlewares/auth.middleware';
const router = new DecoratorRouter(
new ExpressRouterAdapter({ authMiddleware }),
).registerController(new UsersController());When you scaffold with JWT auth + decorators, dot wires this for you:
the auth_jwt_vanilla generator injects authMiddleware into
ExpressRouterAdapter, and the auth controller (MVC or Clean Architecture)
is generated as a decorated AuthController registered on the same router.
@Auth()-protected routes are then gated automatically.
BetterAuth keeps its own catch-all route (toNodeHandler(auth)) and is not
exposed through the decorator system — it manages its own routing.
The OpenAPI document is updated regardless: protected routes always show the
BearerAuth requirement, even if you have not registered a middleware yet.
The Express adapter wraps every decorated handler so a thrown error or a
rejected promise is forwarded to Express' error pipeline via next(err) —
you can rely on your existing error middleware without sprinkling try/catch
through every controller method.
-
The spec is served raw at
GET /docs/openapi.jsonand rendered atGET /docsviaswagger-ui-express. -
Every Zod schema you reference in
@Body/@Query/@Params/@ApiResponseis automatically converted using@asteasolutions/zod-to-openapi. -
For named, reusable component schemas, register them on the registry and reference them inline:
import { getSharedRegistry, z } from '../openapi/registry'; export const userResponse = z.object({ id: z.string(), email: z.string() }) .openapi('User'); getSharedRegistry().register('User', userResponse);
-
Tests should call
createRegistry()to obtain a fresh, isolated registry rather than share global state.
| Architecture | Controller location | Schema location |
|---|---|---|
| Clean | src/modules/<name>/application/controllers/ |
src/modules/<name>/application/validators/ |
| MVC | src/controllers/ |
src/shared/validators/ |
| Hexagonal | src/adapters/primary/http/controllers/ |
src/adapters/primary/http/schemas/ |
The decorator and OpenAPI runtimes themselves live in src/shared/decorators/
and src/shared/openapi/ regardless of architecture.
The decorator-router reads metadata and produces a RouteRegistration:
export interface RouteRegistration {
method: HttpMethod;
path: string;
handler: (...args: unknown[]) => unknown;
validation: { body?: ZodSchema; params?: ZodSchema; query?: ZodSchema };
requiredHeaders: string[];
isProtected: boolean;
}To plug in another framework, implement RouterAdapter<TNative>:
import type { FastifyInstance } from 'fastify';
import type { RouteRegistration, RouterAdapter } from '../shared/decorators/router-adapter';
export class FastifyRouterAdapter implements RouterAdapter<FastifyInstance> {
constructor(private readonly fastify: FastifyInstance) {}
register(reg: RouteRegistration): void {
// translate validation/auth/headers into Fastify hooks and routes
}
build(): FastifyInstance {
return this.fastify;
}
}The decorator surface — @Controller, @Get, @Body, … — stays the same.
Only the adapter changes.
Decorator path:
src/shared/decorators/__tests__/decorators.unit.test.tscovers route registration, body/params/query validation, auth, and required headers.src/shared/openapi/__tests__/spec.unit.test.tsverifies the generated OpenAPI document — paths, tags, security, and the served JSON endpoint.src/__tests__/decorators-<arch>.e2e.test.tsboots the realappvia supertest and exercises the example controller end to end.
Classic JSDoc path:
src/shared/swagger/__tests__/swagger.unit.test.tsboots the app, hits/docs/openapi.json, and asserts that/health(and any other@openapi-annotated endpoint) is present in the spec.
Run them with pnpm test.
When you opt out of decorators, every endpoint that should appear in the spec
needs a @openapi JSDoc block above its handler. swagger-jsdoc scans
src/**/*.{ts,js} at boot and folds every block into a single document.
/**
* @openapi
* /users/{id}:
* get:
* tags: [Users]
* summary: Fetch a user by id
* parameters:
* - name: id
* in: path
* required: true
* schema: { type: string, format: uuid }
* responses:
* 200:
* description: The user
* content:
* application/json:
* schema:
* type: object
* properties:
* id: { type: string, format: uuid }
* email: { type: string, format: email }
* 404: { description: Not found }
*/
export async function getUser(req: Request, res: Response): Promise<void> {
// ...
}The dot-generated controllers (auth.controller.ts, app.ts's /health)
already follow this convention — copy one of them as a template.