CLEAN Architecture: Building Maintainable Software
Deep dive into CLEAN Architecture principles, implementation patterns, and how to structure your applications for maximum maintainability and testability.

CLEAN Architecture: Building Maintainable Software
Clean Architecture, introduced by Robert C. Martin (Uncle Bob), is a software design philosophy that emphasizes separation of concerns and dependency inversion. This comprehensive guide explores how to implement Clean Architecture in modern applications.
What is Clean Architecture?
Clean Architecture is a software design approach that organizes code into layers with clear boundaries and dependencies flowing inward. The main goal is to create systems that are:
The Clean Architecture Layers
1. Entities (Enterprise Business Rules)
Entities encapsulate the most general and high-level rules:
// entities/user.entity.ts
export class User {
constructor(
private readonly id: string,
private readonly email: string,
private readonly name: string,
private readonly createdAt: Date
) {}
getId(): string {
return this.id;
}
getEmail(): string {
return this.email;
}
getName(): string {
return this.name;
}
isValidEmail(): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(this.email);
}
canBeDeleted(): boolean {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
return this.createdAt < thirtyDaysAgo;
}
}
2. Use Cases (Application Business Rules)
Use cases contain application-specific business rules:
// use-cases/create-user.use-case.ts
import { User } from '../entities/user.entity';
import { UserRepository } from '../repositories/user.repository';
import { EmailService } from '../services/email.service';
export interface CreateUserRequest {
email: string;
name: string;
}
export interface CreateUserResponse {
user: User;
success: boolean;
}
export class CreateUserUseCase {
constructor(
private readonly userRepository: UserRepository,
private readonly emailService: EmailService
) {}
async execute(request: CreateUserRequest): Promise {
// Validate input
if (!request.email || !request.name) {
throw new Error('Email and name are required');
}
// Check if user already exists
const existingUser = await this.userRepository.findByEmail(request.email);
if (existingUser) {
throw new Error('User already exists');
}
// Create new user
const user = new User(
this.generateId(),
request.email,
request.name,
new Date()
);
// Validate business rules
if (!user.isValidEmail()) {
throw new Error('Invalid email format');
}
// Save user
await this.userRepository.save(user);
// Send welcome email
await this.emailService.sendWelcomeEmail(user.getEmail(), user.getName());
return {
user,
success: true
};
}
private generateId(): string {
return Math.random().toString(36).substring(2, 15);
}
}
3. Interface Adapters
Interface adapters convert data between use cases and external layers:
// repositories/user.repository.ts
import { User } from '../entities/user.entity';
export interface UserRepository {
findById(id: string): Promise;
findByEmail(email: string): Promise;
save(user: User): Promise;
delete(id: string): Promise;
}
// controllers/user.controller.ts
import { CreateUserUseCase, CreateUserRequest } from '../use-cases/create-user.use-case';
export class UserController {
constructor(private readonly createUserUseCase: CreateUserUseCase) {}
async createUser(request: any): Promise {
try {
const createUserRequest: CreateUserRequest = {
email: request.body.email,
name: request.body.name
};
const response = await this.createUserUseCase.execute(createUserRequest);
return {
status: 201,
data: {
id: response.user.getId(),
email: response.user.getEmail(),
name: response.user.getName()
}
};
} catch (error) {
return {
status: 400,
error: error.message
};
}
}
}
4. Frameworks and Drivers
The outermost layer contains frameworks, databases, and external services:
// infrastructure/database/postgres-user.repository.ts
import { UserRepository } from '../../repositories/user.repository';
import { User } from '../../entities/user.entity';
export class PostgresUserRepository implements UserRepository {
constructor(private readonly connection: any) {}
async findById(id: string): Promise {
const result = await this.connection.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
return new User(row.id, row.email, row.name, row.created_at);
}
async findByEmail(email: string): Promise {
const result = await this.connection.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
return new User(row.id, row.email, row.name, row.created_at);
}
async save(user: User): Promise {
await this.connection.query(
'INSERT INTO users (id, email, name, created_at) VALUES ($1, $2, $3, $4)',
[user.getId(), user.getEmail(), user.getName(), new Date()]
);
}
async delete(id: string): Promise {
await this.connection.query('DELETE FROM users WHERE id = $1', [id]);
}
}
Dependency Injection
Implement dependency injection to manage dependencies:
// di/container.ts
export class Container {
private services = new Map();
register(name: string, factory: () => T): void {
this.services.set(name, factory);
}
resolve(name: string): T {
const factory = this.services.get(name);
if (!factory) {
throw new Error(Service ${name} not found
);
}
return factory();
}
}
// di/setup.ts
import { Container } from './container';
import { CreateUserUseCase } from '../use-cases/create-user.use-case';
import { PostgresUserRepository } from '../infrastructure/database/postgres-user.repository';
import { EmailService } from '../infrastructure/email/email.service';
export function setupContainer(): Container {
const container = new Container();
// Register repositories
container.register('UserRepository', () =>
new PostgresUserRepository(/* database connection */)
);
// Register services
container.register('EmailService', () =>
new EmailService(/* email configuration */)
);
// Register use cases
container.register('CreateUserUseCase', () =>
new CreateUserUseCase(
container.resolve('UserRepository'),
container.resolve('EmailService')
)
);
return container;
}
Testing Clean Architecture
Unit Testing Entities
// tests/entities/user.entity.test.ts
import { User } from '../../entities/user.entity';
describe('User Entity', () => {
it('should validate email correctly', () => {
const user = new User('1', 'test@example.com', 'Test User', new Date());
expect(user.isValidEmail()).toBe(true);
});
it('should reject invalid email', () => {
const user = new User('1', 'invalid-email', 'Test User', new Date());
expect(user.isValidEmail()).toBe(false);
});
it('should allow deletion after 30 days', () => {
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 31);
const user = new User('1', 'test@example.com', 'Test User', oldDate);
expect(user.canBeDeleted()).toBe(true);
});
});
Testing Use Cases
// tests/use-cases/create-user.use-case.test.ts
import { CreateUserUseCase } from '../../use-cases/create-user.use-case';
import { UserRepository } from '../../repositories/user.repository';
import { EmailService } from '../../services/email.service';
describe('CreateUserUseCase', () => {
let useCase: CreateUserUseCase;
let mockUserRepository: jest.Mocked;
let mockEmailService: jest.Mocked;
beforeEach(() => {
mockUserRepository = {
findById: jest.fn(),
findByEmail: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
};
mockEmailService = {
sendWelcomeEmail: jest.fn(),
};
useCase = new CreateUserUseCase(mockUserRepository, mockEmailService);
});
it('should create user successfully', async () => {
mockUserRepository.findByEmail.mockResolvedValue(null);
mockUserRepository.save.mockResolvedValue();
mockEmailService.sendWelcomeEmail.mockResolvedValue();
const request = {
email: 'test@example.com',
name: 'Test User'
};
const result = await useCase.execute(request);
expect(result.success).toBe(true);
expect(result.user.getEmail()).toBe('test@example.com');
expect(mockUserRepository.save).toHaveBeenCalled();
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalled();
});
it('should throw error if user already exists', async () => {
const existingUser = new User('1', 'test@example.com', 'Existing User', new Date());
mockUserRepository.findByEmail.mockResolvedValue(existingUser);
const request = {
email: 'test@example.com',
name: 'Test User'
};
await expect(useCase.execute(request)).rejects.toThrow('User already exists');
});
});
Clean Architecture with NestJS
Implementing Clean Architecture in NestJS:
// modules/user/user.module.ts
import { Module } from '@nestjs/common';
import { UserController } from './controllers/user.controller';
import { CreateUserUseCase } from './use-cases/create-user.use-case';
import { PostgresUserRepository } from './infrastructure/postgres-user.repository';
import { EmailService } from './infrastructure/email.service';
@Module({
controllers: [UserController],
providers: [
{
provide: 'UserRepository',
useClass: PostgresUserRepository,
},
{
provide: 'EmailService',
useClass: EmailService,
},
{
provide: CreateUserUseCase,
useFactory: (userRepository, emailService) =>
new CreateUserUseCase(userRepository, emailService),
inject: ['UserRepository', 'EmailService'],
},
],
})
export class UserModule {}
Benefits of Clean Architecture
1. Testability
Each layer can be tested independently:
// Easy to mock dependencies
const mockRepository = createMock();
const useCase = new CreateUserUseCase(mockRepository, mockEmailService);
2. Flexibility
Easy to swap implementations:
// Switch from PostgreSQL to MongoDB
container.register('UserRepository', () =>
new MongoUserRepository(/* mongo connection */)
);
3. Maintainability
Clear separation of concerns makes code easier to maintain and understand.
4. Framework Independence
Business logic is not tied to any specific framework.
Common Pitfalls
1. Over-Engineering
Don't apply Clean Architecture to simple CRUD applications:
// For simple operations, this might be overkill
class GetUserByIdUseCase {
async execute(id: string): Promise {
return this.userRepository.findById(id);
}
}
2. Anemic Domain Models
Avoid entities that only contain data without behavior:
// Bad: Anemic model
class User {
id: string;
email: string;
name: string;
}
// Good: Rich domain model
class User {
constructor(private id: string, private email: string, private name: string) {}
changeEmail(newEmail: string): void {
if (!this.isValidEmail(newEmail)) {
throw new Error('Invalid email');
}
this.email = newEmail;
}
}
3. Dependency Direction Violations
Always ensure dependencies point inward:
// Bad: Use case depends on infrastructure
import { PostgresUserRepository } from '../infrastructure/postgres-user.repository';
// Good: Use case depends on abstraction
import { UserRepository } from '../repositories/user.repository';
Best Practices
1. Start Simple
Begin with a simple structure and evolve as needed.
2. Focus on Business Logic
Keep business rules in entities and use cases.
3. Use Dependency Injection
Implement proper dependency injection for flexibility.
4. Write Tests
Test each layer independently with proper mocking.
5. Keep Interfaces Simple
Design interfaces that are easy to implement and test.
Conclusion
Clean Architecture provides a robust foundation for building maintainable, testable, and flexible applications. While it may seem complex initially, the benefits become apparent as applications grow in size and complexity.
Key takeaways:
Implement Clean Architecture in your next project and experience the benefits of well-structured, maintainable code!
Related Articles
Redis Integration in NestJS: Complete Guide
Learn how to integrate Redis with NestJS for caching, session management, and real-time applications. Complete with code examples and best practices.
How to Containerize NestJS Applications with Docker
Step-by-step guide to containerizing your NestJS applications using Docker. Includes multi-stage builds, optimization techniques, and deployment strategies.