Profile
Back to NewsBack
GitHub Trending 14 min
Reader Mode
marcoturi/fastify-boilerplate: Fastify 5 application boilerplate based on clean architecture, domain-driven design, CQRS, functional programming, vertical slice architecture for building production-grade applications 🚀

marcoturi/fastify-boilerplate: Fastify 5 application boilerplate based on clean architecture, domain-driven design, CQRS, functional programming, vertical slice architecture for building production-grade applications 🚀

19 hours ago

!Fastify Boilerplate Logo

Biome</a> Commitizen friendly</a> MIT License</a> !GitHub Actions Workflow Status !GitHub Actions Workflow Status

A production-ready Fastify 5 boilerplate built on Clean Architecture, CQRS, DDD, and functional programming. Designed as a starting point for real-world applications, the architecture is framework-agnostic at its core — the patterns and boundaries translate to any language or framework.

Table of Contents

- Principles - Modules - Module Components

Features

| Category | Details | |---|---| | Runtime | Native TypeScript via Node.js type stripping — no build step, no transpiler | | Framework | Fastify 5 with Awilix DI and Pino logging | | API | REST (TypeBox schemas, Swagger UI) + GraphQL (Mercurius, GraphiQL in dev) | | Database | Postgres.js client + DBMate migrations & seeds | | Security | @fastify/helmet, @fastify/under-pressure for back-pressure | | Telemetry | Vendor-agnostic OpenTelemetry with auto-instrumentation (off in .env.example and the Docker image) | | Linting | Biome — single tool for linting, formatting, and import sorting | | Architecture | dependency-cruiser validates layer boundaries in the pre-commit hook and in CI | | Release | Husky + Commitlint + Semantic Release | | Client types | REST (OpenAPI) and GraphQL types auto-generated and published to npm on every release | | Testing | E2E with Cucumber (Gherkin), unit/integration with node:test, load tests with k6 | | Docker | Production-ready multi-stage Dockerfile (Alpine, non-root, health check) + Docker Compose | | AI-Ready | AGENTS.md — architecture rules and coding conventions for AI assistants |

Prerequisites

| Tool | Notes | |---|---| | Node.js | The version in .nvmrc — run fnm use or nvm use. It runs TypeScript natively and has the synchronous module hooks OpenTelemetry needs | | pnpm | The version pinned in packageManager (package.json). Node no longer bundles Corepack: npm install -g corepack && corepack enable activates it | | Docker | Used to run PostgreSQL via Docker Compose. Alternatively, use a local Postgres install |

Getting Started

# 1. Scaffold from the template
npx degit marcoturi/fastify-boilerplate my-app
cd my-app

2. Install dependencies

pnpm install

3. Create your .env file

pnpm create:env # copies .env.example → .env

4. Start PostgreSQL (pick one)

docker compose up postgres -d # via Docker Compose

— or use a local Postgres and adjust .env values —

5. Run database migrations

pnpm db:migrate

6. Start the dev server (with watch mode and pretty logs)

pnpm start

The server starts at http://localhost:3000 by default. See API Endpoints for what's available.

Available Scripts

Development

| Script | Description | |---|---| | pnpm start | Start dev server with watch mode and pretty-printed logs | | pnpm start:prod | Start production server (no watch, no pretty-print) | | pnpm create:env | Copy .env.example to .env (fails if .env already exists) |

Code Quality

| Script | Description | |---|---| | pnpm check | Run lint + format check + type check (use this before committing) | | pnpm check:fix | Same as check but auto-fixes lint and format issues | | pnpm format | Auto-format all files with Biome | | pnpm lint | Run Biome linter with auto-fix | | pnpm type:check | TypeScript type checking (tsc --noEmit) | | pnpm deps:validate | Validate architecture layer boundaries with dependency-cruiser | | pnpm deps:graph | Generate a dependency graph SVG in doc/ (requires Graphviz dot) |

Testing

| Script | Description | |---|---| | pnpm test | Run unit tests (alias for test:unit) | | pnpm test:unit | Run unit and integration tests with node:test | | pnpm test:coverage | Run unit tests with node:test coverage | | pnpm test:e2e | Run E2E tests with Cucumber (requires running Postgres) |

Database

| Script | Description | |---|---| | pnpm db:migrate | Apply pending migrations | | pnpm db:create-migration | Create a new migration file | | pnpm db:seed | Run database seeds | | pnpm db:create-seed | Create a new seed file | | pnpm db:seed:users | Create users through the REST API with k6 (requires k6 and a server on port 3000) | | pnpm db | Show the DBMate help. Commands without a script take the flags of db:migrate, e.g. pnpm exec dbmate -e DBMATE_DATABASE_URL --no-dump-schema rollback |

