Skip to content

Clean Architecture

Introduction

Clean Architecture is a software design philosophy introduced by Robert C. Martin (Uncle Bob) that organizes code into concentric layers with a strict dependency rule: source code dependencies must point inward, toward higher-level policies. It produces systems that are independent of frameworks, testable without UI or databases, and adaptable to changing requirements. Understanding Clean Architecture is essential for building enterprise applications that remain maintainable as they grow in complexity.

Core Concepts

The Dependency Rule

The fundamental principle of Clean Architecture is the Dependency Rule: code in an inner layer must never reference code in an outer layer. Inner layers define abstractions (interfaces), and outer layers provide concrete implementations. This inversion of control ensures that your core business logic is never contaminated by infrastructure details.

The Four Layers

1. Entities (Enterprise Business Rules)

Entities encapsulate the most general, high-level business rules. They are plain objects that represent core domain concepts and are least likely to change when something external changes. An entity can be a class with methods or a set of data structures and functions.

2. Use Cases (Application Business Rules)

Use cases contain application-specific business logic. They orchestrate the flow of data to and from entities and direct those entities to use their enterprise-wide business rules. Use cases define what the system does, not how it does it.

3. Interface Adapters

This layer converts data from the format most convenient for use cases and entities to the format most convenient for external agencies like databases or the web. Controllers, presenters, gateways, and repository implementations live here.

4. Frameworks & Drivers

The outermost layer contains frameworks, tools, and delivery mechanisms — web frameworks, database drivers, HTTP clients, message queues. This layer contains glue code that connects to the next inner circle.

Data Flow vs. Dependency Direction

A critical distinction in Clean Architecture is that while dependencies point inward, data can flow in any direction. A request enters from the outer layer, passes inward to the use case, then results flow back outward. The trick is that inner layers define the interfaces that outer layers implement.

Boundary Interfaces

Boundaries are the contracts between layers. The use case layer defines Input Ports (what it accepts) and Output Ports (what it needs from outer layers). Outer layers implement these ports.

Implementation: Order Processing System

Let's build a complete order processing system using Clean Architecture in Java.

Project Structure

Layer 1: Entities (Domain Layer)

java
// domain/entity/Order.java
package com.example.cleanarch.domain.entity;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;

public class Order {
    private final String id;
    private final String customerId;
    private final List<OrderItem> items;
    private OrderStatus status;
    private final LocalDateTime createdAt;

    public Order(String customerId) {
        this.id = UUID.randomUUID().toString();
        this.customerId = customerId;
        this.items = new ArrayList<>();
        this.status = OrderStatus.CREATED;
        this.createdAt = LocalDateTime.now();
    }

    // Enterprise business rule: orders must have at least one item
    public void addItem(OrderItem item) {
        if (item.getQuantity() <= 0) {
            throw new DomainException("Item quantity must be positive");
        }
        if (item.getPrice().compareTo(BigDecimal.ZERO) <= 0) {
            throw new DomainException("Item price must be positive");
        }
        this.items.add(item);
    }

    // Enterprise business rule: calculate total with potential discount
    public BigDecimal calculateTotal() {
        BigDecimal subtotal = items.stream()
                .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        // Business rule: 10% discount for orders over $100
        if (subtotal.compareTo(new BigDecimal("100.00")) > 0) {
            return subtotal.multiply(new BigDecimal("0.90"));
        }
        return subtotal;
    }

    // Enterprise business rule: only CREATED orders can be confirmed
    public void confirm() {
        if (this.status != OrderStatus.CREATED) {
            throw new DomainException(
                "Cannot confirm order in status: " + this.status);
        }
        if (this.items.isEmpty()) {
            throw new DomainException("Cannot confirm order with no items");
        }
        this.status = OrderStatus.CONFIRMED;
    }

    public void cancel() {
        if (this.status == OrderStatus.SHIPPED) {
            throw new DomainException("Cannot cancel shipped order");
        }
        this.status = OrderStatus.CANCELLED;
    }

    // Getters
    public String getId() { return id; }
    public String getCustomerId() { return customerId; }
    public List<OrderItem> getItems() { return Collections.unmodifiableList(items); }
    public OrderStatus getStatus() { return status; }
    public LocalDateTime getCreatedAt() { return createdAt; }
}
java
// domain/entity/OrderItem.java
package com.example.cleanarch.domain.entity;

import java.math.BigDecimal;

