What is Clean Architecture
Clean Architecture is a software design principle proposed by Robert C. Martin (Uncle Bob). It separates business logic from external details (frameworks, databases, UI) to build testable and maintainable systems.
flowchart TB
subgraph Outer["Frameworks & Drivers (Web, DB, UI)"]
subgraph Adapters["Interface Adapters (Controllers, Gateways)"]
subgraph App["Application Business (Use Cases)"]
subgraph Core["Enterprise Business"]
Entities["Entities<br/>(Business Rules)"]
end
end
end
end
Dependency Rule: Outer → Inner (Inner knows nothing about outer)
Dependency Inversion Principle (DIP)
The core of Clean Architecture is the Dependency Inversion Principle.
Traditional Dependencies:
flowchart LR
Controller --> Service --> Repository["Repository (Impl)"]
High-level depends on low-level
After Dependency Inversion:
flowchart LR
Controller --> UseCase
UseCase <-- Interface["Repository Interface"]
Interface --> Impl["Repository Impl"]
Depend on abstractions (hide implementation details)
Layer Structure
Directory Structure
src/
├── domain/ # Enterprise Business Rules
│ ├── entities/ # Entities
│ │ ├── User.ts
│ │ ├── Order.ts
│ │ └── Product.ts
│ ├── value-objects/ # Value Objects
│ │ ├── Email.ts
│ │ ├── Money.ts
│ │ └── Address.ts
│ ├── repositories/ # Repository Interfaces
│ │ ├── IUserRepository.ts
│ │ └── IOrderRepository.ts
│ ├── services/ # Domain Services
│ │ └── PricingService.ts
│ └── errors/ # Domain Errors
│ └── DomainError.ts
├── application/ # Application Business Rules
│ ├── use-cases/ # Use Cases
│ │ ├── user/
│ │ │ ├── CreateUserUseCase.ts
│ │ │ └── GetUserByIdUseCase.ts
│ │ └── order/
│ │ ├── CreateOrderUseCase.ts
│ │ └── CancelOrderUseCase.ts
│ ├── dto/ # Data Transfer Objects
│ │ ├── CreateUserDTO.ts
│ │ └── OrderResponseDTO.ts
│ └── services/ # Application Services
│ └── NotificationService.ts
├── infrastructure/ # Frameworks & Drivers
│ ├── database/
│ │ ├── prisma/
│ │ │ └── PrismaUserRepository.ts
│ │ └── drizzle/
│ │ └── DrizzleOrderRepository.ts
│ ├── external/
│ │ ├── StripePaymentGateway.ts
│ │ └── SendGridEmailService.ts
│ └── config/
│ └── database.ts
└── presentation/ # Interface Adapters
├── http/
│ ├── controllers/
│ │ ├── UserController.ts
│ │ └── OrderController.ts
│ ├── middleware/
│ │ └── authMiddleware.ts
│ └── routes/
│ └── index.ts
└── graphql/
├── resolvers/
└── schema/
Entity Implementation
// domain/entities/User.ts
import { Email } from '../value-objects/Email';
import { UserId } from '../value-objects/UserId';
import { DomainError } from '../errors/DomainError';
export interface UserProps {
id: UserId;
email: Email;
name: string;
createdAt: Date;
updatedAt: Date;
}
export class User {
private constructor(private readonly props: UserProps) {}
// Factory method
static create(input: { email: string; name: string }): User {
const email = Email.create(input.email);
const id = UserId.generate();
const now = new Date();
return new User({
id,
email,
name: input.name,
createdAt: now,
updatedAt: now,
});
}
// Reconstitution (loading from DB)
static reconstruct(props: UserProps): User {
return new User(props);
}
// Getters
get id(): UserId {
return this.props.id;
}
get email(): Email {
return this.props.email;
}
get name(): string {
return this.props.name;
}
// Business logic
changeName(newName: string): void {
if (newName.length < 2 || newName.length > 100) {
throw new DomainError('Name must be between 2 and 100 characters');
}
this.props.name = newName;
this.props.updatedAt = new Date();
}
changeEmail(newEmail: string): void {
this.props.email = Email.create(newEmail);
this.props.updatedAt = new Date();
}
}
Value Object Implementation
// domain/value-objects/Email.ts
import { DomainError } from '../errors/DomainError';
export class Email {
private constructor(private readonly value: string) {}
static create(value: string): Email {
if (!this.isValid(value)) {
throw new DomainError(`Invalid email format: ${value}`);
}
return new Email(value.toLowerCase());
}
private static isValid(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
getValue(): string {
return this.value;
}
equals(other: Email): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}
// domain/value-objects/Money.ts
import { DomainError } from '../errors/DomainError';
export class Money {
private constructor(
private readonly amount: number,
private readonly currency: string
) {}
static create(amount: number, currency: string = 'USD'): Money {
if (amount < 0) {
throw new DomainError('Amount cannot be negative');
}
return new Money(amount, currency);
}
getAmount(): number {
return this.amount;
}
getCurrency(): string {
return this.currency;
}
add(other: Money): Money {
this.ensureSameCurrency(other);
return Money.create(this.amount + other.amount, this.currency);
}
subtract(other: Money): Money {
this.ensureSameCurrency(other);
const result = this.amount - other.amount;
if (result < 0) {
throw new DomainError('Insufficient funds');
}
return Money.create(result, this.currency);
}
multiply(factor: number): Money {
return Money.create(Math.round(this.amount * factor), this.currency);
}
private ensureSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new DomainError('Currency mismatch');
}
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
}
Repository Interface
// domain/repositories/IUserRepository.ts
import { User } from '../entities/User';
import { UserId } from '../value-objects/UserId';
import { Email } from '../value-objects/Email';
export interface IUserRepository {
save(user: User): Promise<void>;
findById(id: UserId): Promise<User | null>;
findByEmail(email: Email): Promise<User | null>;
findAll(options?: { limit?: number; offset?: number }): Promise<User[]>;
delete(id: UserId): Promise<void>;
exists(id: UserId): Promise<boolean>;
}
// domain/repositories/IOrderRepository.ts
import { Order } from '../entities/Order';
import { OrderId } from '../value-objects/OrderId';
import { UserId } from '../value-objects/UserId';
export interface IOrderRepository {
save(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
findByUserId(userId: UserId): Promise<Order[]>;
findPending(): Promise<Order[]>;
}
Use Case Implementation
// application/use-cases/user/CreateUserUseCase.ts
import { User } from '../../../domain/entities/User';
import { IUserRepository } from '../../../domain/repositories/IUserRepository';
import { Email } from '../../../domain/value-objects/Email';
import { ApplicationError } from '../../errors/ApplicationError';
export interface CreateUserInput {
email: string;
name: string;
}
export interface CreateUserOutput {
id: string;
email: string;
name: string;
createdAt: Date;
}
export class CreateUserUseCase {
constructor(private readonly userRepository: IUserRepository) {}
async execute(input: CreateUserInput): Promise<CreateUserOutput> {
// Duplicate check
const email = Email.create(input.email);
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) {
throw new ApplicationError('User with this email already exists');
}
// Create entity
const user = User.create({
email: input.email,
name: input.name,
});
// Persist
await this.userRepository.save(user);
// Transform response
return {
id: user.id.getValue(),
email: user.email.getValue(),
name: user.name,
createdAt: user.createdAt,
};
}
}
Infrastructure Layer Implementation
// infrastructure/database/prisma/PrismaUserRepository.ts
import { PrismaClient } from '@prisma/client';
import { IUserRepository } from '../../../domain/repositories/IUserRepository';
import { User } from '../../../domain/entities/User';
import { UserId } from '../../../domain/value-objects/UserId';
import { Email } from '../../../domain/value-objects/Email';
export class PrismaUserRepository implements IUserRepository {
constructor(private readonly prisma: PrismaClient) {}
async save(user: User): Promise<void> {
await this.prisma.user.upsert({
where: { id: user.id.getValue() },
create: {
id: user.id.getValue(),
email: user.email.getValue(),
name: user.name,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
},
update: {
email: user.email.getValue(),
name: user.name,
updatedAt: user.updatedAt,
},
});
}
async findById(id: UserId): Promise<User | null> {
const record = await this.prisma.user.findUnique({
where: { id: id.getValue() },
});
if (!record) return null;
return User.reconstruct({
id: UserId.create(record.id),
email: Email.create(record.email),
name: record.name,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
}
async findByEmail(email: Email): Promise<User | null> {
const record = await this.prisma.user.findUnique({
where: { email: email.getValue() },
});
if (!record) return null;
return this.toDomain(record);
}
private toDomain(record: PrismaUser): User {
return User.reconstruct({
id: UserId.create(record.id),
email: Email.create(record.email),
name: record.name,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
}
}
Dependency Injection Setup
// infrastructure/di/container.ts
import { PrismaClient } from '@prisma/client';
import { PrismaUserRepository } from '../database/prisma/PrismaUserRepository';
import { CreateUserUseCase } from '../../application/use-cases/user/CreateUserUseCase';
// Simple DI Container
export class Container {
private static instance: Container;
private readonly prisma: PrismaClient;
private constructor() {
this.prisma = new PrismaClient();
}
static getInstance(): Container {
if (!Container.instance) {
Container.instance = new Container();
}
return Container.instance;
}
// Repositories
getUserRepository(): IUserRepository {
return new PrismaUserRepository(this.prisma);
}
// Use Cases
getCreateUserUseCase(): CreateUserUseCase {
return new CreateUserUseCase(this.getUserRepository());
}
}
Testability
// __tests__/application/CreateUserUseCase.test.ts
import { CreateUserUseCase } from '../../src/application/use-cases/user/CreateUserUseCase';
import { InMemoryUserRepository } from '../__mocks__/InMemoryUserRepository';
describe('CreateUserUseCase', () => {
let useCase: CreateUserUseCase;
let userRepository: InMemoryUserRepository;
beforeEach(() => {
userRepository = new InMemoryUserRepository();
useCase = new CreateUserUseCase(userRepository);
});
it('should create a new user', async () => {
const input = {
email: 'test@example.com',
name: 'Test User',
};
const result = await useCase.execute(input);
expect(result.email).toBe(input.email);
expect(result.name).toBe(input.name);
expect(result.id).toBeDefined();
});
it('should throw error for duplicate email', async () => {
const input = {
email: 'test@example.com',
name: 'Test User',
};
await useCase.execute(input);
await expect(useCase.execute(input)).rejects.toThrow(
'User with this email already exists'
);
});
});
Summary
| Layer | Responsibility | Dependency Direction |
|---|---|---|
| Domain | Business rules, Entities | No dependencies |
| Application | Use cases, Application logic | Domain |
| Infrastructure | DB, External service implementations | Domain, Application |
| Presentation | Controllers, Routing | Application |