Skip to content

Z3r0J/nodejs-clean-architecture

Repository files navigation

Node.js Clean Architecture Starter β€” TypeScript, Express 5, TypeORM 1, Docker

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.

CI Renovate enabled TypeScript Node.js Express TypeORM Docker License: MIT

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.


✨ Features

  • πŸ›οΈ 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 new chains.
  • πŸ” 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> / ResultAsync at the service boundary; no thrown control-flow.
  • πŸ—ƒοΈ TypeORM 1.0 β€” DataSource API, generic repository base, MySQL via mysql2 driver, optional better-sqlite3 for tests.
  • 🐳 Docker first-class β€” multi-stage Dockerfile (slim runtime, non-root user, healthcheck) + docker-compose.yml for app + MySQL.
  • πŸ›‘οΈ Validation with class-validator + a single ValidationError channel β€” controllers never res.status(400) inline.
  • πŸ” API Key middleware β€” denies by default when API_KEY env 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).

πŸ“¦ Tech stack

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

πŸš€ Quick start

Option A β€” Docker (recommended for first run)

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 --build

The API is up on http://localhost:3000 with a fresh MySQL 8.4 next to it.

Option B β€” Local development with tsx

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 dev

Just need the DB locally? docker compose up db -d starts only MySQL on localhost:3306 β€” point src/.env at it and run pnpm dev.


πŸ“ Project layout

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

πŸ› οΈ Common commands

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 passthrough

🐳 Docker

The Dockerfile is a 5-stage multi-stage build:

  1. base β€” pinned node:22-slim + Corepack-managed pnpm.
  2. deps β€” installs the full dep graph (cached via BuildKit mount).
  3. build β€” tsc + tsc-alias to ./build.
  4. prod-deps β€” fresh install of production deps only.
  5. runner β€” copies build/ + prod node_modules onto a non-root nodejs user. Includes a HEALTHCHECK that hits /.

Build manually:

docker build -t nodejs-clean-architecture .
docker run --rm -p 3000:3000 --env-file .env nodejs-clean-architecture

Multi-arch (Apple Silicon + Linux x86):

docker buildx build --platform linux/amd64,linux/arm64 -t you/nodejs-clean-architecture --push .

🧩 Architecture patterns

Dependency Injection (tsyringe)

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

Unit of Work (atomic multi-step)

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 β‡’ one START TRANSACTION / COMMIT round-trip.
  • Repositories inside the callback share the same transactional EntityManager.
  • A thrown BadRequestError rolls back; the ResultAsync wraps it back into a typed Err.

Result pattern at the controller boundary

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, ...).
  • respond does the .match: success β†’ res.status(...).json(map(value)), failure β†’ res.status(err.statusCode).json({ message }) (or { errors } for ValidationError).
  • The ErrorHandler middleware is the safety net for genuinely uncaught exceptions only.

🀝 Contributing

PRs welcome. The CI gate is lint β†’ typecheck β†’ test; all green before review.

pnpm install
pnpm check        # auto-fix lint/format
pnpm typecheck
pnpm test

Renovate handles routine dep bumps. For major bumps, open a PR manually.


πŸ“š Adding a new resource

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.


πŸ“„ License

MIT β€” see LICENSE.


πŸ™‹ FAQ

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.


🌟 Star history

If this template saved you time, a star is the easiest way to say thanks and helps others find it.

Made with ❀️ by Jean Carlos Reyes

About

Nodejs Clean Architecture starter project, Using Express + Typeorm

Topics

Resources

Stars

71 stars

Watchers

1 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors