Production-ready boilerplate for building scalable REST APIs with Node.js, TypeScript, Clean Architecture, Dependency Injection (tsyringe), Unit of Work, Result Pattern (neverthrow), Docker, and CI/CD.
A modern, opinionated TypeScript starter for building Node.js REST APIs the right way. Heavy on patterns, light on ceremony. Use it as a template, fork it, or steal the parts you like.
Keywords: nodejs, typescript, express, typeorm, clean-architecture, ddd, dependency-injection, tsyringe, unit-of-work, result-pattern, neverthrow, docker, docker-compose, mysql, vitest, biome, pnpm, boilerplate, starter, template, rest-api.
- ποΈ Clean Architecture β four concentric layers (domain β application β infrastructure β interfaces), dependencies always point inward.
- π Dependency Injection with tsyringe β bind by interface, swap implementations in tests, no manual
newchains. - π Unit of Work β multi-step operations wrapped in a single transaction, with typed access to transactional repositories.
- π― Result Pattern with neverthrow β explicit
Result<T, E>/ResultAsyncat the service boundary; no thrown control-flow. - ποΈ TypeORM 1.0 β
DataSourceAPI, generic repository base, MySQL viamysql2driver, optionalbetter-sqlite3for tests. - π³ Docker first-class β multi-stage
Dockerfile(slim runtime, non-root user, healthcheck) +docker-compose.ymlfor app + MySQL. - π‘οΈ Validation with class-validator + a single
ValidationErrorchannel β controllers neverres.status(400)inline. - π API Key middleware β denies by default when
API_KEYenv is unset. - π§ͺ Vitest with in-memory SQLite repository integration tests + mocked service unit tests.
- π€ GitHub Actions CI + Renovate auto-merge for non-major bumps.
- β‘ tsx runtime in dev (no rebuild loop), tsc + tsc-alias for production builds (smaller image, faster cold-start).
- π¨ Biome as the single lint + format binary (no ESLint/Prettier sprawl).
| Layer | Choice | Why |
|---|---|---|
| Runtime | Node.js 22 LTS | Active LTS through 2027 |
| Language | TypeScript 6 (strict) |
Latest stable, full strictness |
| HTTP | Express 5 | Built-in async error catching |
| ORM | TypeORM 1.0 + mysql2 | Stable major, decorator-based entities |
| DI | tsyringe | Microsoft, decorators, fits the stack |
| Result | neverthrow | Type-safe, ~3KB, great ergonomics |
| Validation | class-validator | DTO decorators |
| Tests | Vitest + supertest | Fast, TS-native |
| Lint/format | Biome | One binary, no plugins |
| Pkg mgr | pnpm 10 | Fast, disk-efficient |
| Container | Docker + Compose | Multi-stage, slim, non-root |
| CI | GitHub Actions | Lint β typecheck β test |
| Deps bot | Renovate | Grouped weekly PRs, auto-merge |
git clone https://github.com/Z3r0J/nodejs-clean-architecture.git
cd nodejs-clean-architecture
# Optional: customise creds via a root .env file (compose picks it up)
cp src/.env.example .env
docker compose up --buildThe API is up on http://localhost:3000 with a fresh MySQL 8.4 next to it.
git clone https://github.com/Z3r0J/nodejs-clean-architecture.git
cd nodejs-clean-architecture
# Install pnpm via corepack (already pinned in package.json)
corepack enable
pnpm install
cp src/.env.example src/.env # then edit src/.env with your DB creds
pnpm devJust need the DB locally?
docker compose up db -dstarts only MySQL onlocalhost:3306β pointsrc/.envat it and runpnpm dev.
src/
βββ domain/ # entities (TypeORM @Entity)
βββ application/
β βββ dtos/ # request DTOs (class-validator) + response DTOs
β βββ helpers/ # logger, DB config, EventId
β βββ interfaces/ # IRepository, IServices, IUnitOfWork, ILogger
β βββ services/ # business logic, returns Result/ResultAsync
βββ infrastructure/
β βββ repositories/ # TypeORM repos (decoupled from singleton)
β βββ UnitOfWork.ts # transactional repo factory
β βββ typeorm.config.ts # AppDataSource (the only singleton site)
βββ container/ # tsyringe tokens + buildContainer()
βββ interfaces/
β βββ controllers/ # @injectable, return DTOs, no try/catch
β β βββ helpers/ # respond(res, result), parseId
β βββ middlewares/ # api-key, error handler (safety net)
β βββ routes/ # route factories: buildXRoute(container)
βββ error-handling/ # CustomError + NotFound/BadRequest/Validation
βββ config/ # ExpressConfig
βββ index.ts # bootstrap: dotenv β DataSource β container β server
tests/ # vitest suite
.github/workflows/ci.yml # lint + typecheck + test
Dockerfile # multi-stage slim runtime
docker-compose.yml # app + MySQL
renovate.json # weekly bot
biome.json # lint + format
pnpm dev # tsx watch mode
pnpm start # tsx one-shot (dev runtime)
pnpm build # tsc β JavaScript + tsc-alias (path-alias rewrite) β ./build
pnpm start:prod # node build/index.js (what runs inside Docker)
pnpm test # vitest run
pnpm test:watch # vitest watch
pnpm lint # biome check
pnpm format # biome format --write
pnpm check # biome check --write (lint + format auto-fix)
pnpm typecheck # tsc --noEmit (strict)
pnpm typeorm -- migration:generate ... # TypeORM CLI passthroughThe Dockerfile is a 5-stage multi-stage build:
baseβ pinnednode:22-slim+ Corepack-managed pnpm.depsβ installs the full dep graph (cached via BuildKit mount).buildβtsc+tsc-aliasto./build.prod-depsβ fresh install of production deps only.runnerβ copiesbuild/+ prodnode_modulesonto a non-rootnodejsuser. Includes aHEALTHCHECKthat hits/.
Build manually:
docker build -t nodejs-clean-architecture .
docker run --rm -p 3000:3000 --env-file .env nodejs-clean-architectureMulti-arch (Apple Silicon + Linux x86):
docker buildx build --platform linux/amd64,linux/arm64 -t you/nodejs-clean-architecture --push .// src/container/index.ts
export const buildContainer = (dataSource: DataSource): DependencyContainer => {
const c = container.createChildContainer();
c.register(TOKENS.DataSource, { useValue: dataSource });
c.register(TOKENS.TestRepository, {
useFactory: (dep) => new TestRepository(dep.resolve(TOKENS.DataSource)),
});
c.register(TOKENS.UnitOfWork, {
useFactory: instancePerContainerCachingFactory(
(dep) => new TypeORMUnitOfWork(dep.resolve(TOKENS.DataSource))
),
});
c.registerSingleton(TOKENS.TestServices, TestServices);
return c;
};- Tokens are
Symbol("Name") as InjectionToken<T>β type-safe, collision-free. - Bind interfaces, not classes β swap implementations in tests by registering a different value against the same token.
- Child container per app (and per test) keeps bindings isolated from the global
container.
async createIfUnique(name: string): ResultAsync<Test, BadRequestError> {
return ResultAsync.fromPromise(
this.uow.run(async (repos) => {
if (await repos.test.findByName(name)) {
throw new BadRequestError(`Test "${name}" already exists`);
}
return repos.test.Create(new Test({ name }));
}),
(e) => (e instanceof BadRequestError ? e : new BadRequestError(String(e)))
);
}- One
uow.runβ oneSTART TRANSACTION/COMMITround-trip. - Repositories inside the callback share the same transactional
EntityManager. - A thrown
BadRequestErrorrolls back; theResultAsyncwraps it back into a typedErr.
getAll = (_req: Request, res: Response) =>
respond(
res,
ResultAsync.fromSafePromise(this._testServices.GetAll()).andThen((rows) =>
rows.length === 0 ? errAsync(new NotFoundError("No data")) : okAsync(rows)
),
{ map: TestResponseDTO.fromEntities }
);- Every handler is a single pipeline that terminates at
respond(res, ...). responddoes the.match: success βres.status(...).json(map(value)), failure βres.status(err.statusCode).json({ message })(or{ errors }forValidationError).- The
ErrorHandlermiddleware is the safety net for genuinely uncaught exceptions only.
PRs welcome. The CI gate is lint β typecheck β test; all green before review.
pnpm install
pnpm check # auto-fix lint/format
pnpm typecheck
pnpm testRenovate handles routine dep bumps. For major bumps, open a PR manually.
There's a 10-step recipe in CLAUDE.md that walks through:
entity β DTOs β repository (with findByX) β service (with business logic + UoW) β token + container binding β @injectable controller β route factory β tests.
MIT β see LICENSE.
Q: Is this overkill for a small API? Probably. The patterns shine when you outgrow a single file but want to keep the surface manageable. If you're just doing a CRUD prototype, you don't need DI containers and UoW β use Express + a single route file.
Q: Why tsyringe and not Inversify? Smaller API, lighter footprint, Microsoft-maintained. Inversify is more powerful (scopes, decorators, middleware) but the surface is bigger than this template needs.
Q: Why neverthrow and not just throw?
Two reasons: known business outcomes become part of the type signature, and controllers stop being littered with try { ... } catch (e) { next(e); }. The ErrorHandler middleware is still there for genuine bugs.
Q: Can I use Postgres / SQLite / SQL Server?
Yes β set DB_TYPE in env. TypeORM picks the right driver. Add the driver package (pg, better-sqlite3, mssql).
Q: Why Express 5, not Fastify / Hono / Elysia?
Express 5 is now stable, ubiquitous, and the path of least resistance for most teams. Fastify is a great swap if you need raw throughput β only the src/interfaces/ layer would change.
If this template saved you time, a star is the easiest way to say thanks and helps others find it.