Clean Architecture in TypeScript — Layered Design Guide 2026
Advertisement
Introduction
Why This Matters
Most Node.js codebases start fast and become unmaintainable. Business logic leaks into Express route handlers. Database queries are mixed with validation. Tests require spinning up a database.
Clean Architecture, formalized by Robert C. Martin, enforces a strict dependency rule: inner layers never depend on outer layers. Business logic in the domain layer knows nothing about Express, PostgreSQL, or Redis. This makes it independently testable, swappable, and maintainable for years.
The Four Layers
src/
domain/ # Layer 1 — Entities and business rules
entities/
value-objects/
repositories/ # Interfaces (not implementations)
errors/
application/ # Layer 2 — Use cases / application logic
use-cases/
services/
dtos/
infrastructure/ # Layer 3 — External concerns
database/
repositories/ # Implements domain repository interfaces
http/
cache/
email/
presentation/ # Layer 4 — Delivery mechanism
http/
controllers/
routes/
middleware/
cli/Dependencies flow inward only: presentation depends on application, application depends on domain, infrastructure depends on domain. Nothing in domain or application imports from infrastructure or presentation.
Layer 1: Domain — Entities and Repository Interfaces
The domain layer contains pure TypeScript — no Express, no Prisma, no external library dependencies:
// src/domain/entities/user.entity.ts
export class User {
private constructor(
public readonly id: string,
public readonly email: string,
public readonly name: string,
private _passwordHash: string,
public readonly createdAt: Date,
private _isActive: boolean
) {}
static create(params: {
id: string;
email: string;
name: string;
passwordHash: string;
}): User {
if (!params.email.includes('@')) {
throw new Error('Invalid email address');
}
if (params.name.trim().length === 0) {
throw new Error('Name cannot be empty');
}
return new User(
params.id,
params.email.toLowerCase().trim(),
params.name.trim(),
params.passwordHash,
new Date(),
true
);
}
static reconstitute(data: {
id: string;
email: string;
name: string;
passwordHash: string;
createdAt: Date;
isActive: boolean;
}): User {
return new User(
data.id, data.email, data.name,
data.passwordHash, data.createdAt, data.isActive
);
}
get passwordHash(): string { return this._passwordHash; }
get isActive(): boolean { return this._isActive; }
deactivate(): void {
if (!this._isActive) throw new Error('User is already inactive');
this._isActive = false;
}
}// src/domain/repositories/user.repository.ts
// Interface only — no implementation details
export interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
delete(id: string): Promise<void>;
}Layer 2: Application — Use Cases
Use cases orchestrate domain entities and external interfaces. They contain application-specific business rules:
// src/application/use-cases/register-user.use-case.ts
import { User } from '../../domain/entities/user.entity';
import { UserRepository } from '../../domain/repositories/user.repository';
import { HashingService } from '../services/hashing.service';
import { IdGenerator } from '../services/id-generator.service';
export interface RegisterUserInput {
email: string;
name: string;
password: string;
}
export interface RegisterUserOutput {
userId: string;
email: string;
name: string;
}
export class RegisterUserUseCase {
constructor(
private userRepository: UserRepository,
private hashingService: HashingService,
private idGenerator: IdGenerator
) {}
async execute(input: RegisterUserInput): Promise<RegisterUserOutput> {
const existing = await this.userRepository.findByEmail(input.email);
if (existing) {
throw new Error('A user with this email already exists');
}
const passwordHash = await this.hashingService.hash(input.password);
const id = this.idGenerator.generate();
const user = User.create({
id,
email: input.email,
name: input.name,
passwordHash,
});
await this.userRepository.save(user);
return {
userId: user.id,
email: user.email,
name: user.name,
};
}
}Note: RegisterUserUseCase depends only on interfaces (UserRepository, HashingService, IdGenerator) — never on concrete implementations. This enables full unit testing without a database.
Layer 3: Infrastructure — Implementations
Infrastructure implements the interfaces defined in the domain and application layers:
// src/infrastructure/database/repositories/postgres-user.repository.ts
import { Pool } from 'pg';
import { User } from '../../../domain/entities/user.entity';
import { UserRepository } from '../../../domain/repositories/user.repository';
export class PostgresUserRepository implements UserRepository {
constructor(private pool: Pool) {}
async findById(id: string): Promise<User | null> {
const result = await this.pool.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
const row = result.rows[0];
if (!row) return null;
return User.reconstitute({
id: row.id,
email: row.email,
name: row.name,
passwordHash: row.password_hash,
createdAt: new Date(row.created_at),
isActive: row.is_active,
});
}
async findByEmail(email: string): Promise<User | null> {
const result = await this.pool.query(
'SELECT * FROM users WHERE email = $1',
[email.toLowerCase()]
);
const row = result.rows[0];
if (!row) return null;
return User.reconstitute({
id: row.id,
email: row.email,
name: row.name,
passwordHash: row.password_hash,
createdAt: new Date(row.created_at),
isActive: row.is_active,
});
}
async save(user: User): Promise<void> {
await this.pool.query(
`INSERT INTO users (id, email, name, password_hash, created_at, is_active)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE
SET email = EXCLUDED.email,
name = EXCLUDED.name,
is_active = EXCLUDED.is_active`,
[user.id, user.email, user.name, user.passwordHash, user.createdAt, user.isActive]
);
}
async delete(id: string): Promise<void> {
await this.pool.query('DELETE FROM users WHERE id = $1', [id]);
}
}Layer 4: Presentation — Controllers and Routes
The presentation layer translates HTTP requests into use-case inputs and use-case outputs into HTTP responses:
// src/presentation/http/controllers/user.controller.ts
import { Request, Response } from 'express';
import { RegisterUserUseCase } from '../../../application/use-cases/register-user.use-case';
export class UserController {
constructor(private registerUser: RegisterUserUseCase) {}
async register(req: Request, res: Response): Promise<void> {
try {
const result = await this.registerUser.execute({
email: req.body.email as string,
name: req.body.name as string,
password: req.body.password as string,
});
res.status(201).json({
data: result,
message: 'User registered successfully',
});
} catch (err: any) {
if (err.message.includes('already exists')) {
res.status(409).json({ error: err.message });
return;
}
res.status(400).json({ error: err.message });
}
}
}Dependency Injection Wiring
All layers are composed at the application entry point:
// src/main.ts
import { Pool } from 'pg';
import express from 'express';
import { PostgresUserRepository } from './infrastructure/database/repositories/postgres-user.repository';
import { BcryptHashingService } from './infrastructure/services/bcrypt-hashing.service';
import { UuidIdGenerator } from './infrastructure/services/uuid-id-generator.service';
import { RegisterUserUseCase } from './application/use-cases/register-user.use-case';
import { UserController } from './presentation/http/controllers/user.controller';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Compose dependencies
const userRepository = new PostgresUserRepository(pool);
const hashingService = new BcryptHashingService(10);
const idGenerator = new UuidIdGenerator();
const registerUserUseCase = new RegisterUserUseCase(userRepository, hashingService, idGenerator);
const userController = new UserController(registerUserUseCase);
const app = express();
app.use(express.json());
app.post('/api/users/register', (req, res) => userController.register(req, res));
app.listen(3000, () => console.log('Server running on port 3000'));Use cases are tested with mock repositories — no database required:
// Unit test — no database, no HTTP
const mockRepo: jest.Mocked<UserRepository> = {
findById: jest.fn(),
findByEmail: jest.fn().mockResolvedValue(null),
save: jest.fn(),
delete: jest.fn(),
};
const useCase = new RegisterUserUseCase(mockRepo, new BcryptHashingService(1), new UuidIdGenerator());
const result = await useCase.execute({ email: 'a@b.com', name: 'Alice', password: 'secret' });
expect(result.email).toBe('a@b.com');
expect(mockRepo.save).toHaveBeenCalledTimes(1);Common Mistakes
- Business logic in controllers — controllers are transport adapters. Never put
ifconditions with business meaning in a controller. - Domain entities importing from infrastructure — this inverts the dependency rule. Domain must know nothing about databases or frameworks.
- Anemic domain models — entities that are pure data containers with no behavior (plain interfaces with getters only) provide no encapsulation benefit.
- Skipping the application layer — calling repository methods directly from controllers bypasses use-case orchestration and makes logic untestable.
- Over-engineering small projects — Clean Architecture pays off in codebases with 5+ engineers or 2+ years of planned longevity. For solo weekend projects, it is overkill.
Best Practices
- Keep each use case in its own file with a single
executemethod — one use case, one responsibility. - Define repository interfaces in the domain layer and implementations in infrastructure — this is the dependency inversion principle in action.
- Use DTOs (Data Transfer Objects) to define use-case inputs and outputs, never passing raw domain entities to the presentation layer.
- Validate request input at the presentation boundary (Zod, class-validator) before passing it to use cases.
- Write unit tests for use cases with mock repositories — they run in milliseconds with no infrastructure dependencies.
Key Takeaways
- Clean Architecture enforces a strict inward dependency rule: outer layers depend on inner layers, never the reverse.
- The four layers are domain (entities, repository interfaces), application (use cases), infrastructure (implementations), and presentation (controllers, routes).
- Domain entities contain business invariants and behavior — they are not plain data objects.
- Repository interfaces defined in the domain layer enable the infrastructure layer to be swapped (PostgreSQL to MongoDB) without touching business logic.
- Use cases are unit-testable without databases or HTTP servers because they depend on interfaces injected at composition time.
- The presentation layer is a thin adapter that translates between HTTP and use-case inputs/outputs — it contains no business logic.
- Dependency injection is wired at the application entry point (
main.ts), composing all layers into a working system. - Clean Architecture pays off in team codebases and long-lived applications — for small solo projects, a simpler layering is appropriate.
Advertisement