Once the strategic boundaries have been drawn with Bounded Contexts, we need to build the domain model within each context. DDD's tactical design provides us with a set of implementation patterns—Value Objects, Entities, Aggregates, Repositories, Domain Services, and Domain Events—that allow us to render the business rules into robust and expressive code. This lesson is the most practical in the module: we will work with Java examples that you will see reflected in real projects. Mastering these patterns is what differentiates an anemic model (data without rules) from a rich model that protects the integrity of the business.

Contents

  1. Value Objects
  2. Entities
  3. Aggregates and aggregate root
  4. Repositories
  5. Domain services
  6. Domain events

  1. Value Objects

A Value Object is an object that is defined by its attributes, not by an identity. Two Value Objects with the same values are interchangeable. They are immutable: once created, they do not change.

Typical examples: an amount of money, a date, an address, a date range.

public final class Money {
    private final BigDecimal amount;
    private final String currency;

    public Money(BigDecimal amount, String currency) {
        if (amount == null || currency == null) {
            throw new IllegalArgumentException("Amount and currency are required");
        }
        this.amount = amount;
        this.currency = currency;
    }

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Cannot add different currencies");
        }
        return new Money(this.amount.add(other.amount), this.currency);  // returns a NEW object
    }

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

    @Override
    public int hashCode() { return Objects.hash(amount, currency); }
}

Key points about this Money:

  • The class is final and all its fields are final: it is immutable, there are no setters.
  • The add method does not modify the current object; it returns a new Money. This is the essence of immutability.
  • A business rule is included (do not add different currencies) within the object itself.
  • equals and hashCode are overridden based on the values: two Money objects of 10 EUR are equal even if they are different instances. This is what defines a Value Object.

Practical advantage: using Money instead of a loose BigDecimal avoids errors such as adding euros with dollars or passing the amount without the currency.

  1. Entities

An Entity is an object that has a unique and continuous identity over time, independent of its attributes. Even if all its data changes, it remains the same entity. A Policy, an Insured, or a Claim are entities: identified by their number/ID, not by their attributes.

public class Claim {
    private final ClaimId id;         // identity: never changes
    private ClaimStatus status;       // the attributes can change
    private Money claimedAmount;

    public Claim(ClaimId id, Money claimedAmount) {
        this.id = Objects.requireNonNull(id);
        this.claimedAmount = Objects.requireNonNull(claimedAmount);
        this.status = ClaimStatus.OPEN;
    }

    public void close() {
        if (status == ClaimStatus.CLOSED) {
            throw new IllegalStateException("The claim is already closed");
        }
        this.status = ClaimStatus.CLOSED;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Claim)) return false;
        return this.id.equals(((Claim) o).id);   // equality by IDENTITY
    }

    @Override
    public int hashCode() { return id.hashCode(); }
}

Differences from the Value Object:

  • The identity id is final and defines the entity. Even if status and claimedAmount change, it remains the same claim.
  • equals and hashCode are based only on the id, not on all the attributes. Two claims with the same id are the same, even if they differ in status.
  • The entity can mutate (close() changes the state), but always through methods that protect the rules.
Aspect Value Object Entity
Identity Has none; defined by its values Has its own stable ID
Mutability Immutable Mutable (in a controlled way)
Equality By values (equals over attributes) By identity (equals over the ID)
Examples Money, Date, Address Policy, Claim, Insured

  1. Aggregates and aggregate root

An Aggregate is a group of entities and Value Objects that are treated as a unit of consistency. It has a main entity called the aggregate root, which is the only entry point to the aggregate: all access from outside passes through it.

Fundamental rules of aggregates:

  • Only the root is referenced from outside. The internal objects are not exposed directly.
  • The root protects the invariants (rules that must always hold) of the entire aggregate.
  • An aggregate is saved and loaded whole, as a transactional unit.
  • Between aggregates, references are made by identity (ID), not by object.
// Policy is the ROOT of the aggregate; Coverage is an internal entity
public class Policy {
    private final PolicyId id;
    private final List<Coverage> coverages = new ArrayList<>();
    private Money totalPremium;

    // Access to the coverages ALWAYS goes through the root
    public void addCoverage(CoverageType type, Money capital) {
        if (coverages.size() >= 10) {
            throw new IllegalStateException("Maximum of 10 coverages per policy");
        }
        Coverage c = new Coverage(type, capital);
        coverages.add(c);
        recalculatePremium();       // the root maintains the coherence INVARIANT
    }

    private void recalculatePremium() {
        Money total = new Money(BigDecimal.ZERO, "EUR");
        for (Coverage c : coverages) {
            total = total.add(c.calculatePremium());
        }
        this.totalPremium = total;
    }

    // A read-only copy is exposed, never the mutable internal list
    public List<Coverage> getCoverages() {
        return Collections.unmodifiableList(coverages);
    }
}