public class OrderItem {
    private final String productId;
    private final String productName;
    private final int quantity;
    private final BigDecimal price;

    public OrderItem(String productId, String productName,
                     int quantity, BigDecimal price) {
        this.productId = productId;
        this.productName = productName;
        this.quantity = quantity;
        this.price = price;
    }

    public String getProductId() { return productId; }
    public String getProductName() { return productName; }
    public int getQuantity() { return quantity; }
    public BigDecimal getPrice() { return price; }
}
java
// domain/entity/OrderStatus.java
package com.example.cleanarch.domain.entity;

public enum OrderStatus {
    CREATED, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}

// domain/entity/DomainException.java
package com.example.cleanarch.domain.entity;

public class DomainException extends RuntimeException {
    public DomainException(String message) {
        super(message);
    }
}

Layer 2: Use Cases (Application Layer)

java
// usecase/port/in/CreateOrderUseCase.java
package com.example.cleanarch.usecase.port.in;

import com.example.cleanarch.usecase.dto.CreateOrderRequest;
import com.example.cleanarch.usecase.dto.OrderResponse;

public interface CreateOrderUseCase {
    OrderResponse execute(CreateOrderRequest request);
}
java
// usecase/port/in/ConfirmOrderUseCase.java
package com.example.cleanarch.usecase.port.in;

import com.example.cleanarch.usecase.dto.OrderResponse;

public interface ConfirmOrderUseCase {
    OrderResponse execute(String orderId);
}
java
// usecase/port/out/OrderRepository.java
package com.example.cleanarch.usecase.port.out;

import com.example.cleanarch.domain.entity.Order;
import java.util.Optional;

public interface OrderRepository {
    Order save(Order order);
    Optional<Order> findById(String id);
}
java
// usecase/port/out/NotificationPort.java
package com.example.cleanarch.usecase.port.out;

public interface NotificationPort {
    void sendOrderConfirmation(String customerId, String orderId);
}
java
// usecase/dto/CreateOrderRequest.java
package com.example.cleanarch.usecase.dto;

import java.math.BigDecimal;
import java.util.List;

public record CreateOrderRequest(
        String customerId,
        List<ItemRequest> items
) {
    public record ItemRequest(
            String productId,
            String productName,
            int quantity,
            BigDecimal price
    ) {}
}
java
// usecase/dto/OrderResponse.java
package com.example.cleanarch.usecase.dto;

import java.math.BigDecimal;
import java.util.List;

public record OrderResponse(
        String orderId,
        String customerId,
        String status,
        BigDecimal total,
        List<ItemResponse> items
) {
    public record ItemResponse(
            String productId,
            String productName,
            int quantity,
            BigDecimal price
    ) {}
}
java
// usecase/interactor/CreateOrderInteractor.java
package com.example.cleanarch.usecase.interactor;

import com.example.cleanarch.domain.entity.Order;
import com.example.cleanarch.domain.entity.OrderItem;
import com.example.cleanarch.usecase.dto.CreateOrderRequest;
import com.example.cleanarch.usecase.dto.OrderResponse;
import com.example.cleanarch.usecase.mapper.OrderMapper;
import com.example.cleanarch.usecase.port.in.CreateOrderUseCase;
import com.example.cleanarch.usecase.port.out.OrderRepository;

public class CreateOrderInteractor implements CreateOrderUseCase {

    private final OrderRepository orderRepository;

    public CreateOrderInteractor(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Override
    public OrderResponse execute(CreateOrderRequest request) {
        // 1. Create domain entity
        Order order = new Order(request.customerId());

        // 2. Add items using domain logic
        for (CreateOrderRequest.ItemRequest item : request.items()) {
            order.addItem(new OrderItem(
                    item.productId(),
                    item.productName(),
                    item.quantity(),
                    item.price()
            ));
        }

        // 3. Persist through output port
        Order savedOrder = orderRepository.save(order);

        // 4. Map to response DTO
        return OrderMapper.toResponse(savedOrder);
    }
}
java
// usecase/interactor/ConfirmOrderInteractor.java
package com.example.cleanarch.usecase.interactor;

import com.example.cleanarch.domain.entity.Order;
import com.example.cleanarch.usecase.dto.OrderResponse;
import com.example.cleanarch.usecase.mapper.OrderMapper;
import com.example.cleanarch.usecase.port.in.ConfirmOrderUseCase;
import com.example.cleanarch.usecase.port.out.NotificationPort;
import com.example.cleanarch.usecase.port.out.OrderRepository;

public class ConfirmOrderInteractor implements ConfirmOrderUseCase {