Other

| Script | Description | |---|---| | pnpm generate:types | Generate REST and GraphQL client types |

Running with Docker

Full stack (app + Postgres)

pnpm create:env       # create .env from .env.example (if not done already)
docker compose up     # starts Postgres, applies the migrations (one-shot migrate service), then the app

Standalone image

docker build -t fastify-boilerplate .
docker run -p 3000:3000 --env-file .env \
  -e NODE_ENV=production \
  -e POSTGRES_URL=host.docker.internal:5432 \
  --add-host=host.docker.internal:host-gateway \
  fastify-boilerplate

The -e flags override two values of a .env made for local development: NODE_ENV=development would replace the image's production, and 127.0.0.1 inside the container is the container itself, so Postgres is reached through the host. The image already binds to 0.0.0.0.

Dockerfile highlights

  • Multi-stage build — dependencies installed in an isolated stage for optimal layer caching
  • Node Alpine — small footprint, native TypeScript execution (no build step)
  • Non-root user — runs as an unprivileged fastify user
  • dumb-init — proper PID 1 signal forwarding for graceful shutdown
  • HEALTHCHECK — built-in Docker health check against /health, on the port in PORT

API Endpoints

| Endpoint | Description | |---|---| | GET /health | Liveness check: the process is up (@fastify/under-pressure) | | GET /ready | Readiness check: the database answers (503 otherwise) | | /api/... | All REST routes are prefixed with /api (e.g. /api/v1/users) | | GET /api-docs | Swagger UI (interactive API documentation) | | GET /api-docs/json | OpenAPI 3.1.0 JSON spec | | POST /graphql | GraphQL endpoint (Mercurius) | | GET /graphiql | GraphiQL IDE (development only) |

Architecture

!Architecture Diagram Diagram adapted from Domain-Driven Hexagon

Principles

Project-level:

  • Adaptable complexity — the structure scales up or down by adding or removing layers to match the application's actual needs.
  • Future-proofing — framework code and business logic are separated. Dependencies are well-established and minimal.
  • Functional programming first — composition and factory functions over classes and inheritance.
  • Microservices-ready — vertical slices, path aliases, and CQRS make it straightforward to extract a module into its own service later.
Code-level:
  • Framework-agnostic core — business logic has no Fastify dependency. Fastify concerns stay in routes.
  • Protocol-agnostic handlers — command/query handlers serve REST, GraphQL, gRPC, or CLI equally.
  • Database-agnostic domain — SQL stays in repository files. Handlers interact with data through repository ports (interfaces).
  • Inward dependency flow — outer layers depend on inner layers, never the reverse: Route/Resolver → Handler → (Service) → Domain + Repository. The domain is pure and never calls repositories.
Based on:

Modules

Each module maps to a domain concept and lives in its own folder under src/modules/. Modules follow the vertical slice architecture — everything a feature needs is co-located.

Key rules:

  • No direct imports between modules. Cross-module communication uses the CQRS buses (commands/queries for request-response, events for fire-and-forget). The one exception is another module's domain events (domain/*.events.ts), the contract its subscribers depend on; pnpm deps:validate enforces both.
  • Extractable — any module can be pulled into a separate microservice. The CQRS handler boundary becomes the network boundary.
  • If two modules are too "chatty", they probably belong together — merge them.
CQRS: see doc/CQRS.md for how the command, query, and event buses work — actions, handlers, middlewares, ordered per-key events, and worked examples.

Module Components

Each layer has a single responsibility (details in clean-architecture.md):

Route / Resolver — handles the transport (REST route, GraphQL resolver; gRPC or CLI would be one more adapter). Validates input, dispatches one action on a bus, shapes the response. No business logic.

Example: find-users.route.ts, find-users.resolver.ts

Command/Query Handler — runs one use case. Receives a command or query, calls a few domain, service or repository functions, returns a result or emits events. One handler per use case (e.g. CreateUser, FindUsers).

Benefits of the CQRS bus pattern:

  • Middlewares — cross-cutting concerns (tracing, timing; auth, caching, rate limiting) plug in between route and handler. A middleware wraps every action of its bus: one meant for some actions only checks action.type (e.g. the user/ prefix). See middlewares.ts.
  • Decoupling — modules communicate through the bus instead of direct imports, making future extraction to microservices trivial.
Example: find-users.handler.ts

Service — orchestrates a use case that takes several steps: reads from repositories, asks the domain for decisions, writes, emits events. It decides nothing itself. Add one when a handler grows into orchestration or two handlers share it.

