Clean Architecture in Node.js Business Applications
Clean Architecture principles are framework-agnostic, but applying them in Node.js requires specific decisions about project structure, dependency management and testing. This article covers the practical approach.
Clean Architecture is Robert Martin’s formalization of layered software design: business logic at the center, infrastructure at the edges, with strict dependency rules between layers. The concept is not new, but applying it correctly in Node.js business applications requires specific decisions that are not always covered in the theoretical material.
This article is a practical guide to Clean Architecture in Node.js — not a description of the theory, but how to implement it in a real application.
Why it matters for business applications
Clean Architecture pays off most clearly in applications that:
- Have complex business rules that need to be tested independently of infrastructure
- Need to support multiple interfaces (REST API, job queue, CLI, etc.) using the same core logic
- Have long lifespans and change requirements over time
- Require database or framework migrations without rewriting the entire application
For simple CRUD applications or short-lived scripts, it is likely overengineering. For a business system expected to operate for years with evolving requirements, the investment is justified.
The layer structure
A Clean Architecture Node.js application typically has four layers:
Domain: Business entities, value objects and domain interfaces. No dependencies on anything outside this layer. No Node.js modules, no framework code, no database drivers.
Application: Use cases that orchestrate the domain logic. Defines the interfaces (ports) that infrastructure must implement. Depends on the domain layer only.
Infrastructure: Implementations of the application’s ports — database repositories, HTTP clients, email senders, file storage, etc. Depends on the application layer interfaces.
Interface: Entry points — HTTP controllers, queue consumers, CLI commands. Translates external requests into application use case calls.
src/
├── domain/
│ ├── entities/
│ ├── value-objects/
│ └── ports/ # interfaces defined by the application layer
├── application/
│ ├── use-cases/
│ └── services/
├── infrastructure/
│ ├── repositories/ # implements domain ports
│ ├── clients/
│ └── providers/
└── interface/
├── http/ # Express/Fastify controllers
├── jobs/ # background job handlers
└── cli/
The dependency rule in practice
The critical rule: dependencies only point inward. Domain has no external dependencies. Application knows about domain but not infrastructure. Infrastructure knows about application interfaces but implements them without being imported by the core.
In practice, this means using dependency injection. Your use cases receive their dependencies (repositories, services) as constructor arguments rather than importing them directly.
// Application layer — defines the interface
export interface ProductRepository {
findById(id: string): Promise<Product | null>;
save(product: Product): Promise<void>;
}
// Application use case — depends on the interface, not the implementation
export class UpdateProductDescriptionUseCase {
constructor(
private readonly productRepo: ProductRepository,
private readonly aiService: ProductDescriptionService
) {}
async execute(productId: string, prompt: string): Promise<void> {
const product = await this.productRepo.findById(productId);
if (!product) throw new ProductNotFoundError(productId);
const description = await this.aiService.generate(product, prompt);
product.updateDescription(description);
await this.productRepo.save(product);
}
}
The infrastructure layer provides a PostgresProductRepository that implements ProductRepository. The use case never knows whether it is talking to PostgreSQL, an in-memory store, or a mock in tests.
Dependency injection without a framework
Node.js does not require an IoC container for clean architecture. For most business applications, simple manual dependency injection in a composition root is more readable and easier to debug than a framework.
// composition-root.ts
const db = new PostgresDatabase(config.database);
const productRepo = new PostgresProductRepository(db);
const aiService = new OpenAIProductDescriptionService(config.openai);
const updateProductDescription = new UpdateProductDescriptionUseCase(
productRepo,
aiService
);
// Wire up your HTTP router
const productController = new ProductController(updateProductDescription);
This is explicit, traceable and easy to swap implementations for testing.
Testing strategy
The main benefit of this structure for testing:
Unit tests for use cases: Inject mock implementations of all ports. Test business logic in isolation from infrastructure. These tests are fast and reliable.
Integration tests for repositories: Test the repository implementations against a real (or test container) database. These tests are slower but validate that infrastructure works correctly.
End-to-end tests: Test the full stack for critical flows. Keep the count small — these are expensive to maintain.
Practical tradeoffs
Clean Architecture adds structure, which means more files and more boilerplate for simple operations. This is the real cost.
Mitigate it by:
- Not applying it uniformly. Simple read endpoints do not need use cases — they can call the repository directly from the controller.
- Not over-abstracting early. Define ports when you have a clear reason (testing, multiple implementations) rather than speculatively.
- Keeping DTOs simple. The transformation between interface layer DTOs, application layer commands and domain objects can be verbose. Keep the transforms close to the layer boundaries.
The structure earns its keep as the application grows, the team expands and requirements change. It does not always feel worth it at the start.
Clean Architecture in Node.js is a pattern that scales with complexity. Applied thoughtfully — with awareness of where it adds value and where it is unnecessary overhead — it produces business applications that are significantly easier to test, maintain and evolve.
If you are designing or refactoring a Node.js business application and would like to discuss the architecture, get in touch.