    private final OrderRepository orderRepository;
    private final NotificationPort notificationPort;

    public ConfirmOrderInteractor(OrderRepository orderRepository,
                                   NotificationPort notificationPort) {
        this.orderRepository = orderRepository;
        this.notificationPort = notificationPort;
    }

    @Override
    public OrderResponse execute(String orderId) {
        // 1. Retrieve order
        Order order = orderRepository.findById(orderId)
                .orElseThrow(() ->
                    new IllegalArgumentException("Order not found: " + orderId));

        // 2. Execute domain business rule
        order.confirm();

        // 3. Persist updated state
        Order saved = orderRepository.save(order);

        // 4. Side effect: notification
        notificationPort.sendOrderConfirmation(
                saved.getCustomerId(), saved.getId());

        // 5. Return result
        return OrderMapper.toResponse(saved);
    }
}
java
// usecase/mapper/OrderMapper.java
package com.example.cleanarch.usecase.mapper;

import com.example.cleanarch.domain.entity.Order;
import com.example.cleanarch.usecase.dto.OrderResponse;

import java.util.stream.Collectors;

public class OrderMapper {
    public static OrderResponse toResponse(Order order) {
        var items = order.getItems().stream()
                .map(i -> new OrderResponse.ItemResponse(
                        i.getProductId(),
                        i.getProductName(),
                        i.getQuantity(),
                        i.getPrice()))
                .collect(Collectors.toList());

        return new OrderResponse(
                order.getId(),
                order.getCustomerId(),
                order.getStatus().name(),
                order.calculateTotal(),
                items
        );
    }
}

Layer 3: Interface Adapters

java
// adapter/in/web/OrderController.java
package com.example.cleanarch.adapter.in.web;

import com.example.cleanarch.domain.entity.DomainException;
import com.example.cleanarch.usecase.dto.CreateOrderRequest;
import com.example.cleanarch.usecase.dto.OrderResponse;
import com.example.cleanarch.usecase.port.in.ConfirmOrderUseCase;
import com.example.cleanarch.usecase.port.in.CreateOrderUseCase;

import java.util.Map;

/**
 * Simulates an HTTP controller — in a real app this could be
 * a Spring @RestController or AWS Lambda handler.
 */
public class OrderController {

    private final CreateOrderUseCase createOrderUseCase;
    private final ConfirmOrderUseCase confirmOrderUseCase;

    public OrderController(CreateOrderUseCase createOrderUseCase,
                           ConfirmOrderUseCase confirmOrderUseCase) {
        this.createOrderUseCase = createOrderUseCase;
        this.confirmOrderUseCase = confirmOrderUseCase;
    }

    public Map<String, Object> createOrder(CreateOrderRequest request) {
        try {
            OrderResponse response = createOrderUseCase.execute(request);
            return Map.of("status", 201, "body", response);
        } catch (DomainException e) {
            return Map.of("status", 400, "error", e.getMessage());
        } catch (Exception e) {
            return Map.of("status", 500, "error", "Internal server error");
        }
    }

    public Map<String, Object> confirmOrder(String orderId) {
        try {
            OrderResponse response = confirmOrderUseCase.execute(orderId);
            return Map.of("status", 200, "body", response);
        } catch (IllegalArgumentException e) {
            return Map.of("status", 404, "error", e.getMessage());
        } catch (DomainException e) {
            return Map.of("status", 400, "error", e.getMessage());
        }
    }
}
java
// adapter/out/persistence/InMemoryOrderRepository.java
package com.example.cleanarch.adapter.out.persistence;

import com.example.cleanarch.domain.entity.Order;
import com.example.cleanarch.usecase.port.out.OrderRepository;

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

public class InMemoryOrderRepository implements OrderRepository {

    private final Map<String, Order> store = new ConcurrentHashMap<>();

    @Override
    public Order save(Order order) {
        store.put(order.getId(), order);
        return order;
    }

    @Override
    public Optional<Order> findById(String id) {
        return Optional.ofNullable(store.get(id));
    }
}
java
// adapter/out/notification/ConsoleNotificationAdapter.java
package com.example.cleanarch.adapter.out.notification;

import com.example.cleanarch.usecase.port.out.NotificationPort;

public class ConsoleNotificationAdapter implements NotificationPort {