Detailed analysis:

  • Policy is the root. Coverage is an internal entity that is not manipulated directly from outside.
  • To add a coverage, addCoverage() is used on the root, which applies the invariant "maximum of 10 coverages" and recalculates the premium. If we allowed the list to be modified from outside, that rule could be broken.
  • recalculatePremium() guarantees that the total premium is always coherent with the coverages: that is an invariant of the aggregate.
  • getCoverages() returns an unmodifiable list: the outside can read, but cannot alter the internal state while bypassing the rules.
graph TD
    subgraph Agregado_Poliza[Aggregate: Policy]
        R[Policy - ROOT] --> C1[Coverage 1]
        R --> C2[Coverage 2]
        R --> PT[totalPremium - Value Object]
    end
    EXT[External code] -->|only accesses the root| R

This diagram shows that the external code only "sees" the Policy root; the coverages and the premium are internal and protected.

Golden rule: a transaction modifies a single aggregate. If you need to change several, it is usually a sign that you should coordinate them with domain events (section 6), not in the same transaction.

  1. Repositories

A Repository provides the illusion of an in-memory collection of aggregate roots, hiding the persistence details (database, etc.). There is one repository per aggregate (per its root), not per each internal entity.

// The INTERFACE lives in the domain layer: it talks about Policy, not SQL
public interface PolicyRepository {
    Optional<Policy> findById(PolicyId id);
    void save(Policy policy);              // saves the COMPLETE aggregate
    List<Policy> findActiveOf(InsuredId insured);
}

Comments on the interface:

  • It lives in the domain and uses exclusively domain concepts (Policy, PolicyId). It does not mention JDBC, JPA, or SQL: the domain must not know how it is persisted.
  • save(Policy) persists the complete aggregate (the policy with its coverages), respecting that the aggregate is the unit of consistency.
  • It only exposes operations that make sense in the domain (find the active policies of an insured).
// The IMPLEMENTATION lives in the infrastructure layer
@Repository
public class PolicyRepositoryJpa implements PolicyRepository {
    private final EntityManager em;

    public PolicyRepositoryJpa(EntityManager em) { this.em = em; }

    @Override
    public Optional<Policy> findById(PolicyId id) {
        return Optional.ofNullable(em.find(Policy.class, id.value()));
    }

    @Override
    public void save(Policy policy) { em.merge(policy); }

    @Override
    public List<Policy> findActiveOf(InsuredId insured) {
        return em.createQuery(
                "SELECT p FROM Policy p WHERE p.insured = :a AND p.active = true",
                Policy.class)
            .setParameter("a", insured.value())
            .getResultList();
    }
}

What matters about this implementation:

  • It implements the domain interface but contains the technical details (JPA, EntityManager, JPQL). This separation is what allows the database to be changed without touching the domain.
  • It is a direct application of the dependency inversion principle: the domain defines the interface, the infrastructure implements it.

  1. Domain services

Sometimes a business operation does not naturally belong to any entity or Value Object. When an important piece of domain logic involves several aggregates or concepts, it is modeled as a Domain Service: a stateless object that exposes an operation with a business name.

public class PricingService {

    private final ActuarialTable table;

    public PricingService(ActuarialTable table) { this.table = table; }

    // Domain operation that does not fit in a single entity
    public Money calculatePremium(RiskProfile profile, CoverageType coverage) {
        BigDecimal factor = table.factorFor(profile.age(), profile.occupation());
        BigDecimal base = coverage.insuredCapital().amount();
        return new Money(base.multiply(factor), "EUR");
    }
}

Why this is a Domain Service and not a method of an entity:

  • Calculating the premium combines a RiskProfile, an ActuarialTable, and a CoverageType. It does not "belong" to any of them exclusively.
  • The service has no state of its own: it only orchestrates the calculation with the data it receives.
  • Its name, calculatePremium, is a verb from the Ubiquitous Language. It is not a generic "Helper" or "Utils".

Careful: do not overuse domain services. If all the logic ends up in services and the entities are left empty, you have returned to the anemic model. Use services only when the operation really does not fit in an entity.

  1. Domain events

A Domain Event represents something relevant that has occurred in the domain and that may be of interest to others. It is named in the past tense: PolicyUnderwritten, ClaimClosed. They allow different aggregates (even from different Bounded Contexts) to react without coupling directly.

// The event is immutable and describes a completed fact from the past
public final class PolicyUnderwritten {
    private final PolicyId policyId;
    private final InsuredId insuredId;
    private final Instant date;

    public PolicyUnderwritten(PolicyId policyId, InsuredId insuredId) {
        this.policyId = policyId;
        this.insuredId = insuredId;
        this.date = Instant.now();
    }
    // read-only getters...
}
// The root publishes the event when the fact occurs
public class Policy {
    private final List<Object> events = new ArrayList<>();

