ause infrastructure swaps are isolated to adapter layers. The architectural constraint pays for itself within two major dependency upgrades or team scaling events.
Core Solution
Clean Architecture is not a folder convention. It is a dependency management strategy enforced through TypeScript's type system and build tooling. The implementation follows four concentric boundaries, each governed by the Dependency Rule: source code dependencies must point inward.
Step 1: Define Entities (Innermost Circle)
Entities encapsulate enterprise-wide business rules. They contain no imports from frameworks, databases, or external libraries. They are plain TypeScript classes or interfaces with strict validation.
// src/domain/entities/User.ts
export interface User {
readonly id: string;
readonly email: string;
readonly status: 'active' | 'suspended' | 'pending';
readonly createdAt: Date;
}
export class UserFactory {
static create(email: string): User {
if (!email.includes('@')) {
throw new Error('Invalid email format');
}
return {
id: crypto.randomUUID(),
email,
status: 'pending',
createdAt: new Date(),
};
}
}
Architecture decision: Entities use value objects and factories instead of ORMs or decorators. This prevents database schema concerns from leaking into business rules. Validation happens at creation, not persistence.
Step 2: Define Use Cases (Application Layer)
Use cases orchestrate entity behavior to fulfill a single business objective. They depend only on interfaces (ports) that describe external interactions. No concrete implementations are imported.
// src/application/ports/IUserRepository.ts
import { User } from '../../domain/entities/User';
export interface IUserRepository {
save(user: User): Promise<void>;
findById(id: string): Promise<User | null>;
}
// src/application/usecases/RegisterUser.ts
import { User, UserFactory } from '../../domain/entities/User';
import { IUserRepository } from '../ports/IUserRepository';
export class RegisterUser {
constructor(private readonly userRepository: IUserRepository) {}
async execute(email: string): Promise<User> {
const user = UserFactory.create(email);
await this.userRepository.save(user);
return user;
}
}
Architecture decision: Interfaces live in the application layer, not infrastructure. This enforces the dependency rule: use cases define what they need; adapters fulfill it. The use case remains pure TypeScript with zero framework dependencies.
Step 3: Implement Adapters (Outer Circle)
Adapters translate between external systems and application ports. Controllers, repositories, message queues, and cache clients belong here. They depend inward to implement interfaces.
// src/infrastructure/repositories/PostgresUserRepository.ts
import { IUserRepository } from '../../application/ports/IUserRepository';
import { User } from '../../domain/entities/User';
import { Pool } from 'pg';
export class PostgresUserRepository implements IUserRepository {
constructor(private readonly pool: Pool) {}
async save(user: User): Promise<void> {
await this.pool.query(
`INSERT INTO users (id, email, status, created_at) VALUES ($1, $2, $3, $4)`,
[user.id, user.email, user.status, user.createdAt]
);
}
async findById(id: string): Promise<User | null> {
const res = await this.pool.query(`SELECT * FROM users WHERE id = $1`, [id]);
if (res.rows.length === 0) return null;
return res.rows[0] as User;
}
}
Architecture decision: Infrastructure adapters are the only layer allowed to import external libraries (pg, express, aws-sdk). They map external data shapes to domain entities. Error handling, retries, and connection pooling are isolated here.
Step 4: Compose at the Entry Point
The composition root wires dependencies. It is the only place where concrete implementations are instantiated. Frameworks bootstrap here, then delegate to use cases.
// src/composition-root.ts
import { Pool } from 'pg';
import { PostgresUserRepository } from './infrastructure/repositories/PostgresUserRepository';
import { RegisterUser } from './application/usecases/RegisterUser';
export function createApp() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const userRepository = new PostgresUserRepository(pool);
const registerUser = new RegisterUser(userRepository);
return { registerUser, pool };
}
Architecture decision: Composition happens once, at startup. Controllers receive pre-configured use cases. This eliminates scattered dependency injection, prevents circular dependencies, and guarantees that business logic never resolves infrastructure at runtime.
Pitfall Guide
1. Violating the Dependency Rule
Mistake: Importing framework types or database drivers into entities or use cases.
Impact: Business logic becomes coupled to infrastructure. Testing requires mocks for external systems. Framework upgrades break core rules.
Best practice: Run static analysis (madge, eslint-plugin-import) to block inward-to-outward imports. Keep domain and application layers free of import { ... } from 'express' or import { ... } from 'typeorm'.
2. Over-Abstracting Early
Mistake: Creating interfaces for every service, repository, and utility before a second implementation exists.
Impact: Boilerplate inflation, reduced readability, and unnecessary indirection.
Best practice: Apply interfaces only at system boundaries where substitution is likely (databases, message brokers, external APIs). Use concrete classes internally until a second implementation is demanded.
3. Treating Architecture as Folder Structure Without Enforcement
Mistake: Organizing code into domain/, application/, infrastructure/ folders but allowing cross-layer imports.
Impact: Architecture degrades within weeks. Developers import infrastructure into use cases for convenience.
Best practice: Enforce boundaries at compile time. Use TypeScript path mapping restrictions, ESLint import rules, and CI pipeline checks. Folders are documentation; tooling is enforcement.
4. Ignoring the Composition Root
Mistake: Instantiating repositories inside controllers or using service locators scattered across layers.
Impact: Hidden dependencies, unpredictable lifecycles, and test setup complexity.
Best practice: Centralize wiring in a single module. Pass configured use cases to adapters. Keep the composition root framework-agnostic where possible.
5. Anemic Domain Models
Mistake: Moving business logic into use cases or services while entities become passive data carriers.
Impact: Logic duplication, inconsistent validation, and violation of object-oriented principles.
Best practice: Keep validation, state transitions, and invariant enforcement inside entities. Use cases should orchestrate, not dictate business rules.
6. Testing the Wrong Layer
Mistake: Writing integration tests for use cases by spinning up databases and HTTP servers.
Impact: Slow feedback loops, flaky tests, and blurred responsibility boundaries.
Best practice: Test use cases in-process with fake repositories. Test adapters separately with real infrastructure. Reserve integration tests for boundary validation and contract compliance.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup MVP (0-6 months) | Lightweight Clean Architecture (3 layers, minimal ports) | Reduces framework lock-in early while preserving velocity | +15% initial setup, -40% refactoring later |
| Enterprise Legacy Modernization | Strangler Fig + Adapter Layer | Wraps existing code with ports; gradually migrates logic inward | +20% overhead per module, prevents full rewrite risk |
| High-Compliance System (FinTech/Health) | Strict boundary enforcement + formal ADRs | Guarantees auditability, testability, and infrastructure independence | +25% design time, -60% compliance rework |
Configuration Template
Folder Structure
src/
βββ domain/ # Entities, value objects, domain exceptions
βββ application/ # Use cases, ports (interfaces), application exceptions
βββ infrastructure/ # Adapters, repositories, controllers, external clients
βββ composition-root.ts
βββ main.ts # Framework bootstrap
tsconfig.json (Path & Strictness)
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"paths": {
"@domain/*": ["src/domain/*"],
"@application/*": ["src/application/*"],
"@infrastructure/*": ["src/infrastructure/*"]
}
},
"include": ["src/**/*"]
}
ESLint Dependency Enforcement (.eslintrc.json)
{
"plugins": ["import"],
"rules": {
"import/no-cycle": "error",
"import/no-extraneous-dependencies": ["error", { "devDependencies": false }],
"import/order": ["error", {
"groups": ["builtin", "external", "internal", "parent", "sibling", "index"],
"pathGroups": [
{ "pattern": "@domain/**", "group": "internal", "position": "before" },
{ "pattern": "@application/**", "group": "internal", "position": "before" },
{ "pattern": "@infrastructure/**", "group": "internal", "position": "after" }
],
"newlines-between": "always"
}]
}
}
Quick Start Guide
- Scaffold the boundary structure: Create
domain/, application/, and infrastructure/ directories. Initialize TypeScript with strict mode and path aliases matching the template.
- Define your first port: Write an interface in
application/ports/ describing a single external dependency (e.g., IUserRepository). Do not import any database or framework types.
- Implement a use case: Create a class in
application/usecases/ that accepts the port via constructor. Write business logic using only domain entities and the port interface.
- Wire and run: In
composition-root.ts, instantiate the concrete adapter from infrastructure/, pass it to the use case, and export. Bootstrap your framework in main.ts using the exported use case. Run npm test to verify in-process execution without external dependencies.