Clean Architecture Domain-Driven Design Patterns
Clean architecture domain modeling creates software systems where business logic remains independent of frameworks, databases, and external concerns. Therefore, changes to infrastructure never ripple into your core business rules. As a result, applications become easier to test, maintain, and evolve over time.
The Dependency Rule and Layer Boundaries
The dependency rule states that source code dependencies must point inward, toward higher-level policies. Moreover, inner layers define interfaces that outer layers implement, inverting the traditional dependency direction. Consequently, the innermost layer has zero dependencies on frameworks, databases, or UI components.
Four concentric layers define the structure: entities at the center, use cases around them, interface adapters next, and frameworks at the outermost ring. Furthermore, each layer can only reference the layer directly inside it.
The concentric layers with the dependency rule directing inward
Entities and Clean Architecture Domain Logic
Entities encapsulate enterprise-wide business rules and critical logic that changes least frequently. Specifically, they contain the most general and high-level rules of the system. Additionally, entities remain pure Java objects with no framework annotations or infrastructure dependencies.
// Domain Entity - zero framework dependencies
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderLine> lines;
private OrderStatus status;
private Money totalAmount;
public Order(OrderId id, CustomerId customerId) {
this.id = Objects.requireNonNull(id);
this.customerId = Objects.requireNonNull(customerId);
this.lines = new ArrayList<>();
this.status = OrderStatus.DRAFT;
this.totalAmount = Money.ZERO;
}
public void addLine(Product product, Quantity quantity) {
if (status != OrderStatus.DRAFT) {
throw new DomainException("Cannot modify a submitted order");
}
OrderLine line = new OrderLine(product, quantity);
lines.add(line);
recalculateTotal();
}
public void submit() {
if (lines.isEmpty()) {
throw new DomainException("Cannot submit an empty order");
}
this.status = OrderStatus.SUBMITTED;
}
private void recalculateTotal() {
this.totalAmount = lines.stream()
.map(OrderLine::lineTotal)
.reduce(Money.ZERO, Money::add);
}
}
// Use Case - orchestrates entities
public class SubmitOrderUseCase {
private final OrderRepository orderRepo;
private final PaymentGateway paymentGateway;
private final EventPublisher eventPublisher;
public SubmitOrderUseCase(OrderRepository orderRepo,
PaymentGateway paymentGateway,
EventPublisher eventPublisher) {
this.orderRepo = orderRepo;
this.paymentGateway = paymentGateway;
this.eventPublisher = eventPublisher;
}
public OrderConfirmation execute(SubmitOrderCommand command) {
Order order = orderRepo.findById(command.orderId())
.orElseThrow(() -> new OrderNotFoundException(command.orderId()));
order.submit();
paymentGateway.authorize(order.totalAmount(), command.paymentMethod());
orderRepo.save(order);
eventPublisher.publish(new OrderSubmittedEvent(order.id()));
return new OrderConfirmation(order.id(), order.status());
}
}
// Port - interface defined by inner layer, implemented by outer
public interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order);
}
The use case layer orchestrates entities without knowing about the database or web framework. Therefore, you can test business logic with simple in-memory implementations of the repository interface.
Interface Adapters and Ports
Interface adapters convert data between the format used by use cases and the format needed by external agents. However, they must not contain business logic themselves. In contrast to service layer patterns, these adapters are thin translation layers.
Controllers map HTTP requests to use case commands. Additionally, repository implementations translate between entities and database rows using JPA or other persistence frameworks.
Interface adapters bridge inner layers and infrastructure
DDD Integration with Bounded Contexts
Bounded contexts map naturally onto modular architecture boundaries. Moreover, each bounded context gets its own set of entities, use cases, and adapters. For example, an e-commerce system might have separate Order, Inventory, and Shipping contexts with distinct models.
Context mapping defines how bounded contexts communicate. As a result, anti-corruption layers prevent one context's model from leaking into another, maintaining clean separation.
Bounded contexts with isolated models and anti-corruption layers
Related Reading:
Further Resources:
In conclusion, this modeling approach isolates business logic from infrastructure through the dependency rule. Therefore, adopt these patterns to build systems that are testable, flexible, and resilient to technology changes.