    public void underwrite(InsuredId insured) {
        // ... underwriting logic and invariant validation ...
        this.events.add(new PolicyUnderwritten(this.id, insured));  // records the event
    }

    public List<Object> pendingEvents() {
        return Collections.unmodifiableList(events);
    }
}

How it works and why it matters:

  • When underwrite() runs, the policy records a PolicyUnderwritten event instead of directly calling other modules.
  • After saving the aggregate, the infrastructure publishes those events. Other contexts (for example, Billing or Notifications) can subscribe and react (issue the first receipt, send the welcome message).
  • This decouples the aggregates: the Policy neither knows nor cares who reacts to its underwriting. It is the basis of event-driven architectures.

Represented as a flow:

sequenceDiagram
    participant U as User
    participant P as Policy (Underwriting)
    participant B as Billing
    participant N as Notifications
    U->>P: underwrite()
    P->>P: records PolicyUnderwritten
    P-->>B: PolicyUnderwritten
    P-->>N: PolicyUnderwritten
    B->>B: issues first receipt
    N->>N: sends welcome email

The sequence diagram shows how a single PolicyUnderwritten event triggers independent reactions in Billing and Notifications, without Underwriting knowing those details.

Common Mistakes and Tips

  • Aggregates that are too large. Putting half the database inside a single aggregate generates locks and performance problems. Keep aggregates small; reference other aggregates by ID.
  • Modifying several aggregates in one transaction. It breaks the consistency rule. Use domain events to coordinate changes between aggregates.
  • Repositories per internal entity. Only aggregate roots have a repository. Do not create a CoverageRepository if Coverage lives inside the Policy aggregate.
  • Mutable Value Objects. If you add setters to a Value Object, you lose its guarantees. Keep them immutable and return new copies in operations.
  • Overusing domain services. They empty the entities and the anemic model reappears. Always ask yourself whether the logic fits in an entity before creating a service.
  • Tip: start with the Value Objects. Replacing primitives (BigDecimal, String, int) with domain concepts (Money, Email, PolicyId) already greatly improves the expressiveness and safety of the model.

Exercises

Exercise 1. For each concept in a library's domain, decide whether you would model it as an Entity or as a Value Object, justifying it: (a) a physical copy of a book; (b) an ISBN; (c) a library user; (d) a loan period (start and end date).

Exercise 2. Given an Order aggregate that contains OrderLine, write the signature of a method on the root that adds a line while respecting the invariant "the order total cannot exceed 5,000 EUR". Explain why the method goes on the root and not on OrderLine.

Exercise 3. Identify which tactical pattern (Value Object, Entity, Domain Service, Domain Event, or Repository) you would use for each case: (a) save and retrieve orders; (b) represent a discount percentage; (c) transfer a balance between two accounts; (d) notify that "an order has been shipped".

Solutions

Solution 1. (a) Entity: each physical copy has its own identity (a specific one can be lost or damaged). (b) Value Object: an ISBN is defined by its value; two equal ISBNs are interchangeable. (c) Entity: the user has a stable identity even if their data changes. (d) Value Object: a period is immutable and is defined by its dates.

Solution 2. A valid signature: public void addLine(Product product, int quantity). The method goes on the Order root because the invariant "the total does not exceed 5,000 EUR" depends on all the lines together. A single OrderLine does not know the order total, so it cannot guarantee that rule; only the root, which sees the complete set, can do so.

Solution 3. (a) Repository (of the Order root). (b) Value Object (an immutable percentage, defined by its value). (c) Domain Service (the transfer involves two Account aggregates and does not belong to only one). (d) Domain Event (OrderShipped, a completed fact from the past).

Conclusion

In this lesson we have gone through DDD's tactical arsenal: the immutable Value Objects defined by their values, the Entities with stable identity, the Aggregates that group objects under a root that protects the invariants, the Repositories that hide persistence, the Domain Services for the logic that does not fit in an entity, and the Domain Events that decouple reactions between parts of the system. With these patterns you can build a rich model that is faithful to the business within each Bounded Context.

So far we have seen the contexts separately. In the last lesson of the module, "Context Mapping", we will study how the different Bounded Contexts relate to and integrate with each other through patterns such as ACL, Shared Kernel, or Open Host Service.

Application Architecture Course

Module 1: Fundamentals of Application Architecture

Module 2: Design Principles and Tactics

Module 3: Architectural Styles and Patterns

Module 4: Distributed Architectures and Microservices

Module 5: Event-Driven Architectures and Messaging

Module 6: Domain-Driven Design (DDD)

Module 7: Data and Persistence

Module 8: Cloud Architecture and Deployment

Module 9: Quality, Security and Observability

Module 10: Evolution, Governance and Case Studies

© Copyright 2026. All rights reserved