Domain — pure business logic: decisions, computations, invariants, entity creation. It never calls repositories, emits events or does any I/O.

Example: user.domain.ts

Repository — data access. Converts between domain models and database rows. All SQL lives here. Implements a port (interface) defined alongside it.

Example: user.repository.ts

Guideline: use as many or as few layers as needed. Not every feature needs a service or domain logic — simple CRUD operations can go straight from handler to repository.

Folder Structure

.
├── db/
│   ├── migrations/                → SQL migration files (DBMate)
│   └── seeds/                     → SQL seed files (DBMate)
├── tests/
│   ├── <module>/
│   │   ├── <slice>/
│   │   │   ├── <scenario>.feature → Gherkin E2E scenarios (REST and GraphQL)
│   │   │   └── <slice>.k6.ts      → k6 load test scripts
│   │   └── <module>*.steps.ts     → Step definitions shared by the module's scenarios
│   ├── shared/                    → Cross-module scenarios and common steps
│   └── support/                   → Test server, hooks, custom world
├── client/                        → Generated REST + GraphQL client types (npm package)
├── scripts/                       → Type generation scripts
└── src/
    ├── index.ts                   → Entry point: server start, graceful shutdown
    ├── instrumentation.ts         → OpenTelemetry setup (loaded via --import)
    ├── declarations.ts            → Global types and module augmentations (DI cradle, request context)
    ├── config/                    → Environment validation (env-schema + TypeBox)
    ├── modules/
    │   ├── index.ts                                 → Global DI dependencies
    │   └── <module>/
    │       ├── commands/
    │       │   └── <command>/
    │       │       ├── <command>.handler.ts         → Command handler + its action creator
    │       │       ├── <command>.event-handler.ts   → Event subscriber (in place of a handler)
    │       │       ├── <command>.route.ts           → REST route
    │       │       ├── <command>.resolver.ts        → GraphQL resolver
    │       │       ├── <command>.graphql-schema.ts  → GraphQL type definitions
    │       │       └── <command>.schema.ts          → TypeBox request schemas
    │       ├── queries/
    │       │   └── <query>/                         → Same files as a command
    │       ├── database/
    │       │   ├── <module>.repository.port.ts      → Repository interface (port)
    │       │   └── <module>.repository.ts           → Repository implementation (adapter)
    │       ├── domain/
    │       │   ├── <module>.domain.ts               → Pure business logic
    │       │   ├── <module>.events.ts               → Domain events (the only cross-module import)
    │       │   ├── <module>.errors.ts               → Domain-specific errors
    │       │   └── <module>.types.ts                → Domain types
    │       ├── dtos/
    │       │   ├── <module>.graphql-schema.ts       → Shared GraphQL types
    │       │   └── <module>.response.dto.ts         → Shared response DTOs
    │       ├── <name>.service.ts                    → Optional orchestration layer
    │       ├── <module>.action-creator.ts           → Action creator with the module prefix
    │       ├── <module>.mapper.ts                   → Entity ↔ DB model ↔ DTO mapper
    │       └── index.ts                             → DI declarations
    ├── server/
    │   ├── index.ts               → Fastify setup: Helmet, CORS, GraphQL, plugin and route autoload
    │   ├── options.ts             → Fastify instance options (logger, request ids, Ajv), shared with the e2e tests
    │   ├── di/                    → Awilix container: global dependencies and module autoload
    │   ├── plugins/               → Autoloaded plugins (CQRS, error handler, readiness, request context, Swagger)
    │   ├── graphql-schema.ts      → Collects the modules' GraphQL schemas
    │   ├── graphql-error-formatter.ts → Maps exceptions to GraphQL errors
    │   └── request-id.ts          → Request id from the request-id header, or a new UUID
    └── shared/
        ├── api/                   → Shared TypeBox DTOs (ids, pagination, errors)
        ├── app/                   → Request context accessors
        ├── cqrs/                  → Command/Query/Event bus, middlewares
        ├── db/                    → Postgres connection, transactions, repository base, SQL helpers
        ├── ddd/                   → Mapper interface, query base
        ├── exceptions/            → Transport-neutral exceptions
        └── utils/                 → Ajv validator, timeouts

OpenTelemetry

The project ships with a vendor-agnostic OpenTelemetry setup in src/instrumentation.ts. It uses the standard OTLP protocol, so it works with any backend (Grafana, Datadog, Honeycomb, Jaeger, etc.) without code changes.