    @Override
    public void sendOrderConfirmation(String customerId, String orderId) {
        System.out.printf("[NOTIFICATION] Order %s confirmed for customer %s%n",
                orderId, customerId);
    }
}

Layer 4: Frameworks & Drivers (Composition Root)

java
// infrastructure/Application.java
package com.example.cleanarch.infrastructure;

import com.example.cleanarch.adapter.in.web.OrderController;
import com.example.cleanarch.adapter.out.notification.ConsoleNotificationAdapter;
import com.example.cleanarch.adapter.out.persistence.InMemoryOrderRepository;
import com.example.cleanarch.usecase.dto.CreateOrderRequest;
import com.example.cleanarch.usecase.interactor.ConfirmOrderInteractor;
import com.example.cleanarch.usecase.interactor.CreateOrderInteractor;
import com.example.cleanarch.usecase.port.out.NotificationPort;
import com.example.cleanarch.usecase.port.out.OrderRepository;

import java.math.BigDecimal;
import java.util.List;
import java.util.Map;

public class Application {

    public static void main(String[] args) {
        // === COMPOSITION ROOT: Wire all dependencies ===

        // Output adapters
        OrderRepository orderRepo = new InMemoryOrderRepository();
        NotificationPort notifier = new ConsoleNotificationAdapter();

        // Use case interactors
        var createOrder = new CreateOrderInteractor(orderRepo);
        var confirmOrder = new ConfirmOrderInteractor(orderRepo, notifier);

        // Input adapter (controller)
        var controller = new OrderController(createOrder, confirmOrder);

        // === SIMULATE HTTP REQUESTS ===

        // 1. Create an order
        var request = new CreateOrderRequest("customer-42", List.of(
                new CreateOrderRequest.ItemRequest(
                    "prod-1", "Mechanical Keyboard", 1, new BigDecimal("79.99")),
                new CreateOrderRequest.ItemRequest(
                    "prod-2", "USB-C Hub", 2, new BigDecimal("34.50"))
        ));

        Map<String, Object> createResult = controller.createOrder(request);
        System.out.println("Create Order: " + createResult);

        // Extract orderId from response
        var orderResponse =
            (com.example.cleanarch.usecase.dto.OrderResponse) createResult.get("body");
        String orderId = orderResponse.orderId();

        // 2. Confirm the order
        Map<String, Object> confirmResult = controller.confirmOrder(orderId);
        System.out.println("Confirm Order: " + confirmResult);

        // 3. Try to confirm again (should fail)
        Map<String, Object> doubleConfirm = controller.confirmOrder(orderId);
        System.out.println("Double Confirm: " + doubleConfirm);

        // 4. Try to confirm non-existent order
        Map<String, Object> notFound = controller.confirmOrder("invalid-id");
        System.out.println("Not Found: " + notFound);
    }
}

How Dependencies Flow

Testing Strategy

Clean Architecture makes testing natural because each layer can be tested in isolation.

java
// Test: Domain entity — zero dependencies
import com.example.cleanarch.domain.entity.*;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;

class OrderTest {

    @Test
    void shouldApplyDiscountForOrdersOver100() {
        Order order = new Order("cust-1");
        order.addItem(new OrderItem("p1", "Laptop Stand", 1,
                new BigDecimal("120.00")));

        // 10% discount: 120 * 0.90 = 108.00
        assertEquals(new BigDecimal("108.00"), order.calculateTotal());
    }

    @Test
    void shouldNotAllowConfirmingEmptyOrder() {
        Order order = new Order("cust-1");
        assertThrows(DomainException.class, order::confirm);
    }

    @Test
    void shouldNotAllowCancellingShippedOrder() {
        Order order = new Order("cust-1");
        order.addItem(new OrderItem("p1", "Mouse", 1,
                new BigDecimal("25.00")));
        order.confirm();
        // Would need to set status to SHIPPED for full test
        // This demonstrates the domain rule enforcement
    }
}
java
// Test: Use case with mock output ports
import com.example.cleanarch.usecase.interactor.CreateOrderInteractor;
import com.example.cleanarch.usecase.dto.*;
import com.example.cleanarch.usecase.port.out.OrderRepository;
import com.example.cleanarch.domain.entity.Order;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;

class CreateOrderInteractorTest {

