20.2 Tactical Modeling: Entities, Value Objects, Aggregates, and Repositories
The goal of tactical modeling is to assign each business invariant a single, executable ownership within the model. It's not true that every table maps directly to an Entity, nor that every related object should be grouped into a single Aggregate.
Entity Defined by Identity and Lifecycle
The examples use Java 21; save each public type in its own matching file. PlayerId is a project-provided immutable identifier. The next lesson defines the registration event, and the project supplies business exceptions.
An entity remains the same entity even when its properties change. Its identity must be stable:
import java.util.Objects;
import java.util.UUID;
public record TournamentId(UUID value) {
public TournamentId {
Objects.requireNonNull(value);
}
}Do not generate an entity's equals/hashCode from all mutable fields, as modifying those fields after the object is added to a collection will break lookup. Entities can define equality by type and stable identity, or use reference identity within a controlled unit of work. This example leaves Tournament with default reference equality. TournamentId is itself a value object comparing UUIDs, not an entity.
Value Object Defined by Value
Value objects do not have independent identity and are well-suited for expressing measured quantities and concepts with rules:
public record Capacity(int value) {
public Capacity {
if (value < 2 || value > 1024) {
throw new IllegalArgumentException("capacity must be 2..1024");
}
}
public boolean canFit(int confirmedEntries) {
if (confirmedEntries < 0) {
throw new IllegalArgumentException("confirmed count must be nonnegative");
}
return confirmedEntries < value;
}
}The capacity range 2 to 1024 is a rule for this tournament example, not a DDD requirement. canFit asks whether one more player fits and rejects negative counts.
Prefer immutable value objects, satisfying constraints at creation time. They preserve units and business meaning more effectively than passing around raw int everywhere.
Aggregate is a Consistency Boundary
An aggregate is a group of objects that are maintained consistently within a single transaction. The aggregate root is the sole entry point for external modifications to these objects. This example creates a new tournament and registers players; TournamentStatus is OPEN or CLOSED. Status transitions and database rehydration are outside this example.
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
public final class Tournament {
private final TournamentId id;
private final Capacity capacity;
private final TournamentStatus status;
private final Set<PlayerId> confirmed = new HashSet<>();
public Tournament(TournamentId id, Capacity capacity, TournamentStatus status) {
this.id = Objects.requireNonNull(id);
this.capacity = Objects.requireNonNull(capacity);
this.status = Objects.requireNonNull(status);
}
public Set<PlayerId> confirmedPlayers() {
return Set.copyOf(confirmed);
}
public RegistrationConfirmed register(PlayerId playerId) {
Objects.requireNonNull(playerId);
if (status != TournamentStatus.OPEN) {
throw new RegistrationClosed(id);
}
if (confirmed.contains(playerId)) {
throw new PlayerAlreadyRegistered(playerId);
}
if (!capacity.canFit(confirmed.size())) {
throw new TournamentFull(id);
}
RegistrationConfirmed event =
new RegistrationConfirmed(id, playerId, confirmed.size() + 1);
confirmed.add(playerId);
return event;
}
}The same root enforces capacity and unique player registration. Its collection accessor returns an immutable snapshot. It constructs the event before changing membership so ordinary validation failures do not leave a partial registration. This does not promise recovery from a JVM crash or exhausted memory. Returning an event does not mean it has committed or been published.
This mutable object belongs to one unit of work and is not thread-safe. Two requests loading separate copies can both pass local checks, so persistence still needs an atomic version check or lock. Duplicate players are rejected in the domain; returning the original success for an HTTP retry belongs in the request-idempotency layer.
Aggregate Size Should Be Minimized
Putting the entire tournament, all matches, players, rewards, and payments into a single aggregate leads to:
- Large object graphs loaded on every operation;
- Concurrent commands competing for the same version;
- Overly broad transaction and lock scopes;
- Inability to scale or evolve independently.
Aggregates should prefer references via ID, with coordination handled at the application layer or through domain services. Cross-aggregate rules typically accept eventual consistency, reservation, or Saga patterns; if a constraint must hold atomically and immediately, the aggregate boundary or data ownership should be reevaluated.
Aggregate boundaries are determined by invariants and concurrency requirements, not by UI page structure or the convenience of ORM cascade operations.
Repository Focused on Aggregate Roots
import java.util.Optional;
public interface TournamentRepository {
record Loaded(Tournament aggregate, long version) {}
Optional<Loaded> findById(TournamentId id);
void save(Tournament tournament, long expectedVersion);
}The loaded result carries a version, which the caller passes unchanged to save. The repository must check it and persist all aggregate changes in one transaction, failing on conflict. It must not save half the members before reporting a conflict. Persistence versioning does not make a shared Java object thread-safe.
The repository provides a domain interface resembling a collection, hiding the details of queries and persistence. It is typically defined per aggregate root and does not offer repository access to individual entities within the aggregate, bypassing the root.
Complex reports and list queries do not need to load the entire aggregate. Instead, they can use dedicated query models. Write model rules enforce data integrity, while read model services handle retrieval.
Domain Service Placement for Rules Without Natural Ownership
A business decision involving multiple concepts but not naturally belonging to any single entity or value object can belong in a stateless domain service. This declares an interface, not an implemented seeding algorithm; Bracket, QualifiedPlayer, and RankingSnapshot are project types:
import java.util.List;
public interface SeedingPolicy {
Bracket seed(List<QualifiedPlayer> players, RankingSnapshot ranking);
}For example, seeding could sort by the frozen ranking in descending order, break ties by ascending player ID, and form a bracket. Random draws are a business choice; persist the result or seed so recovery does not quietly produce different matchups. This rule depends only on the supplied snapshot. Fetching rankings and managing transactions belong in the application service.
A domain service does not mean that all business logic should be centralized here. Behaviors that belong in entities or value objects should be placed close to their state. Responsibilities requiring transactions, repositories, messaging, or permission orchestration belong in application services.
Factory Protection of Complex Creation
When creating an aggregate requires multiple validation steps, strategy decisions, or several value objects, a Factory can express "how to obtain a valid initial aggregate." Simple construction does not necessitate mechanically adding more Factory classes.
Aggregate Tests Focus on State Transitions
Given the tournament is open with one spot remaining
When Player A registers
Then registration is confirmed, the remaining spots become zero, and a RegistrationConfirmed event is returned
Given the tournament is full
When Player B attempts to register
Then registration is rejected with TournamentFull, the state remains unchanged, and no success event is generatedBuild the Given state with capacity 2 and one registered player. Then check a successful final registration, full and duplicate rejection, closed status, and null arguments, verifying unchanged membership after failure. Concurrent versions and transaction rollback still need repository integration tests; an in-memory object test cannot establish those guarantees.
The next lesson addresses model translation between contexts, domain events and integration events, and how models evolve in response to production feedback.
References
- Martin Fowler, Value Object
- Martin Fowler, DDD Aggregate
- Martin Fowler, Repository