Instructions for AI coding assistants (Cursor, Claude Code, GitHub Copilot, etc.)
A production-ready Fastify 5 boilerplate using Clean Architecture, CQRS, DDD, and functional programming. TypeScript strict mode, ESM-only, Node >= 24 (native TS execution, no build step).
| What | Where |
|---|---|
| Package manager | pnpm (never npm or yarn) |
| Linter + formatter | Biome (never ESLint or Prettier) |
| Validation after changes | pnpm check (runs biome check && tsc --noEmit) |
| Auto-fix formatting | pnpm format |
| Unit tests | pnpm test:unit (node:test) |
| E2E tests | pnpm test:e2e (Cucumber + Gherkin) |
| Architecture validation | pnpm deps:validate (dependency-cruiser) |
| DB migrations | pnpm db:migrate (DBMate) |
Always run pnpm check after making changes. If formatting fails, run pnpm format first, then pnpm check.
The dependency flow is strictly inward: Route → Handler → Domain → Repository.
src/
├── instrumentation.ts ← OpenTelemetry setup (loaded via --import before the app)
├── modules/ ← Feature code (vertical slices)
│ └── <feature>/
│ ├── commands/ ← State-changing operations
│ ├── queries/ ← Data-retrieval operations (idempotent)
│ ├── database/ ← Repository port (interface) + adapter (implementation)
│ ├── domain/ ← Business logic, types, errors
│ └── dtos/ ← Shared response schemas
├── server/ ← Fastify setup, plugins, DI wiring
└── shared/ ← Cross-cutting: CQRS, DB, exceptions, utils
Never import from src/shared/db/ or database/ inside handler files.
Handlers interact with data exclusively through repository ports (interfaces).
SQL belongs in repository files only.
Modules should avoid importing directly from other modules. Cross-module communication uses the CQRS buses:
- Commands/queries for request-response
- Events for fire-and-forget notifications
This project uses three buses: CommandBus, QueryBus, and EventBus.
Full guide with examples: doc/CQRS.md (event error isolation and ordered per-key processing are documented there).
Actions are created via actionCreatorFactory with a module prefix.
The Result type parameter is a phantom type — it exists only at the type level
to enable type-safe execute() calls:
// In the module's index.ts
export const userActionCreator = actionCreatorFactory('user');
// In the handler file — embed both Payload and Result types
export type CreateUserResult = string;
export const createUserCommand = userActionCreator<CreateUserRequestDto, CreateUserResult>('create');Every handler follows this structure:
export default function makeHandler({ commandBus, repository, ...deps }: Dependencies) {
return {
async handler({ payload }: HandlerAction<typeof myCommand>): Promise<MyResult> {
// business logic here
},
init() {
commandBus.register(myCommand.type, this.handler);
},
};
}Key rules:
- Handler parameters use
HandlerAction<typeof creator>(strips the phantom Result type for register() compatibility) - The result type (e.g.
CreateUserResult) is the unwrapped value, notPromise<X>— thePromisewrapper comes fromexecute() - Events use
userActionCreator<PayloadType>('event-name')without a Result generic (events return void) - Handlers are auto-loaded and wired via Awilix DI — the
init()method is called automatically
// Return type is inferred from the action's phantom Result — no manual generic needed
const id = await fastify.commandBus.execute(createUserCommand(req.body));Do not pass a manual generic to execute(). The type is inferred from the action creator's Result parameter.
Middlewares use an onion-model pipeline (composed via reduceRight). They must never mutate the action — always spread into a new object:
function myMiddleware(action: Action<unknown>, handler: CommandHandler): Promise<unknown> {
const enrichedAction = { ...action, meta: { ...action.meta, foo: 'bar' } } as Action<unknown>;
return handler(enrichedAction);
}There are separate types for command/query vs event middlewares:
CommandMiddleware— returnsPromise<unknown>(used by CommandBus and QueryBus)EventMiddleware— returnsvoid(used by EventBus)
| Bus | Purpose | Register method | Dispatch method | Handler return |
|---|---|---|---|---|
CommandBus |
State-changing mutations | register(type, handler) |
execute(action) → Promise<R> (inferred) |
Promise<unknown> |
QueryBus |
Idempotent reads | register(type, handler) |
execute(action) → Promise<R> (inferred) |
Promise<unknown> |
EventBus |
Fire-and-forget notifications | on(type, handler) |
emit(action) → void |
void |
Commands and queries share a createRequestBus factory in src/shared/cqrs/request-bus.ts.
The event bus is a separate implementation in src/shared/cqrs/event-bus.ts with a logger dependency
for debug-level warnings when events have no subscribers. Note the different API: on/emit for events
vs register/execute for commands and queries.
DI uses Awilix with @fastify/awilix.
- Global dependencies (
db,logger,commandBus,queryBus,eventBus,repositoryBase) are registered insrc/modules/index.ts - Module-specific dependencies are declared via
declare global { export interface Dependencies { ... } }in the module'sindex.ts - Repositories, mappers, domain services are auto-loaded as singletons from
src/modules/**/*.{repository,mapper,service,domain}.ts - Handlers and event-handlers are auto-loaded with
asyncInit: 'init'fromsrc/modules/**/*.{handler,event-handler}.ts - All handlers receive dependencies as a single destructured object:
function makeX({ dep1, dep2 }: Dependencies) - DI naming convention: kebab-case filenames are converted to camelCase identifiers (e.g.
create-user.handler.ts→createUserHandlerin the container)
- Client:
postgres(postgres.js) — uses tagged template literals for parameterized queries - Connection: lazy singleton via
getDb()insrc/shared/db/postgres.ts; close withcloseDbConnection() - Migrations/seeds: DBMate (SQL files in
db/migrations/anddb/seeds/) - Transaction support:
withTransaction(async (tx) => { ... }) - Repository base:
SqlRepositoryBase(insrc/shared/db/sql-repository.base.ts) provides generic CRUD (insert, findOneById, findAll, findAllPaginated, update, delete) - Repository ports extend
RepositoryPort<Entity>(insrc/shared/db/repository.port.ts) - Mapper interface:
Mapper<DomainEntity, DbRecord, Response>withtoPersistence,toDomain,toResponse(insrc/shared/ddd/mapper.interface.ts)
SQL parameterization rules:
- Always use tagged templates:
db`SELECT * FROM ${db(tableName)} WHERE id = ${id}` - Table names use
db(tableName)(identifier interpolation) - Values use
${value}(parameterized automatically) - Condition composition uses
joinConditions()fromsrc/shared/db/postgres.ts
- Biome enforces: single quotes, 2-space indent, trailing commas, semicolons, LF line endings
- Max line width: 100 characters
- File naming:
kebab-caseonly (enforced by Biome) - No enums — use
constobjects with derived types (e.g.UserRoles) - No classes for business logic — prefer factory functions and composition
- No
any— Biome'snoExplicitAnyis an error (relaxed only in test files) - No
console— use the injectedlogger(Pino)
strict: truewithnoImplicitAny: true- Path aliases:
#src/*maps to./src/*,#tests/*maps to./tests/*(defined as Node subpath imports inpackage.json, not intsconfig.json) - Always include
.tsextension in imports (ESM requirement) - Prefer
typeimports:import type { Foo } from './bar.ts'
- All REST routes are prefixed with
/api(configured insrc/server/index.ts) - Schemas use TypeBox (
Type.Object,Type.String, etc.) - Routes handle HTTP concerns only — no business logic in routes
- GraphQL resolvers co-locate with their REST route counterparts
- Unit/integration tests:
*.spec.tsfiles next to source, usingnode:testwithdescribe/it/assert - E2E tests: Cucumber features in
tests/, step definitions intests/<feature>/ - Load tests: k6 scripts in
tests/<feature>/ - Test server: use
buildApp()fromtests/support/server.ts— creates a Fastify instance without listening
- Domain errors extend
ExceptionBase(insrc/shared/exceptions/) - Built-in exceptions:
NotFoundException,ConflictException,DatabaseErrorException,ArgumentInvalidException,InternalServerErrorException,ProviderErrorException - Always include a descriptive message:
throw new NotFoundException('User with id X not found')
- Create
src/modules/<name>/with the vertical slice structure - Create
src/modules/<name>/index.tswithactionCreatorFactory('<name>')anddeclare globalDependencies - Create domain types in
domain/<name>.types.ts - Create repository port in
database/<name>.repository.port.ts(interface extendingRepositoryPort<Entity>) - Create repository adapter in
database/<name>.repository.ts(implements the port) - Create mapper in
<name>.mapper.ts(implementsMapper<Entity, DbModel, ResponseDto>) - Create command/query handlers with action creators embedding
<Payload, Result>types - Create routes and/or resolvers — call
bus.execute(action)without manual generics - Create a DB migration:
pnpm db:create-migration <name> - Run
pnpm checkto validate
- Define the middleware function in
src/shared/cqrs/middlewares.ts(or a new file) - Match the correct signature:
CommandMiddlewarefor command/query buses,EventMiddlewarefor event bus - Never mutate the action — spread into a new object
- Register in
src/shared/cqrs/index.tsviabusInstance.addMiddleware(myMiddleware) - Middleware order matters: first added = outermost wrapper
- Importing DB/infrastructure code in handlers (violates architecture boundaries)
- Using
execute<ManualType>(action)instead of letting the type be inferred from the action creator - Mutating
action.metain middleware instead of spreading - Using
ReturnType<typeof creator>for handler params (incompatible with register — useHandlerAction<typeof creator>) - Forgetting
.tsextensions in imports - Using
npmoryarninstead ofpnpm - Using
console.loginstead of the injected Pinologger - Adding
enumtypes (use const objects + derived types) - Putting business logic in route files
- Directly importing from one module into another