# marcoturi/fastify-boilerplate: a Fastify 5 TypeScript starter with enforced layer boundaries

> This boilerplate pairs Fastify 5 with Clean Architecture, CQRS and vertical slices, and uses dependency-cruiser to fail CI when a layer import breaks the rules. It fits teams that want the architecture enforced from the first commit.

**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.

- Repository: https://github.com/marcoturi/fastify-boilerplate
- Stars: 465 · Forks: 53
- Language: TypeScript
- License: MIT
- Published: 2026-08-08 · Updated: 2026-08-18 · Language: en
- Canonical page: https://hysenlabs.com/projects/marcoturi-fastify-boilerplate

## The problem: architecture rules that nobody enforces

Most Fastify starters give you a server, a logger and a folder for routes. Six months later the route handler imports the Postgres client directly, the domain model imports a Fastify type, and the diagram in the wiki describes a codebase that no longer exists. marcoturi/fastify-boilerplate takes the opposite position: the layering is a build artifact. The README lists dependency-cruiser under Features with the note that it "validates layer boundaries at CI time", and package.json exposes that as pnpm deps:validate, which runs depcruise src --config .dependency-cruiser.cjs --output-type err-long. A cross-layer import is a failing command, not a code review comment. The audience is a team starting a service that will live for years, where several people will add features and the structure has to survive them. The README also claims the architecture is "framework-agnostic at its core", which is a fair description of the pattern set but not of the repository: what you clone is Fastify, Awilix, Postgres.js and Mercurius wired together.

## How the pieces fit: DI container, modules, CQRS handlers

The runtime is Fastify 5 with Awilix for dependency injection and Pino for logging. Rather than registering plugins by hand, the project uses Fastify autoload, which is why the E2E script sets FASTIFY_AUTOLOAD_TYPESCRIPT=1 before invoking cucumber-js: the loader has to be told that the files it discovers are TypeScript. Each vertical slice lives under src and carries its own routes, handlers, domain code and repository, so a feature is added by adding a folder instead of editing a shared controller. CQRS splits the write path from the read path inside that slice, which is the part that most starters skip. Persistence is Postgres.js for queries plus DBMate for migrations and seeds, kept in the top-level db directory. On the edge, @fastify/helmet sets security headers and @fastify/under-pressure provides back-pressure and backs the GET /health endpoint. REST routes are mounted under /api, Swagger UI is at /api-docs with the OpenAPI 3.1.0 document at /api-docs/json, and GraphQL is served by Mercurius at POST /graphql with GraphiQL available in development. OpenTelemetry is wired through src/instrumentation.ts and disabled by default, so nothing is exported until you point it at a collector.

## Installing it and running a first request

The README scaffolds the project with degit rather than git clone, which gives you a clean tree with no upstream history to detach. Node.js 24 or newer is required because the project runs TypeScript through Node's native type stripping; there is no build step and no transpiler in the pipeline. pnpm 10 or newer is the package manager, and a .nvmrc is included for fnm or nvm.

```bash
npx degit marcoturi/fastify-boilerplate my-app
cd my-app
pnpm install
pnpm create:env
```

pnpm create:env copies .env.example to .env and fails if .env already exists, so it will not silently overwrite local settings. The example file sets PORT=3000, POSTGRES_URL=127.0.0.1:5432 and DBMATE_DATABASE_URL pointing at the same host, with POSTGRES_SSL=false for local work. Start Postgres and apply migrations before the first boot:

```bash
docker compose up postgres -d
pnpm db:migrate
pnpm start
```

The migrate script runs dbmate -e DBMATE_DATABASE_URL --no-dump-schema up, and the --no-dump-schema flag means DBMate will not rewrite a schema file after applying migrations. pnpm start runs Node with --watch and pipes output through pino-pretty, so you should see readable log lines and a listening server on http://localhost:3000. Check GET /health for the under-pressure response, then open /api-docs for Swagger UI.

For a container build, the README gives a standalone image path. The Dockerfile is multi-stage, runs as the non-root user fastify with UID 1001, installs dumb-init for PID 1 signal forwarding, and defines a HEALTHCHECK that fetches /health every 30 seconds.

```bash
docker build -t fastify-boilerplate .
docker run -p 3000:3000 --env-file .env -e HOST=0.0.0.0 fastify-boilerplate
```

HOST is set to 0.0.0.0 in the image, the same value docker-compose.yml sets for the app service. The compose file also defines a one-shot migrate service using ghcr.io/amacneil/dbmate:2, and app waits for it with condition: service_completed_successfully. The comment in that file is explicit that the app image does not ship db/ or dbmate, so migrations never run from inside the application container.

## What the Node 24 requirement costs you

