Pavan Rangani

HomeBlogClean Architecture Domain-Driven Design

Clean Architecture Domain-Driven Design

By Pavan Rangani · February 28, 2026 · Architecture

Clean Architecture Domain-Driven Design

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. Furthermore, this independence is not an academic ideal: it is what lets you swap a relational database for a document store, or migrate from one web framework to another, without rewriting the rules that actually define what your business does.

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 mechanism that makes this possible is dependency inversion: rather than the use case calling a concrete JPA repository, it calls an interface that it owns, and the JPA implementation lives in an outer ring that depends inward. As a result, the compiler itself enforces the boundary—if your domain package imports anything from the persistence package, the build should fail.

Software architecture layer diagram with dependency rule
The concentric layers with the dependency rule directing inward

Entities and Core Business 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. Importantly, an entity is not an anemic data bag with getters and setters; it owns the invariants that must always hold. In the example below, an Order refuses to accept new lines once it has been submitted, so the rule lives in one place instead of being scattered across every service that touches orders.

// 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. Notice what the use case is responsible for: it coordinates a sequence of steps, but it delegates the actual decision—whether an order may be submitted—to the entity. This separation keeps use cases thin and pushes complexity down into objects that can validate themselves.

Value Objects and Guarding Invariants

One of the most underused tools in domain modeling is the value object. Instead of passing primitives like BigDecimal amount and String currency around, you wrap them in a Money type that refuses to exist in an invalid state. Consequently, an illegal value cannot even be constructed, and entire categories of bugs—mismatched currencies, negative quantities, malformed identifiers—disappear at the type level rather than being caught by scattered runtime checks.

// Value Object - immutable and self-validating
public final class Money {
    public static final Money ZERO = new Money(BigDecimal.ZERO, Currency.getInstance("USD"));

    private final BigDecimal amount;
    private final Currency currency;

    public Money(BigDecimal amount, Currency currency) {
        if (amount.scale() > currency.getDefaultFractionDigits()) {
            throw new IllegalArgumentException("Too many decimal places for " + currency);
        }
        this.amount = Objects.requireNonNull(amount);
        this.currency = Objects.requireNonNull(currency);
    }

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new DomainException("Cannot add " + other.currency + " to " + currency);
        }
        return new Money(this.amount.add(other.amount), currency);
    }

    @Override public boolean equals(Object o) {
        if (!(o instanceof Money m)) return false;
        return amount.compareTo(m.amount) == 0 && currency.equals(m.currency);
    }
}

Because value objects are immutable and compared by value rather than identity, they are safe to share, easy to test, and impossible to corrupt after construction. As a result, the rest of the domain can trust that any Money it receives is already valid.

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. A common and costly mistake is to let JPA-annotated entities be your domain entities. When you do that, the persistence framework’s lifecycle—lazy loading, dirty checking, detached states—leaks into rules that should know nothing about it. Therefore, keep a separate persistence model and map between it and the domain object in the repository implementation, even though it feels like duplication at first.

Clean architecture domain interface adapters
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. Crucially, the same word can mean different things in different contexts: a “product” in the catalog context carries marketing copy and images, while a “product” in the shipping context is reduced to weight and dimensions. Trying to force one universal model across both is the root cause of bloated, contradictory classes.

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. When contexts integrate through events rather than direct calls, the coupling loosens further; the patterns in Event-Driven Architecture with Kafka Patterns describe how to propagate domain events across these boundaries reliably.

Bounded contexts in domain design
Bounded contexts with isolated models and anti-corruption layers

When the Overhead Is Not Worth It

Clean architecture is not free, and applying it everywhere is a mistake. The pattern trades upfront ceremony—extra interfaces, mapping code, and indirection—for long-term flexibility. For a simple CRUD service that mostly shuttles rows between a database and a JSON response, that flexibility may never pay off, and the layers become friction that slows every small change. In those cases, a straightforward transaction-script or active-record approach is honestly the better engineering choice.

The investment pays off, by contrast, when the domain is genuinely complex: when business rules are intricate, change often, and must be reasoned about in isolation. Therefore, judge each context on its own merits rather than mandating one style across an entire codebase. A pragmatic system might wrap its billing context in full clean architecture while leaving a reporting context as thin queries. The discipline lies in matching the structure to the complexity, not in applying the maximum ceremony uniformly. For guidance on packaging these decisions within a single deployable, see the Modular Monolith Architecture Guide.

Related Reading:

Further Resources:

In conclusion, clean architecture domain modeling 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—while remembering that the goal is matching structure to genuine complexity, not pursuing purity for its own sake.

← Back to all articles