Architecture

CLEAN Architecture: Building Maintainable Software

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

January 5, 2024
15 min read
By Naveed Ullah
Clean ArchitectureSoftware DesignBest PracticesSOLIDDesign Patterns
CLEAN Architecture: Building Maintainable Software
Share this article:

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:

  • • **Independent of frameworks**
  • • **Testable**
  • • **Independent of UI**
  • • **Independent of databases**
  • • **Independent of external agencies**
  • 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:

  • • Separate business logic from infrastructure concerns
  • • Use dependency inversion to maintain flexibility
  • • Write comprehensive tests for each layer
  • • Start simple and evolve the architecture as needed
  • • Focus on business value and maintainability
  • Implement Clean Architecture in your next project and experience the benefits of well-structured, maintainable code!

    Naveed Ullah

    About Naveed Ullah

    Senior Next.js & NestJS Developer with 5+ years of experience building scalable applications