Clean Architecture Skill
Comprehensive guide for implementing Clean Architecture, SOLID principles, and maintainable code structures.
When to Use This Skill
-
Designing new service architecture
-
Refactoring legacy code to clean architecture
-
Implementing dependency injection
-
Defining domain boundaries and layer separation
-
Applying SOLID principles
-
Reviewing architectural decisions
Architecture Layers
The Dependency Rule
Dependencies point inward. Inner layers must not know about outer layers.
┌─────────────────────────────────────────────────┐ │ External Layer (Web, CLI, GraphQL) │ │ ┌───────────────────────────────────────────┐ │ │ │ Infrastructure (Repos, Adapters, ORM) │ │ │ │ ┌───────────────────────────────────────┐ │ │ │ │ │ Application (Use Cases, Services) │ │ │ │ │ │ ┌───────────────────────────────────┐ │ │ │ │ │ │ │ Domain (Entities, VOs, Services) │ │ │ │ │ │ │ └───────────────────────────────────┘ │ │ │ │ │ └───────────────────────────────────────┘ │ │ │ └───────────────────────────────────────────┘ │ └─────────────────────────────────────────────────┘ Dependencies point INWARD
- Domain Layer
Business rules isolated from technical concerns:
-
Entities: Objects with identity, business logic
-
Value Objects: Immutable objects without identity
-
Domain Services: Stateless operations on domain objects
-
Repository Interfaces: Data access contracts
// Entity with behavior export class User { constructor( public readonly id: UserId, private passwordHash: PasswordHash ) {}
changePassword(newPassword: Password, hasher: PasswordHasher): void { this.passwordHash = hasher.hash(newPassword); } }
// Value Object - immutable, validated export class Email { private constructor(private readonly value: string) {}
static create(email: string): Email { if (!this.isValid(email)) throw new InvalidEmailError(email); return new Email(email.toLowerCase()); }
private static isValid(email: string): boolean { return /^[^\s@]+@[^\s@]+.[^\s@]+$/.test(email); }
equals(other: Email): boolean { return this.value === other.value; } }
// Repository interface - defines contract export interface UserRepository { findById(id: UserId): Promise<User | null>; save(user: User): Promise<void>; }
- Application Layer
Orchestrates domain objects for use cases:
-
Use Cases: Single responsibility operations
-
DTOs: Data transfer at boundaries
-
Ports: Interfaces for external dependencies
export class CreateUserUseCase { constructor( private readonly userRepository: UserRepository, private readonly passwordHasher: PasswordHasher ) {}
async execute(input: CreateUserInput): Promise<CreateUserOutput> { const existing = await this.userRepository.findByEmail( Email.create(input.email) ); if (existing) throw new EmailAlreadyExistsError();
const user = new User(
UserId.generate(),
Email.create(input.email),
this.passwordHasher.hash(input.password),
new Date()
);
await this.userRepository.save(user);
return user.toDTO();
} }
- Infrastructure Layer
Implements interfaces from inner layers:
-
Repository Implementations: Database access
-
External Adapters: Third-party integrations
-
ORM/Query Builders: Data persistence
export class PostgreSQLUserRepository implements UserRepository { constructor(private readonly db: Database) {}
async findById(id: UserId): Promise<User | null> { const row = await this.db.query('SELECT * FROM users WHERE id = $1', [id.toString()]); return row ? this.toDomain(row) : null; }
async save(user: User): Promise<void> {
await this.db.query(
INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET email = $2,
[user.id.toString(), user.email.toString(), user.passwordHash]
);
}
private toDomain(row: UserRow): User { return new User(UserId.fromString(row.id), PasswordHash.fromString(row.password_hash)); } }
- Presentation Layer
Entry points to the application:
-
Controllers: HTTP handlers
-
Resolvers: GraphQL endpoints
-
CLI Commands: Command-line interfaces
export class UserController { constructor(private readonly createUserUseCase: CreateUserUseCase) {}
async create(req: Request, res: Response): Promise<void> { try { const result = await this.createUserUseCase.execute(req.body); res.status(201).json(result); } catch (error) { if (error instanceof EmailAlreadyExistsError) { res.status(409).json({ error: error.message }); } } } }
Project Structure
src/ ├── domain/ │ ├── entities/ (User, Order) │ ├── value-objects/ (Email, Money, UserId) │ ├── services/ (PricingService) │ ├── repositories/ (Interfaces only) │ └── errors/ ├── application/ │ ├── use-cases/ (CreateUser, UpdateOrder) │ ├── services/ (NotificationService) │ ├── ports/ (EmailPort, PaymentPort) │ └── dto/ ├── infrastructure/ │ ├── repositories/ (PostgreSQL, MongoDB implementations) │ ├── adapters/ (SendGrid, Stripe) │ ├── orm/ │ └── config/ ├── presentation/ │ ├── http/ (Controllers, Routes, Middleware) │ ├── graphql/ (Resolvers) │ └── cli/ (Commands) ├── shared/ (Utilities, Kernel helpers) └── container/ (Dependency Injection setup)
Dependency Injection
// src/container/container.ts import { Container } from 'inversify';
const container = new Container();
// Bind implementations to interfaces container.bind<UserRepository>(TYPES.UserRepository) .to(PostgreSQLUserRepository) .inSingletonScope();
container.bind<CreateUserUseCase>(TYPES.CreateUserUseCase) .to(CreateUserUseCase) .inTransientScope();
container.bind<UserController>(TYPES.UserController) .to(UserController) .inTransientScope();
export { container };
SOLID Principles
Single Responsibility
Each layer has one reason to change:
-
Domain: Business rules
-
Application: Use case coordination
-
Infrastructure: Technical implementations
-
Presentation: User interface
Open/Closed
Add features by creating new use cases, not modifying existing:
export class UpdateUserUseCase { /* ... */ }
Liskov Substitution
Repository implementations are fully interchangeable:
const repo: UserRepository = new PostgreSQLUserRepository(db); const repo: UserRepository = new MongoUserRepository(client); // Both satisfy the contract
Interface Segregation
Use focused interfaces, not fat ones:
// Good: Segregated interface UserCreator { create(data): User; } interface UserDeleter { delete(id): void; }
// Bad: Fat interface interface UserService { create(): User; update(): User; delete(): void; sendEmail(): void; generateReport(): Report; }
Dependency Inversion
Depend on abstractions, not implementations:
// Application defines the port export interface EmailPort { send(to: string, subject: string, body: string): Promise<void>; }
// Infrastructure implements export class SendGridAdapter implements EmailPort { async send(to: string, subject: string, body: string): Promise<void> { await this.sendgrid.send({ to, subject, text: body }); } }
// Use cases depend on port export class CreateUserUseCase { constructor(private readonly emailPort: EmailPort) {} }
Testing
// Unit: Domain logic without infrastructure describe('User', () => { it('should change password', () => { const hasher = new BCryptHasher(); const user = new User(UserId.generate(), hasher.hash('oldpass')); user.changePassword(Password.create('newpass'), hasher); expect(user.validatePassword(Password.create('newpass'), hasher)).toBe(true); }); });
// Integration: Infrastructure with real DB describe('PostgreSQLUserRepository', () => { it('should save and retrieve user', async () => { const repo = new PostgreSQLUserRepository(testDb); const user = createTestUser(); await repo.save(user); const retrieved = await repo.findById(user.id); expect(retrieved).not.toBeNull(); }); });
// E2E: Full stack via HTTP describe('User API', () => { it('should create user via POST', async () => { const response = await request(app).post('/api/users').send({ email: 'test@example.com', password: 'secure123' }); expect(response.status).toBe(201); }); });
Anti-Patterns
Domain Logic in Controllers
// Bad: Business logic in controller async create(req, res) { if (await this.db.query('SELECT * FROM users WHERE email = $1', [req.body.email])) { return res.status(409).json({ error: 'Email exists' }); } }
// Good: Delegate to use case async create(req, res) { const result = await this.createUserUseCase.execute(req.body); res.status(201).json(result); }
Infrastructure in Domain
// Bad: Infrastructure leak in entity class User { async save() { await prisma.user.create({ data: this }); } }
// Good: Repository handles persistence class User { /* pure domain */ } class UserRepository { async save(user: User) { await prisma.user.create(...); } }
Anemic Domain Model
// Bad: Entity is just data class User { id: string; password: string; } class UserService { changePassword(user: User, pwd: string) { user.password = hash(pwd); // Logic outside entity } }
// Good: Rich domain model class User { changePassword(newPassword: Password, hasher: PasswordHasher): void { if (!newPassword.isStrong()) throw new WeakPasswordError(); this.passwordHash = hasher.hash(newPassword); } }
Migration Path (Legacy → Clean Architecture)
-
Identify Boundaries: Find domain concepts
-
Extract Entities: Create domain objects with behavior
-
Define Interfaces: Create repository/port interfaces
-
Implement Adapters: Wrap existing data access
-
Create Use Cases: Extract business logic
-
Refactor Controllers: Delegate to use cases
-
Add DI Container: Wire dependencies
-
Write Tests: Cover each layer
Quick Checklist
-
Domain is infrastructure-free
-
All dependencies point inward
-
Use cases are thin orchestrators
-
Repository interfaces in domain
-
Implementations in infrastructure
-
Controllers delegate to use cases
-
DTOs at layer boundaries
-
Comprehensive test coverage (unit, integration, e2e)
-
DI container wires all dependencies
-
No anemic domain models