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
- Value Objects
- Entities
- Aggregates and aggregate root
- Repositories
- Domain services
- Domain events
- 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
finaland all its fields arefinal: it is immutable, there are no setters. - The
addmethod does not modify the current object; it returns a newMoney. This is the essence of immutability. - A business rule is included (do not add different currencies) within the object itself.
equalsandhashCodeare overridden based on the values: twoMoneyobjects 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.
- 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
idisfinaland defines the entity. Even ifstatusandclaimedAmountchange, it remains the same claim. equalsandhashCodeare based only on theid, not on all the attributes. Two claims with the sameidare 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 |
- 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:
Policyis the root.Coverageis 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| RThis 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.
- 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.
- 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, anActuarialTable, and aCoverageType. 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.
- 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 aPolicyUnderwrittenevent 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
Policyneither 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 emailThe 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
CoverageRepositoryifCoveragelives inside thePolicyaggregate. - 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
- What Is Application Architecture?
- The Role of the Software Architect
- Quality Attributes and Non-Functional Requirements
- Architectural Decisions and Trade-offs
- Architecture Documentation: Views and the C4 Model
Module 2: Design Principles and Tactics
- Coupling, Cohesion and Separation of Concerns
- SOLID Principles Applied to Architecture
- DRY, KISS, YAGNI and Other Design Principles
- Architectural Tactics for Quality Attributes
- Managing Technical Debt
Module 3: Architectural Styles and Patterns
- Monolithic Architecture
- Layered Architecture (N-Tier)
- Client-Server Architecture
- Hexagonal Architecture (Ports and Adapters)
- Clean and Onion Architecture
Module 4: Distributed Architectures and Microservices
- Introduction to Distributed Systems
- Microservices Architecture
- Service Decomposition and Bounded Contexts
- API Gateway, Service Discovery and Inter-Service Communication
- Resilience Patterns: Circuit Breaker, Retry and Bulkhead
- The CAP Theorem and Data Consistency
Module 5: Event-Driven Architectures and Messaging
- Fundamentals of Event-Driven Architecture
- Asynchronous Messaging: Queues and Brokers
- Event Patterns: Event Sourcing and CQRS
- Managing Distributed Transactions: The Saga Pattern
- Real-Time Data Streaming
Module 6: Domain-Driven Design (DDD)
- Core DDD Concepts
- Strategic Design: Bounded Contexts and Ubiquitous Language
- Tactical Design: Entities, Aggregates and Repositories
- Context Mapping
Module 7: Data and Persistence
- Persistence Strategies: SQL vs NoSQL
- Data Access Patterns: Repository, Unit of Work and DAO
- Database per Service and Distributed Data Management
- Caching and Invalidation Strategies
Module 8: Cloud Architecture and Deployment
- Cloud Computing Fundamentals (IaaS, PaaS, SaaS)
- Containers and Orchestration with Docker and Kubernetes
- Serverless Architecture
- Cloud-Native Design Patterns
- Infrastructure as Code (IaC)
Module 9: Quality, Security and Observability
- Scalability: Horizontal vs Vertical and Load Balancing
- High Availability and Fault Tolerance
- Security by Design and Authentication/Authorization
- Observability: Logging, Metrics and Tracing
- Performance and Load Testing
