Implementing clean architecture solutions - a practical example

August 8, 2023 Published on Red Hat Developer
Clean Architecture Software Architecture Design Patterns Software Development Red Hat

Learn how to implement clean architecture principles through a practical, real-world example. Explore the benefits of separation of concerns, dependency inversion, and testable code structure.

Understanding Clean Architecture

Clean Architecture, popularized by Robert C. Martin (Uncle Bob), is an architectural pattern that emphasizes separation of concerns and dependency inversion. The goal is to create systems that are testable, maintainable, and independent of external frameworks, databases, and user interfaces.

Core Principles of Clean Architecture

  • Independence: Business logic is independent of frameworks, UI, and databases
  • Testability: Business rules can be tested without external dependencies
  • UI Independence: The user interface can change without affecting business logic
  • Database Independence: Business logic doesn't depend on specific database implementations
  • External Agency Independence: Business logic doesn't depend on external services

"Clean Architecture isn't about following rigid rules—it's about creating software that can evolve over time by keeping the most important business logic isolated from implementation details."

The Dependency Rule

The fundamental principle of Clean Architecture is the Dependency Rule: dependencies must point inward toward higher-level policies. Outer layers can depend on inner layers, but inner layers should never depend on outer layers.

Entities

Core business objects and rules

Use Cases

Application-specific business rules

Adapters

Interface between use cases and external systems

Frameworks

External tools, databases, web frameworks

Practical Example: E-commerce Order System

Let's implement a practical example of an e-commerce order processing system using Clean Architecture principles. This example will demonstrate how to structure code to achieve independence and testability.

1. Entities Layer: Core Business Objects

The entities layer contains the most fundamental business objects and rules:

Order Entity Example:
public class Order {
    private final OrderId id;
    private final CustomerId customerId;
    private final List<OrderItem> items;
    private OrderStatus status;
    private final Instant createdAt;
    
    public Order(CustomerId customerId, List<OrderItem> items) {
        this.id = OrderId.generate();
        this.customerId = customerId;
        this.items = List.copyOf(items);
        this.status = OrderStatus.PENDING;
        this.createdAt = Instant.now();
        
        validateOrder();
    }
    
    public void confirm() {
        if (status != OrderStatus.PENDING) {
            throw new IllegalStateException("Order must be pending to confirm");
        }
        this.status = OrderStatus.CONFIRMED;
    }
    
    public Money calculateTotal() {
        return items.stream()
            .map(OrderItem::getTotal)
            .reduce(Money.ZERO, Money::add);
    }
}

2. Use Cases Layer: Application Logic

Use cases encapsulate application-specific business rules and orchestrate entities:

Place Order Use Case:
public class PlaceOrderUseCase {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    private final InventoryService inventoryService;
    
    public PlaceOrderUseCase(OrderRepository orderRepository, 
                           PaymentService paymentService,
                           InventoryService inventoryService) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
        this.inventoryService = inventoryService;
    }
    
    public OrderResult execute(PlaceOrderCommand command) {
        // Validate inventory availability
        if (!inventoryService.isAvailable(command.getItems())) {
            return OrderResult.failure("Insufficient inventory");
        }
        
        // Create order entity
        Order order = new Order(command.getCustomerId(), command.getItems());
        
        // Process payment
        PaymentResult payment = paymentService.processPayment(
            command.getPaymentInfo(), order.calculateTotal());
            
        if (payment.isSuccess()) {
            order.confirm();
            orderRepository.save(order);
            return OrderResult.success(order.getId());
        }
        
        return OrderResult.failure("Payment failed");
    }
}

3. Interface Adapters Layer

This layer converts data between the use cases and external systems:

Repository Interface (defined in use cases layer):

public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(OrderId id);
    List<Order> findByCustomerId(CustomerId customerId);
}

Database Adapter Implementation:

@Repository
public class JpaOrderRepository implements OrderRepository {
    private final OrderJpaRepository jpaRepository;
    private final OrderMapper mapper;
    
    @Override
    public void save(Order order) {
        OrderEntity entity = mapper.toEntity(order);
        jpaRepository.save(entity);
    }
    
    @Override
    public Optional<Order> findById(OrderId id) {
        return jpaRepository.findById(id.getValue())
            .map(mapper::toDomain);
    }
}