How it works:

  • HTTP + Fastify — src/instrumentation.ts registers the ESM hooks of import-in-the-middle with Node's synchronous module.registerHooks(), then starts the SDK with instrumentation-http and @fastify/otel. Every request gets a trace span with route, method, status code, and lifecycle hooks.
  • CQRS — a tracing middleware in src/shared/cqrs/otel-middleware.ts wraps every command, query, and event in a span. Spans include the action type, bus kind, and correlation ID.
  • Off unless enabled: .env.example and the Docker image set OTEL_SDK_DISABLED=true. When disabled, @opentelemetry/api returns noop implementations — zero overhead.
To enable, set the standard OTel environment variables in your .env:
OTEL_SDK_DISABLED=false
OTEL_SERVICE_NAME=fastify-boilerplate
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318   # your OTLP collector

All configuration uses standard OTel environment variables — no vendor lock-in.

Testing

Unit and integration tests

Run with node:test.

pnpm test:unit           # run tests
pnpm test:coverage       # run with node:test coverage

E2E tests

Written in Gherkin and executed with Cucumber.js. Where the scenarios, the steps and the test server live: testing rule.

# Requires a running Postgres with migrations applied
pnpm test:e2e

Each scenario empties the users table of the database in .env, the one pnpm start uses: don't run the E2E tests against data you want to keep.

Load tests

k6 scripts, e.g. create-user.k6.ts.

Client Types Package

The release pipeline automatically generates REST (OpenAPI) and GraphQL client types and publishes them as the @marcoturi/fastify-boilerplate npm package. The version is kept in sync with the backend via semantic-release.

Install

pnpm add -D @marcoturi/fastify-boilerplate

Usage

// REST types (generated by openapi-typescript)
import type { paths, components } from '@marcoturi/fastify-boilerplate/rest';

// GraphQL types (generated by graphql-codegen) import type { User, Query, Mutation } from '@marcoturi/fastify-boilerplate/graphql';

Generate locally

To regenerate the types locally (the database settings in .env are enough, no running database):

pnpm generate:types

This starts the server, fetches the OpenAPI and GraphQL schemas, writes the type files to client/, and stops the server.

CI/CD Pipeline

The project uses GitHub Actions with three workflows, and Renovate for dependency updates.

ci.yml — runs on every pull request to main: the checks, unit and E2E tests (against a Postgres service container), architecture boundaries and client type generation.

release.yml — runs on every push to main: the same steps, then semantic-release publishes the release (changelog, GitHub release, npm client package).

How the ruleset, the release token and the version rules work: infra rule.

codeql-analysis.yml — runs on pushes and PRs to main:

  • GitHub CodeQL security analysis for JavaScript/TypeScript
Renovate — opens a pull request for each dependency update; which ones it merges automatically, and how old a release must be, is in its config.

AI-Assisted Development

This project ships with an AGENTS.md file — a guide for AI coding assistants. It documents the architecture, coding conventions, and common pitfalls so that tools like Cursor, Claude Code, and GitHub Copilot can generate code that follows the project's established patterns.

AI assistants automatically pick up AGENTS.md and apply the conventions without manual prompting.

Area-specific rules (clean architecture, CQRS, database, API, modules, testing, infrastructure) live in .agents/rules/, each with a paths frontmatter listing the files it covers, so they enter the context only when relevant. AGENTS.md points every assistant to them; Claude Code loads them automatically through the .claude/rules symlink.

On Windows, clone with symlinks enabled (Developer Mode on, then git clone -c core.symlinks=true …), otherwise .claude/rules is checked out as a plain text file and Claude Code will not load the rules.

Why Biome over ESLint + Prettier?

This project uses Biome as a single tool for linting, formatting, and import sorting:

  • One tool, zero plugins — no @typescript-eslint/parser, eslint-config-prettier, eslint-plugin-import, or other ecosystem packages to keep in sync.
  • Fast — written in Rust, orders of magnitude faster than ESLint + Prettier. Noticeable in CI and pre-commit hooks.
  • Stable — no more breakage from mismatched plugin versions or peer dependency conflicts across the ESLint ecosystem.
  • Mature — covers the vast majority of rules that ESLint + typescript-eslint provide, with a growing community and clear roadmap.

Useful Resources

Contributing

Contributions are welcome! This project uses Conventional Commits enforced by Commitlint and Husky.

  1. Fork and clone the repo
  2. Create a branch: git checkout -b your-feature
  3. Make your changes
  4. Run pnpm check to validate lint, format, and types
  5. Run pnpm test (and pnpm test:e2e if your change touches API behavior)
  6. Commit using Conventional Commits format (e.g. feat: add user roles)
  7. Open a Pull Request

License

MIT

Chat with me