clean-architecture
Clean Architecture and SOLID principles implementation including dependency injection, layer separation, domain-driven design, hexagonal architecture, and code quality patterns
Installation
Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.
View source ↗GuidesSkill instructions
Clean Architecture Skill
Comprehensive guide for implementing Clean Architecture, SOLID principles, and maintainable code structures.
When to Use This Skill
Activate this skill when:
- Designing new service architecture
- Refactoring legacy code to clean architecture
- Implementing dependency injection
- Defining domain boundaries
- Creating layer separation
- Applying SOLID principles
- Reviewing architectural decisions
- Setting up project structure
Clean Architecture Overview
The Dependency Rule
Dependencies must point inward. Inner layers must not know about outer layers.
┌─────────────────────────────────────────────────────────────┐
│ EXTERNAL LAYER │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ INFRASTRUCTURE LAYER │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ APPLICATION LAYER │ │ │
│ │ │ ┌─────────────────────────────────────────────────┐ │ │ │
│ │ │ │ DOMAIN LAYER │ │ │ │
│ │ │ │ (Entities, Value Objects) │ │ │ │
│ │ │ └─────────────────────────────────────────────────┘ │ │ │
│ │ │ (Use Cases, Application Services) │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ (Repositories, External Services, ORM) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ (Web Framework, Database, UI, External APIs) │
└─────────────────────────────────────────────────────────────┘
Dependencies point INWARD →
Layer Definitions
1. Domain Layer (Core)
The heart of the application. Contains:
- Entities: Business objects with identity
- Value Objects: Immutable objects without identity
- Domain Events: Events that occur in the domain
- Domain Services: Stateless operations on domain objects
- Repository Interfaces: Abstractions for data access
// src/domain/entities/user.entity.ts
export class User {
constructor(
public readonly id: UserId,
public email: Email,
public name: UserName,
private passwordHash: PasswordHash,
public readonly createdAt: Date
) {}
changePassword(newPassword: Password, hasher: PasswordHasher): void {
this.passwordHash = hasher.hash(newPassword);
}
validatePassword(password: Password, hasher: PasswordHasher): boolean {
return hasher.verify(password, this.passwordHash);
}
}
// src/domain/value-objects/email.vo.ts
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);
}
toString(): string {
return this.value;
}
equals(other: Email): boolean {
return this.value === other.value;
}
}
// src/domain/repository-interfaces/user.repository.ts
export interface UserRepository {
findById(id: UserId): Promise<User | null>;
findByEmail(email: Email): Promise<User | null>;
save(user: User): Promise<void>;
delete(id: UserId): Promise<void>;
}
2. Application Layer
Orchestrates domain objects to perform use cases:
- Use Cases: Single responsibility application operations
- Application Services: Coordinate multiple use cases
- DTOs: Data transfer objects for boundaries
- Ports: Interfaces for external dependencies
// src/application/use-cases/create-user.use-case.ts
export interface CreateUserInput {
email: string;
name: string;
password: string;
}
export interface CreateUserOutput {
id: string;
email: string;
name: string;
createdAt: Date;
}
export class CreateUserUseCase {
constructor(
private readonly userRepository: UserRepository,
private readonly passwordHasher: PasswordHasher,
private readonly eventEmitter: DomainEventEmitter
) {}
async execute(input: CreateUserInput): Promise<CreateUserOutput> {
// Validate email uniqueness
const existingUser = await this.userRepository.findByEmail(
Email.create(input.email)
);
if (existingUser) {
throw new EmailAlreadyExistsError(input.email);
}
// Create domain entity
const user = new User(
UserId.generate(),
Email.create(input.email),
UserName.create(input.name),
this.passwordHasher.hash(Password.create(input.password)),
new Date()
);
// Persist
await this.userRepository.save(user);
// Emit domain event
this.eventEmitter.emit(new UserCreatedEvent(user));
// Return DTO
return {
id: user.id.toString(),
email: user.email.toString(),
name: user.name.toString(),
createdAt: user.createdAt
};
}
}
3. Infrastructure Layer
Implements interfaces defined in inner layers:
- Repository Implementations: Database access
- External Service Adapters: Third-party integrations
- ORM Configurations: Database mappings
- Messaging: Queue/event implementations
// src/infrastructure/repositories/postgresql-user.repository.ts
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 findByEmail(email: Email): Promise<User | null> {
const row = await this.db.query(
'SELECT * FROM users WHERE email = $1',
[email.toString()]
);
return row ? this.toDomain(row) : null;
}
async save(user: User): Promise<void> {
await this.db.query(
`INSERT INTO users (id, email, name, password_hash, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
email = $2, name = $3, password_hash = $4`,
[user.id.toString(), user.email.toString(), user.name.toString(),
user.passwordHash, user.createdAt]
);
}
private toDomain(row: UserRow): User {
return new User(
UserId.fromString(row.id),
Email.create(row.email),
UserName.create(row.name),
PasswordHash.fromString(row.password_hash),
row.created_at
);
}
}
4. External/Presentation Layer
Entry points to the application:
- Controllers: HTTP request handlers
- CLI: Command-line interfaces
- GraphQL Resolvers: GraphQL handlers
- Message Handlers: Queue consumers
// src/presentation/http/controllers/user.controller.ts
export class UserController {
constructor(
private readonly createUserUseCase: CreateUserUseCase,
private readonly getUserUseCase: GetUserUseCase
) {}
async create(req: Request, res: Response): Promise<void> {
try {
const result = await this.createUserUseCase.execute({
email: req.body.email,
name: req.body.name,
password: req.body.password
});
res.status(201).json(result);
} catch (error) {
if (error instanceof EmailAlreadyExistsError) {
res.status(409).json({ error: error.message });
} else if (error instanceof ValidationError) {
res.status(400).json({ error: error.message });
} else {
throw error;
}
}
}
}
Project Structure
Recommended Directory Structure
src/
├── domain/ # Domain Layer (Core)
│ ├── entities/
│ │ ├── user.entity.ts
│ │ └── order.entity.ts
│ ├── value-objects/
│ │ ├── email.vo.ts
│ │ ├── money.vo.ts
│ │ └── user-id.vo.ts
│ ├── events/
│ │ ├── user-created.event.ts
│ │ └── order-placed.event.ts
│ ├── services/
│ │ └── pricing.domain-service.ts
│ ├── repositories/ # Interfaces only!
│ │ ├── user.repository.ts
│ │ └── order.repository.ts
│ └── errors/
│ ├── domain-error.ts
│ └── validation-error.ts
│
├── application/ # Application Layer
│ ├── use-cases/
│ │ ├── user/
│ │ │ ├── create-user.use-case.ts
│ │ │ ├── get-user.use-case.ts
│ │ │ └── update-user.use-case.ts
│ │ └── order/
│ │ ├── create-order.use-case.ts
│ │ └── cancel-order.use-case.ts
│ ├── services/
│ │ └── notification.service.ts
│ ├── ports/ # Secondary ports
│ │ ├── email.port.ts
│ │ └── payment.port.ts
│ └── dto/
│ ├── user.dto.ts
│ └── order.dto.ts
│
├── infrastructure/ # Infrastructure Layer
│ ├── repositories/
│ │ ├── postgresql-user.repository.ts
│ │ └── postgresql-order.repository.ts
│ ├── adapters/
│ │ ├── sendgrid-email.adapter.ts
│ │ └── stripe-payment.adapter.ts
│ ├── orm/
│ │ ├── prisma/
│ │ │ └── schema.prisma
│ │ └── migrations/
│ ├── messaging/
│ │ ├── rabbitmq-publisher.ts
│ │ └── rabbitmq-consumer.ts
│ └── config/
│ └── database.config.ts
│
├── presentation/ # Presentation Layer
│ ├── http/
│ │ ├── controllers/
│ │ │ ├── user.controller.ts
│ │ │ └── order.controller.ts
│ │ ├── middleware/
│ │ │ ├── auth.middleware.ts
│ │ │ └── error-handler.middleware.ts
│ │ ├── routes/
│ │ │ └── index.ts
│ │ └── validators/
│ │ └── user.validator.ts
│ ├── graphql/
│ │ ├── resolvers/
│ │ └── schema/
│ └── cli/
│ └── commands/
│
├── shared/ # Cross-cutting concerns
│ ├── kernel/
│ │ ├── result.ts
│ │ └── either.ts
│ └── utils/
│ └── date.utils.ts
│
└── container/ # Dependency Injection
├── container.ts
└── providers/
├── user.provider.ts
└── order.provider.ts
Dependency Injection
Container Setup
// src/container/container.ts
import { Container } from 'inversify';
import { TYPES } from './types';
// Domain
import { UserRepository } from '@/domain/repositories/user.repository';
// Application
import { CreateUserUseCase } from '@/application/use-cases/user/create-user.use-case';
// Infrastructure
import { PostgreSQLUserRepository } from '@/infrastructure/repositories/postgresql-user.repository';
// Presentation
import { UserController } from '@/presentation/http/controllers/user.controller';
const container = new Container();
// Bind repositories (interface → implementation)
container.bind<UserRepository>(TYPES.UserRepository)
.to(PostgreSQLUserRepository)
.inSingletonScope();
// Bind use cases
container.bind<CreateUserUseCase>(TYPES.CreateUserUseCase)
.to(CreateUserUseCase)
.inTransientScope();
// Bind controllers
container.bind<UserController>(TYPES.UserController)
.to(UserController)
.inTransientScope();
export { container };
Type Symbols
// src/container/types.ts
export const TYPES = {
// Repositories
UserRepository: Symbol.for('UserRepository'),
OrderRepository: Symbol.for('OrderRepository'),
// Use Cases
CreateUserUseCase: Symbol.for('CreateUserUseCase'),
GetUserUseCase: Symbol.for('GetUserUseCase'),
// Adapters
EmailAdapter: Symbol.for('EmailAdapter'),
PaymentAdapter: Symbol.for('PaymentAdapter'),
// Controllers
UserController: Symbol.for('UserController'),
OrderController: Symbol.for('OrderController'),
};
SOLID in Clean Architecture
Single Responsibility
Each layer has one responsibility:
- Domain: Business rules
- Application: Use case orchestration
- Infrastructure: Technical concerns
- Presentation: User interface
Open/Closed
Add features by adding new use cases, not modifying existing ones:
// Add new feature: UpdateUserUseCase
// Don't modify: CreateUserUseCase
export class UpdateUserUseCase { /* ... */ }
Liskov Substitution
Repository implementations are fully substitutable:
// Both work with UserRepository interface
const postgresRepo: UserRepository = new PostgreSQLUserRepository(db);
const mongoRepo: UserRepository = new MongoUserRepository(client);
const memoryRepo: UserRepository = new InMemoryUserRepository();
Interface Segregation
Small, focused interfaces:
// BAD: Fat interface
interface UserService {
create(data): User;
update(id, data): User;
delete(id): void;
sendEmail(id): void;
generateReport(id): Report;
exportToCSV(id): string;
}
// GOOD: Segregated
interface UserCreator { create(data): User; }
interface UserUpdater { update(id, data): User; }
interface UserDeleter { delete(id): void; }
interface UserEmailer { sendEmail(id): void; }
Dependency Inversion
All dependencies point to abstractions:
// Application layer defines the port (interface)
export interface EmailPort {
send(to: string, subject: string, body: string): Promise<void>;
}
// Infrastructure implements the adapter
export class SendGridEmailAdapter implements EmailPort {
async send(to: string, subject: string, body: string): Promise<void> {
await this.sendgrid.send({ to, subject, text: body });
}
}
// Use case depends on abstraction, not implementation
export class CreateUserUseCase {
constructor(private readonly emailPort: EmailPort) {}
}
Testing Strategy
Unit Tests (Domain & Application)
// Test domain logic without infrastructure
describe('User', () => {
it('should validate password correctly', () => {
const hasher = new BCryptHasher();
const password = Password.create('SecureP@ss1');
const user = new User(
UserId.generate(),
Email.create('test@example.com'),
UserName.create('Test User'),
hasher.hash(password),
new Date()
);
expect(user.validatePassword(password, hasher)).toBe(true);
expect(user.validatePassword(Password.create('wrong'), hasher)).toBe(false);
});
});
// Test use cases with mock repositories
describe('CreateUserUseCase', () => {
it('should create user successfully', async () => {
const mockRepo = {
findByEmail: jest.fn().mockResolvedValue(null),
save: jest.fn().mockResolvedValue(undefined)
};
const mockHasher = { hash: jest.fn().mockReturnValue('hashed') };
const mockEmitter = { emit: jest.fn() };
const useCase = new CreateUserUseCase(mockRepo, mockHasher, mockEmitter);
const result = await useCase.execute({
email: 'test@example.com',
name: 'Test User',
password: 'password123'
});
expect(result.email).toBe('test@example.com');
expect(mockRepo.save).toHaveBeenCalled();
expect(mockEmitter.emit).toHaveBeenCalled();
});
});
Integration Tests (Infrastructure)
describe('PostgreSQLUserRepository', () => {
let repository: PostgreSQLUserRepository;
let db: Database;
beforeAll(async () => {
db = await createTestDatabase();
repository = new PostgreSQLUserRepository(db);
});
afterAll(async () => {
await db.close();
});
it('should save and retrieve user', async () => {
const user = createTestUser();
await repository.save(user);
const retrieved = await repository.findById(user.id);
expect(retrieved).not.toBeNull();
expect(retrieved.email.equals(user.email)).toBe(true);
});
});
E2E Tests (Full Stack)
describe('User API', () => {
it('should create user via HTTP', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
name: 'Test User',
password: 'password123'
});
expect(response.status).toBe(201);
expect(response.body.email).toBe('test@example.com');
});
});
Anti-Patterns to Avoid
1. Domain Logic in Controllers
// BAD
class UserController {
async create(req, res) {
// Business logic in controller!
if (await this.db.query('SELECT * FROM users WHERE email = $1', [req.body.email])) {
return res.status(409).json({ error: 'Email exists' });
}
const hash = await bcrypt.hash(req.body.password, 10);
await this.db.query('INSERT INTO users...');
}
}
// GOOD
class UserController {
async create(req, res) {
const result = await this.createUserUseCase.execute(req.body);
res.status(201).json(result);
}
}
2. Infrastructure in Domain
// BAD
class User {
async save() {
await prisma.user.create({ data: this }); // Infrastructure leak!
}
}
// GOOD
class User {
// Pure domain logic, no infrastructure
}
// Repository handles persistence
class UserRepository {
async save(user: User) {
await prisma.user.create({ data: user.toDTO() });
}
}
3. Anemic Domain Model
// BAD - No behavior, just data
class User {
id: string;
email: string;
password: string;
}
class UserService {
changePassword(user: User, newPassword: string) {
user.password = hash(newPassword); // Logic outside entity
}
}
// GOOD - Rich domain model
class User {
constructor(private readonly id: UserId, private passwordHash: PasswordHash) {}
changePassword(newPassword: Password, hasher: PasswordHasher): void {
if (!newPassword.isStrong()) {
throw new WeakPasswordError();
}
this.passwordHash = hasher.hash(newPassword);
}
}
Migration Strategy
Legacy to Clean Architecture
- Identify Boundaries: Find domain concepts
- Extract Entities: Create domain objects
- Define Interfaces: Create repository 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
Related Resources
Examples
Read the usage examples in the source repository.
View source ↗Source history
- Indexed on
- 2026-01-20
- Index updated
- 2026-01-31
- Branch
- main
Index and sync dates describe this directory. View the source repository for code history.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!