Benefits of This Architecture

1. Testability

Business logic can be tested in isolation without external dependencies:

Testing Benefits:
  • Unit tests for entities don't require databases or frameworks
  • Use case tests can use mock implementations of interfaces
  • Fast test execution due to minimal dependencies
  • High test coverage achievable for business logic

2. Flexibility and Maintainability

The architecture supports changing requirements and technology choices:

  • Database technology can be changed without affecting business logic
  • User interface can be completely redesigned independently
  • External service integrations can be swapped or modified
  • Framework updates don't impact core business rules

3. Team Productivity

Clean separation enables teams to work independently on different layers:

  • Frontend teams can work independently of backend implementation
  • Backend teams can focus on business logic without UI concerns
  • Infrastructure teams can optimize data storage without affecting business rules
  • Clear interfaces enable parallel development

Implementation Patterns

Dependency Injection

Use dependency injection to implement the dependency inversion principle:

Configuration Example:
@Configuration
public class UseCaseConfiguration {
    
    @Bean
    public PlaceOrderUseCase placeOrderUseCase(
            OrderRepository orderRepository,
            PaymentService paymentService,
            InventoryService inventoryService) {
        return new PlaceOrderUseCase(
            orderRepository, paymentService, inventoryService);
    }
    
    @Bean
    public OrderRepository orderRepository(OrderJpaRepository jpaRepository) {
        return new JpaOrderRepository(jpaRepository, new OrderMapper());
    }
}

Controller Layer

Controllers handle HTTP requests and delegate to use cases:

REST Controller Example:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    private final PlaceOrderUseCase placeOrderUseCase;
    
    public OrderController(PlaceOrderUseCase placeOrderUseCase) {
        this.placeOrderUseCase = placeOrderUseCase;
    }
    
    @PostMapping
    public ResponseEntity<OrderResponse> placeOrder(@RequestBody OrderRequest request) {
        PlaceOrderCommand command = mapToCommand(request);
        OrderResult result = placeOrderUseCase.execute(command);
        
        if (result.isSuccess()) {
            return ResponseEntity.ok(new OrderResponse(result.getOrderId()));
        }
        
        return ResponseEntity.badRequest()
            .body(new OrderResponse(result.getError()));
    }
}

Common Implementation Challenges

1. Over-Engineering

Challenge: Creating too many abstraction layers

Solution: Start simple and add layers only when they provide clear value. Not every application needs full Clean Architecture implementation.

2. Performance Considerations

Challenge: Potential performance overhead from abstraction

Solution: Profile and measure actual performance impact. Optimize critical paths while maintaining architectural principles.

3. Team Alignment

Challenge: Getting team buy-in for architectural discipline

Solution: Provide training, document decisions, and demonstrate benefits through concrete examples and success metrics.

Testing Strategy

Unit Testing Entities

Entity tests focus on business rule validation:

Entity Test Example:
@Test
public void shouldCalculateOrderTotalCorrectly() {
    // Given
    List<OrderItem> items = Arrays.asList(
        new OrderItem(ProductId.of("PROD1"), Quantity.of(2), Money.of(10.00)),
        new OrderItem(ProductId.of("PROD2"), Quantity.of(1), Money.of(5.00))
    );
    
    // When
    Order order = new Order(CustomerId.of("CUST1"), items);
    
    // Then
    assertThat(order.calculateTotal()).isEqualTo(Money.of(25.00));
}

@Test
public void shouldNotAllowConfirmingNonPendingOrder() {
    // Given
    Order order = createConfirmedOrder();
    
    // When & Then
    assertThatThrownBy(() -> order.confirm())
        .isInstanceOf(IllegalStateException.class)
        .hasMessageContaining("Order must be pending to confirm");
}

Use Case Testing

Use case tests verify application logic with mocked dependencies:

Use Case Test Example:
@Test
public void shouldPlaceOrderSuccessfully() {
    // Given
    OrderRepository mockRepository = mock(OrderRepository.class);
    PaymentService mockPayment = mock(PaymentService.class);
    InventoryService mockInventory = mock(InventoryService.class);
    
    when(mockInventory.isAvailable(any())).thenReturn(true);
    when(mockPayment.processPayment(any(), any()))
        .thenReturn(PaymentResult.success());
    
    PlaceOrderUseCase useCase = new PlaceOrderUseCase(
        mockRepository, mockPayment, mockInventory);
    
    PlaceOrderCommand command = new PlaceOrderCommand(
        CustomerId.of("CUST1"), createOrderItems(), createPaymentInfo());
    
    // When
    OrderResult result = useCase.execute(command);
    
    // Then
    assertThat(result.isSuccess()).isTrue();
    verify(mockRepository).save(any(Order.class));
}

Real-World Adaptations

Microservices Architecture

Clean Architecture principles apply well to microservices:

  • Each microservice implements its own Clean Architecture layers
  • Inter-service communication happens through adapters
  • Business logic remains isolated from communication protocols
  • Services can evolve independently while maintaining contracts

Event-Driven Systems

Event-driven architectures benefit from Clean Architecture separation:

  • Event handlers are adapters that trigger use cases
  • Business logic doesn't depend on specific event technologies
  • Event publishing is handled by adapters, not use cases
  • Domain events can be generated by entities

Technology Stack Considerations

Framework Integration

Clean Architecture can be implemented with various technology stacks:

Java/Spring
  • Spring Boot for framework
  • JPA for database access
  • Spring Security for authentication
  • Spring Test for testing support
.NET/C#
  • ASP.NET Core for web framework
  • Entity Framework for data access
  • MediatR for use case orchestration
  • xUnit for testing framework
Node.js/TypeScript
  • Express.js for web framework
  • TypeORM for database access
  • ts-node for TypeScript execution
  • Jest for testing framework

Implementation Best Practices

1. Start with the Domain

Begin implementation with the core business entities and rules before building infrastructure:

  1. Identify core business concepts and entities
  2. Define business rules and validation logic
  3. Create use cases for application workflows
  4. Design interfaces for external dependencies
  5. Implement adapters and infrastructure last

2. Keep Interfaces Simple

Design interfaces that are focused and easy to implement:

  • Single responsibility for each interface
  • Minimal method signatures with clear purposes
  • Avoid leaking implementation details through interface design
  • Use domain types rather than primitive types

3. Embrace Immutability

Use immutable objects where possible to reduce complexity:

  • Value objects should be immutable
  • Entity state changes through explicit methods
  • Command and query objects as immutable data structures
  • Reduced side effects and easier testing

Monitoring and Observability

Business Metrics

Clean Architecture makes it easier to instrument business operations:

  • Use case execution metrics and timing
  • Business rule violation tracking
  • Entity lifecycle monitoring
  • Domain event publication and processing

Technical Metrics

  • Adapter performance and error rates
  • Database query performance
  • External service integration health
  • System resource utilization

Evolution and Refactoring

Gradual Migration

Existing applications can be gradually migrated to Clean Architecture:

  1. Extract business logic from existing controllers and services
  2. Create entity objects for core business concepts
  3. Implement use cases for specific workflows
  4. Replace direct database access with repository pattern
  5. Gradually move toward full architectural compliance

Continuous Refactoring

Maintain architectural integrity through ongoing refactoring:

  • Regular architecture reviews and assessments
  • Automated checks for dependency rule violations
  • Code organization and package structure maintenance
  • Documentation updates reflecting architectural decisions

When to Use Clean Architecture

Good Candidates

Ideal Scenarios:
  • Complex business logic that needs to be thoroughly tested
  • Long-lived applications that will evolve over time
  • Systems requiring high reliability and maintainability
  • Applications with multiple user interfaces or integration points
  • Teams that value code quality and architectural discipline

Consider Alternatives For

Less Suitable Scenarios:
  • Simple CRUD applications with minimal business logic
  • Prototype or proof-of-concept projects
  • Teams new to software architecture concepts
  • Projects with very tight deadlines
  • Applications with well-defined, stable requirements

Conclusion

Clean Architecture provides a powerful framework for building maintainable, testable, and flexible software systems. While it requires upfront investment in architectural discipline, the long-term benefits in code quality, team productivity, and system adaptability make it valuable for complex business applications.

Key Insight

The goal of Clean Architecture isn't perfect adherence to rules—it's creating software that can evolve over time. Focus on the principles rather than rigid implementation, and adapt the approach to fit your specific context and constraints.