Native TypeScript execution is the most consequential choice here, and it is a constraint as much as a convenience. Node.js 24 becomes a hard floor for every environment: local machines, CI runners, the production image and any serverless runtime you might target. If your platform pins an older Node line, this repository cannot run there without changes the README does not describe. Type stripping also means the TypeScript you write is not the TypeScript a compiler would emit. Constructs that need transformation rather than erasure are outside what the runtime handles, so the type:check script (tsc --noEmit) verifies types but does not produce JavaScript, and there is no compiled output to fall back on. The Dockerfile confirms the intent by copying src directly into the production stage with no build step between. That is a smaller image and a shorter pipeline, and it also means your runtime is your type checker's sibling rather than its output. Teams that rely on decorators with emitDecoratorMetadata, or on path aliases resolved at compile time, should test their assumptions early: the project handles its own aliases through the imports map in package.json (#src/* and #tests/*), not through a bundler.

## Testing, tooling and the release chain

Testing is split by intent. Unit and integration tests use node:test and run through pnpm test:unit against src/**/*.spec.ts, with c8 coverage available via pnpm test:coverage. E2E tests are written in Gherkin and executed by Cucumber through pnpm test:e2e, and the README notes that this requires a running Postgres. There is also a k6 load-test script, reachable as pnpm db:seed:users, which runs tests/user/create-user/create-user.k6.ts. Linting and formatting are handled by Biome alone rather than ESLint plus Prettier, and the README devotes a section to that decision. pnpm check runs biome check and tsc --noEmit together, which is the command to run before committing. Releases go through Husky, Commitlint and Semantic Release, and the version history in package.json shows the effect: a steady stream of patch releases rather than occasional large ones. The README also states that REST (OpenAPI) and GraphQL client types are auto-generated and published to npm on every release, produced by pnpm generate:types, which the script table says requires a running server and database. That is a real coupling to plan for: type generation is not a pure build step, it needs a live instance to introspect.

## NestJS and plain Fastify starters: different trade-offs

The obvious comparison is NestJS, which the search data keeps pairing with Fastify. NestJS gives you modules, decorators and a dependency injection container as framework primitives, with a large ecosystem and a documented upgrade path. This boilerplate gives you the same conceptual layering without the framework owning it: Awilix resolves dependencies, folders define slices, and dependency-cruiser is the thing that keeps the boundaries honest. The difference shows up when you leave Fastify. A NestJS application is coupled to NestJS; the README's claim that these patterns "translate to any language or framework" is only true because here they are conventions plus a lint rule, which is also why they are easier to break if someone deletes the rule. Against a minimal Fastify starter, the trade is the opposite: you get migrations, seeds, E2E scaffolding, telemetry wiring, a hardened Dockerfile and a release pipeline on day one, and you inherit all of their opinions. If your service is three endpoints over one table, most of this repository is overhead you will spend time removing. If it is the first of several services that must share conventions, the enforcement is the feature.

## Maintenance, licence and what to verify before you commit

The repository is not archived, and the last push was on 2026-08-29, with v2.9.23 tagged the same day. That is recent, and the release cadence suggests the template is still being adjusted, but the version numbering tells you something about stability: 2.9.x is a long patch series, so expect the template to move under you while you build on it. Renovate is configured in the repository root, which means dependency bumps arrive as pull requests rather than as a single annual upgrade. The licence is MIT, stated in both the LICENSE file and package.json, so you can use the code commercially and modify it; the usual obligation is preserving the copyright notice and permission text, and the README does not add any further restriction. That is a summary of what the files say, not legal advice. The upgrade cost is concentrated in two places: Node and pnpm version floors, which affect every environment at once, and the dependency-cruiser configuration, which will reject new code that crosses a boundary. Run pnpm deps:validate after adding your first slice, and treat a failure as a design question rather than a config to relax.

## Conclusion

Adopt it if you want a Fastify 5 service where Clean Architecture and CQRS boundaries are checked by dependency-cruiser in CI rather than agreed in a document, and you are willing to run Node.js 24 and pnpm 10. Skip it if you need a small single-purpose HTTP service, if your team already has a working NestJS structure, or if you cannot run Postgres during tests. Before committing, run pnpm deps:validate with your own first slice and confirm the dependency-cruiser config accepts it.

## FAQ

### Which is better, Fastify or NestJS?

The repository does not compare the two. What it shows is a Fastify 5 application where Clean Architecture and CQRS are conventions held in place by dependency-cruiser, Awilix and folder structure rather than by framework primitives, which is the main structural difference from a NestJS project.

### Is Fastify built on Express?

The README does not say. It lists Fastify 5 as the framework, with Awilix for dependency injection and Pino for logging, and does not describe Fastify's internals or its relationship to Express.

### How can I use Fastify with TypeScript?

This boilerplate runs TypeScript through Node.js 24 native type stripping, so there is no build or transpile step. You install with pnpm, copy the environment file with pnpm create:env, apply migrations with pnpm db:migrate and start with pnpm start, which runs Node with --watch and pipes logs through pino-pretty.

## Sources

- [Official README](https://github.com/marcoturi/fastify-boilerplate#readme)
- [Project repository](https://github.com/marcoturi/fastify-boilerplate)
- [Release notes](https://github.com/marcoturi/fastify-boilerplate/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/marcoturi-fastify-boilerplate
