Clean Architecture: Building Maintainable and Testable Applications

20. Mai 2025 · CodeMatic Team

Clean Architecture

Clean Architecture, popularized by Robert C. Martin, provides a way to structure applications that are independent of frameworks, UI, databases, and external agencies. This guide covers practical implementation of clean architecture principles in modern applications.

Core Principles

  • Independence: Business logic independent of frameworks and libraries
  • Testability: Business logic testable without UI, database, or external services
  • Independence of UI: UI can change without affecting business logic
  • Independence of Database: Can swap databases without changing business logic
  • Independence of External Agencies: Business rules don't depend on external systems

Architecture Layers

1. Domain Layer (Entities)

Core business logic and entities. No dependencies on other layers.

// domain/entities/User.ts
export class User {
  constructor(
    public readonly id: string,
    public readonly email: string,
    public readonly name: string,
    private _role: UserRole
  ) {}
  
  get role(): UserRole {
    return this._role;
  }
  
  promoteToAdmin(): void {
    if (this._role !== UserRole.USER) {
      throw new Error('Only regular users can be promoted');
    }
    this._role = UserRole.ADMIN;
  }
  
  canAccessResource(resource: Resource): boolean {
    return this._role === UserRole.ADMIN || 
           resource.ownerId === this.id;
  }
}

2. Application Layer (Use Cases)

Application-specific business rules. Orchestrates domain objects.

// application/use-cases/CreateUserUseCase.ts
export class CreateUserUseCase {
  constructor(
    private userRepository: IUserRepository,
    private emailService: IEmailService
  ) {}
  
  async execute(input: CreateUserInput): Promise {
    // Business logic
    if (await this.userRepository.exists(input.email)) {
      throw new Error('Email already exists');
    }
    
    const user = new User(
      generateId(),
      input.email,
      input.name,
      UserRole.USER
    );
    
    await this.userRepository.save(user);
    await this.emailService.sendWelcomeEmail(user.email);
    
    return user;
  }
}

3. Infrastructure Layer

Framework-specific implementations. Database, HTTP, file system, etc.

// infrastructure/repositories/PrismaUserRepository.ts
export class PrismaUserRepository implements IUserRepository {
  constructor(private prisma: PrismaClient) {}
  
  async save(user: User): Promise {
    await this.prisma.user.create({
      data: {
        id: user.id,
        email: user.email,
        name: user.name,
        role: user.role,
      },
    });
  }
  
  async findById(id: string): Promise {
    const data = await this.prisma.user.findUnique({ where: { id } });
    return data ? this.toDomain(data) : null;
  }
  
  private toDomain(data: UserData): User {
    return new User(data.id, data.email, data.name, data.role);
  }
}

4. Presentation Layer

UI and API controllers. Depends on application layer.

// presentation/controllers/UserController.ts
export class UserController {
  constructor(private createUserUseCase: CreateUserUseCase) {}
  
  async create(req: Request, res: Response) {
    try {
      const user = await this.createUserUseCase.execute({
        email: req.body.email,
        name: req.body.name,
      });
      
      res.status(201).json(this.toDTO(user));
    } catch (error) {
      res.status(400).json({ error: error.message });
    }
  }
  
  private toDTO(user: User): UserDTO {
    return {
      id: user.id,
      email: user.email,
      name: user.name,
    };
  }
}

Dependency Rule

Dependencies point inward. Outer layers depend on inner layers, never the reverse:

  • Presentation → Application → Domain
  • Infrastructure → Application → Domain
  • Domain has no dependencies

Dependency Inversion

High-level modules should not depend on low-level modules. Both should depend on abstractions.

// Domain defines interface
export interface IUserRepository {
  save(user: User): Promise;
  findById(id: string): Promise;
  exists(email: string): Promise;
}

// Application uses interface
export class CreateUserUseCase {
  constructor(private userRepository: IUserRepository) {}
  // ...
}

// Infrastructure implements interface
export class PrismaUserRepository implements IUserRepository {
  // ...
}

Project Structure

src/
  domain/
    entities/          # Business entities
    value-objects/     # Value objects
    interfaces/        # Repository interfaces
  application/
    use-cases/         # Business use cases
    dto/               # Data transfer objects
    interfaces/        # Service interfaces
  infrastructure/
    repositories/      # Database implementations
    services/          # External service implementations
    config/            # Configuration
  presentation/
    controllers/       # API controllers
    middleware/        # Express middleware
    routes/            # Route definitions
  shared/
    errors/            # Custom errors
    utils/             # Utilities

Testing Strategy

Unit Tests (Domain Layer)

describe('User', () => {
  it('should promote user to admin', () => {
    const user = new User('1', 'test@example.com', 'Test', UserRole.USER);
    user.promoteToAdmin();
    expect(user.role).toBe(UserRole.ADMIN);
  });
  
  it('should throw error when promoting non-user', () => {
    const user = new User('1', 'test@example.com', 'Test', UserRole.ADMIN);
    expect(() => user.promoteToAdmin()).toThrow();
  });
});

Integration Tests (Use Cases)

describe('CreateUserUseCase', () => {
  it('should create user successfully', async () => {
    const mockRepo = {
      exists: jest.fn().resolves(false),
      save: jest.fn().resolves(),
    };
    const mockEmail = { sendWelcomeEmail: jest.fn().resolves() };
    
    const useCase = new CreateUserUseCase(mockRepo, mockEmail);
    const user = await useCase.execute({
      email: 'test@example.com',
      name: 'Test User',
    });
    
    expect(user.email).toBe('test@example.com');
    expect(mockRepo.save).toHaveBeenCalled();
  });
});

Benefits of Clean Architecture

  • Maintainability: Clear separation makes code easier to understand and modify
  • Testability: Business logic can be tested without external dependencies
  • Flexibility: Easy to swap implementations (database, framework, UI)
  • Independence: Business rules don't depend on external concerns
  • Scalability: Well-organized code scales better

Real-World Implementation

We refactored a monolithic application to clean architecture:

  • Separated business logic from framework code
  • Created clear boundaries between layers
  • Implemented dependency inversion
  • Result: 70% test coverage, 50% faster development, easier maintenance

Best Practices

  • Keep domain layer pure (no framework dependencies)
  • Use interfaces for all external dependencies
  • Keep use cases focused and single-purpose
  • Don't leak infrastructure concerns into domain
  • Use dependency injection for flexibility
  • Start simple and add layers as needed

Conclusion

Clean Architecture provides a solid foundation for building maintainable applications. While it requires more upfront structure, the benefits in testability, maintainability, and flexibility make it worthwhile for complex applications. Start with clear layer separation and dependency inversion, then refine as your application grows.