A clean and scalable Node.js API template built with TypeScript, Express, and Prisma, following Clean Architecture and SOLID principles.
Designed as a production-ready starter for modern backend applications with modularity, testability, and infrastructure independence.
- β TypeScript + Express setup
- β Modular folder structure following Clean Architecture
- β Prisma ORM with PostgreSQL support
- β Zod for schema validation and runtime type safety
- β Centralized error handling with Error Adapters
- β Background job processing with BullMQ + Redis (Queue Workers module)
- β HTTP transport layer abstraction with support for future adapters (Express, Fastify, etc)
- β Built-in internationalization (i18n) with request-scoped language detection
- β Jest for unit and E2E testing
- β ESLint + Prettier for linting and formatting
- β Husky for Git hooks (pre-commit checks)
- β Ready for CI/CD, production scaling, and infrastructure swap (ORM, Queue, HTTP layer, etc.)
## π Project Structure
βββ .env # Project environment variables
βββ .env.example # Example environment variables for setup reference
βββ .gitignore
βββ client.http # HTTP client requests for testing APIs (e.g., REST Client in VSCode)
βββ commitlint.config.ts # Commit lint rules
βββ docker-compose.yml # Docker services (Postgres, etc.)
βββ eslint.config.mjs # ESLint configuration
βββ package.json
βββ package-lock.json
βββ README.md # Yep, that's me
βββ tsconfig.json
β
π prisma/ # Prisma migrations and schema
β
π src/
βββ app.module.ts # Application bootstrapper (loads all infra and modules)
β
βββ π modules/ # Domain layer (business logic and use cases)
β βββ π users/
β βββ dtos/ # Zod DTOs for input validation (request body, params, etc.)
β βββ entities/ # Domain entities (pure business models)
β βββ repositories/ # Repository interfaces (contracts, no implementation here)
β βββ use-cases/ # Application use cases (e.g., create, update, list users)
β βββ user.controller.ts # HTTP controller for user-related endpoints
β
βββ π infra/ # Infrastructure layer: database, HTTP server, configs, testing setup
β βββ π config/
β β βββ env.ts # Parses and validates .env variables using Zod
β β
β βββ π database/
β β βββ database.interface.ts # DB abstraction interface
β β βββ database.module.ts # Binds repository implementations and Prisma service
β β βββ π prisma/
β β βββ prisma.service.ts # Prisma client instance and connection management
β β βββ π mappers/ # Converts Prisma models to domain entities
β β β βββ user.mapper.ts
β β βββ π repositories/ # Prisma-specific repository implementations
β β βββ user.repository.ts
β β
β βββ π http/
β β βββ http.interface.ts # HTTP interface definitions (Request, Response types, etc.)
β β βββ http.module.ts # Exposes HTTP-layer dependencies (Express, routers, etc.)
β β βββ π express/
β β β βββ express.service.ts # Express app setup and server start
β β β βββ π adapters/
β β β β βββ express-router.adapter.ts # Express-specific HTTP adapter (HttpRouter implementation)
β β β βββ π middlewares/ # Express middlewares
β β β βββ global-error.middleware.ts # Centralized error handler
β β β βββ request-context.middleware.ts # Initializes AsyncLocalStorage context
β β β βββ route-not-found.middleware.ts # Handles unknown routes (404)
β β β
β β βββ π routes/
β β βββ healthcheck.routes.ts # Infra-specific routes like healthcheck, status, etc.
β β βββ users.routes.ts # User module routes
β β
β βββ π queues/
β β βββ queue.interface.ts # Queue abstraction interface (decouples infrastructure from business logic)
β β βββ queue.module.ts # Queue lifecycle manager (init/shutdown for workers)
β β βββ queue.bootstrap.ts # Standalone bootstrapper to run queue workers as a separate process
β β βββ π bullmq/
β β βββ bullmq-queue.service.ts # BullMQ implementation of the Queue interface
β β βββ π workers/ # Queue workers (e.g., email, file processing)
β β βββ email.worker.ts
β β
β βββ π testing/ # Testing infrastructure (Jest configs, E2E setup)
β βββ jest.config.ts
β βββ jest.e2e.config.ts
β βββ setup.ts # Unit and Integration test setup
β βββ setup.e2e.ts # E2E test setup (test DB cleanups, migrations, etc.)
β
βββ π common/ # Cross-cutting concerns and shared utilities
β βββ π context/
β β βββ request-context.ts # Per-request context (locale, requestId, etc.) using AsyncLocalStorage
β β
β βββ π errors/
β β βββ adapters/ # Error adapters (Prisma, Zod, body-parser, etc.)
β β β βββ body-parser-error-adapter.ts
β β β βββ prisma-error-adapter.ts
β β β βββ zod-error-adapter.ts
β β βββ custom-error.ts # Custom error class for manual error throws
β β βββ error-adapter.interface.ts # Adapter interface for error adapters
β β βββ error-response.ts # Standardizes error response shape
β β βββ types.ts # Error-related types
β β
β βββ π i18n/ # Internationalization (i18n) system for multi-language support
β β βββ en-US.ts # English translations
β β βββ pt-BR.ts # Brazilian Portuguese translations
β β βββ index.ts # Language selector and i18n helpers
β β βββ types.ts # i18n message type contracts
β β
β βββ π utils/
β βββ validate-request.ts # Utility for validating request body and params together using Zod
git clone https://github.com/gusilveiramp/node-clean-architecture-api.git
cd node-clean-architecture-api
npm installcp .env.example .env
# Then edit your `.env` file as needednpm run devnpm test # Run unit and integration tests
npm run test:e2e # Run end-to-end tests
npm run test:watch # Watch mode
npm run test:cov # Generate test coverage reportThe E2E tests use a separate Jest config and a dedicated Postgres schema for isolation.
After running the coverage command, you can open the report locally by opening this file in your browser:
./coverage/lcov-report/index.html
npm run lintHusky will also run automatically on pre-commit.
If you prefer, you can run the project inside Docker.
docker compose up -dAfter that:
npx prisma migrate deployAccess your API on:
API: http://localhost:3333
This project follows Clean Architecture and SOLID principles. Below are key design decisions and the reasoning behind them.
- Why?
To enforce strict separation between application layers and responsibilities.
| Folder | Responsibility |
|---|---|
modules/ |
Business logic: entities, use cases, repository interfaces, DTOs |
infra/ |
Infrastructure: database, HTTP server, external services |
common/ |
Cross-cutting concerns: error handling, context management, utilities |
- Benefits:
β Infrastructure can evolve or be replaced (e.g., switch from Prisma to Sequelize) without impacting the business logic
β Clear boundaries improve testability and scalability
-
Why?
To abstract data access and avoid tight coupling to any specific ORM or database technology. -
How?
Business logic depends only on repository interfaces (insidemodules/).
Concrete implementations (likePrismaUserRepository) live insideinfra/database/. -
Benefits:
β Swapping database providers (e.g., from Prisma to Sequelize or raw SQL) requires only creating a new repository implementation
β No changes needed in the business logic or use cases
-
Why?
To provide request-scoped context (likeAccept-Languagefor internationalization) throughout the application without manually passing it between layers. -
How?
Using AsyncLocalStorage, a context is created for each incoming HTTP request.
Any part of the app (use cases, error adapters, services) can access the context at runtime. -
Benefits:
β Clean and uncluttered function signatures
β No need to pass request metadata manually through every layer
-
Why?
To ensure incoming HTTP request data adheres to the expected structure and data types. -
How?
Each DTO uses Zod schemas for validation.
If validation fails, a ZodErrorAdapter converts the error into the standard API error response format. -
Benefits:
β Automatic input validation at the controller layer
β Consistent, translatable error messages
-
Why?
To decouple infrastructure-specific errors (like Prisma, Sequelize, etc.) from the APIβs public error contract. -
How?
Each external error source (ORMs, validation libraries, external services) has its own Error Adapter, implementing a common interface with two methods:
| Method | Responsibility |
|---|---|
canHandle() |
Determines if the adapter can handle a given error type |
handle() |
Transforms the original error into the APIβs standard error format |
- Benefits:
β Centralized and predictable error handling
β Easily extendable (e.g., adding a new SequelizeErrorAdapter in the future)
β The global error middleware remains untouched even as new adapters are added
-
Why?
To have a single, centralized place responsible for handling all unhandled errors from any part of the application. -
How?
The middleware iterates through all registered ErrorAdapters.
The first adapter that returnscanHandle() === truewill process the error and return a standardized API error response. -
Benefits:
β Predictable error output for API consumers
β No risk of leaking stack traces or internal error details
β Ready for extension as the system grows
-
Why?
To offload time-consuming or non-blocking tasks (like sending emails, processing files, etc.) to background workers, avoiding performance impacts on HTTP requests. -
How?
The system uses BullMQ + Redis for queue management and background job processing. -
Where?
All queue-related infrastructure lives inside/src/infra/queues/.
Thequeue.interface.tsdefines a generic queue contract to decouple the business logic from the infrastructure.
The BullMQ-specific implementation lives inside/bullmq/.
Each queue worker is defined as a separate file inside/bullmq/workers/. -
Execution Flow:
The queue system runs in a separate Node.js process, fully independent from the HTTP server.
This is handled via a standalone bootstrapper:
queue.bootstrap.tsβ started via the scriptnpm run dev:worker -
Benefits:
β Full decoupling between API and background workers
β Scalable: You can run multiple worker instances without affecting the API
β Pluggable: Easily replace BullMQ with Kafka, RabbitMQ, or any other queue system in the future
-
Why?
To allow the project to switch from Express to Fastify (or any other framework) in the future with minimal changes. -
How?
The project defines a genericHttpRouterinterface in/infra/http/http.interface.ts.
The Express-specific implementation lives inside/infra/http/express/adapters/express-router.adapter.ts.Business modules and route files never import Express types directly.
Instead, they rely on the genericHttpRouterandHttpHandlertypes, which are exposed centrally from theHttpModule. -
Where do routes live?
All route registration functions (likeregisterUserRoutes,registerHealthCheckRoutes) live inside/infra/http/routes/.
Each module has its own route registration file (e.g.,user.routes.ts,payment.routes.ts, etc). -
How are routes registered?
Inside theExpressService, each route registration function is called manually, using the current HTTP adapter. -
Benefits:
β HTTP framework agnostic (easily swap Express for Fastify or others)
β Centralized router management
β Consistent pattern for all modules
β Clean separation between transport layer and business logic
-
Why?
To centralize dependency composition for each module, making the system highly decoupled, testable, and infrastructure-agnostic. -
How?
Each module (likeUserModule,PaymentModule, etc) has a static method (typically calledgetXController()) that wires all necessary dependencies (repositories, queue services, etc) and returns a fully constructed Controller. -
Where?
Inside each module folder, as auser.module.ts,payment.module.ts, etc. -
Example flow for Users:
| Layer | Responsibility |
|---|---|
DatabaseModule |
Exposes repositories (ex: userRepository) |
QueueModule |
Exposes the Queue service |
UserModule |
Wires repositories and services into UseCases |
UserController |
Gets all UseCases already injected and ready |
users.routes.ts |
Simply imports the Controller from UserModule and binds it to routes |
-
Benefits:
β Full inversion of control at module level
β Clear module composition boundaries
β No direct infrastructure imports inside controllers or use cases
β Simplifies future DI Container migration if needed
β Extremely testable (easy to inject mocks in unit tests) -
UserModule Example:
export class UserModule {
static getUserController(): UserController {
const userRepository = DatabaseModule.userRepository;
const queueService = QueueModule.queueService;
const createUserUseCase = new CreateUserUseCase(
userRepository,
queueService
);
const updateUserUseCase = new UpdateUserUseCase(userRepository);
const findAllUsersUseCase = new FindAllUsersUseCase(userRepository);
return new UserController(
createUserUseCase,
updateUserUseCase,
findAllUsersUseCase
);
}
}This API supports multi-language error messages, making it ready for international projects or apps that serve users from different regions.
-
Why?
To provide error messages and system responses in the client's preferred language. -
How?
The app reads theAccept-Languageheader from each request and loads the correct translation file (e.g.,pt-BR,en-US) using a request-scoped context. -
Where?
Translations live inside/src/common/i18n/. -
Benefits:
β Customizable multilingual error messages
β Fully translatable API responses (e.g., validation errors, system errors, database errors)
β Centralized message management
This architecture ensures that:
β
Business logic remains independent from frameworks and infrastructure
β
Infrastructure can evolve without affecting core business processes
β
Error handling is centralized, predictable, and easily extensible
β
The project remains maintainable, testable, and scalable over time
| Type | When to use | Example |
|---|---|---|
feat |
When you add a new feature | feat: add user creation endpoint |
fix |
When you fix a bug | fix: fix unique email constraint issue on user creation |
chore |
For non-business logic tasks (e.g., configs, dependencies, tooling) | chore: update Prisma dependencies |
refactor |
When you refactor code without changing its external behavior | refactor: extract validation logic into a helper |
test |
When you add or update tests | test: add unit test for CreateUserUseCase |
style |
For style-only changes (formatting, spaces, indentation, etc.) | style: fix indentation in express.service.ts |
docs |
When you add or update documentation | docs: update folder structure in README |
ci |
For CI/CD-related changes (e.g., workflows, pipelines, husky) | ci: add GitHub Actions workflow for deployment |
perf |
When you improve performance | perf: improve user query performance by adding email index |
revert |
When you revert a previous commit | revert: feat: add JWT authentication middleware |
Created by Gustavo Silveira
LinkedIn