    @Test
    void shouldCreateOrderSuccessfully() {
        // Arrange: simple mock repository
        OrderRepository mockRepo = new OrderRepository() {
            @Override
            public Order save(Order order) { return order; }
            @Override
            public Optional<Order> findById(String id) {
                return Optional.empty();
            }
        };

        var useCase = new CreateOrderInteractor(mockRepo);
        var request = new CreateOrderRequest("cust-1", List.of(
                new CreateOrderRequest.ItemRequest(
                    "p1", "Widget", 3, new BigDecimal("10.00"))
        ));

        // Act
        OrderResponse response = useCase.execute(request);

        // Assert
        assertEquals("cust-1", response.customerId());
        assertEquals("CREATED", response.status());
        assertEquals(new BigDecimal("30.00"), response.total());
        assertEquals(1, response.items().size());
    }
}

Clean Architecture vs. Other Patterns

AspectLayeredHexagonalCleanOnion
Dependency directionTop-downInward via portsStrict inwardInward
Explicit use casesNoNoYesNo
Framework independenceLowHighVery highHigh
Boundary definitionsImplicitPorts/AdaptersInput/Output portsInterfaces
TestabilityMediumHighVery highHigh

Swapping Implementations

One of the most powerful benefits: swapping an in-memory store for DynamoDB requires zero changes to use cases or entities.

java
// adapter/out/persistence/DynamoDBOrderRepository.java
package com.example.cleanarch.adapter.out.persistence;

import com.example.cleanarch.domain.entity.Order;
import com.example.cleanarch.usecase.port.out.OrderRepository;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;

import java.util.Map;
import java.util.Optional;

public class DynamoDBOrderRepository implements OrderRepository {

    private final DynamoDbClient dynamoDb;
    private static final String TABLE_NAME = "Orders";

    public DynamoDBOrderRepository(DynamoDbClient dynamoDb) {
        this.dynamoDb = dynamoDb;
    }

    @Override
    public Order save(Order order) {
        PutItemRequest request = PutItemRequest.builder()
                .tableName(TABLE_NAME)
                .item(Map.of(
                    "PK", AttributeValue.fromS("ORDER#" + order.getId()),
                    "customerId", AttributeValue.fromS(order.getCustomerId()),
                    "status", AttributeValue.fromS(order.getStatus().name()),
                    "total", AttributeValue.fromN(order.calculateTotal().toString())
                ))
                .build();

        dynamoDb.putItem(request);
        return order;
    }

    @Override
    public Optional<Order> findById(String id) {
        GetItemRequest request = GetItemRequest.builder()
                .tableName(TABLE_NAME)
                .key(Map.of("PK", AttributeValue.fromS("ORDER#" + id)))
                .build();

        GetItemResponse response = dynamoDb.getItem(request);
        if (!response.hasItem()) {
            return Optional.empty();
        }
        // Map DynamoDB item back to Order entity
        // (reconstruction logic would go here)
        return Optional.empty(); // Simplified
    }
}

Order State Machine

Best Practices

  1. Keep entities pure: Entities should contain only business rules and have zero dependencies on frameworks, databases, or external libraries. They are the most stable part of your system.

  2. Define boundaries with interfaces: Every cross-layer communication must go through an interface (port). Use cases define Input Ports and Output Ports; adapters implement them.

  3. DTOs at boundaries only: Use Data Transfer Objects to cross architectural boundaries. Never pass entities directly to controllers or return them from APIs.

  4. Composition root at the edge: Wire all dependencies in a single location (the main() method or a DI container configuration). This is the only place that knows about all layers.

  5. One use case per class: Each interactor should implement a single use case. This keeps classes small, focused, and independently testable, aligning with the Single Responsibility Principle.

  6. Favor constructor injection: All dependencies should be injected through constructors, making them explicit and enabling easy substitution in tests.

  7. Test from the inside out: Start with entity unit tests (no mocks needed), then use case tests (mock output ports only), then adapter integration tests, and finally end-to-end tests.

  8. Don't over-architect small projects: Clean Architecture adds structural complexity. For simple CRUD applications or prototypes, the overhead may not be justified. Apply it when business logic complexity warrants isolation.

  9. Map at boundaries: Create dedicated mapper classes to convert between domain entities, DTOs, and persistence models. This prevents leaky abstractions between layers.

  10. Guard the dependency rule in CI: Use tools like ArchUnit to enforce that inner layers never import from outer layers. Automate this check in your build pipeline.

Common Pitfalls