Skip to content

20.2 Tactical Modeling: Entities, Value Objects, Aggregates, and Repositories

After stepping into the domain modeling workshop, Ah Hua noticed that the database tables had already made decisions about who was responsible for maintaining invariants, while the code itself offered no clear answers.

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

An entity remains the same object even if its properties change. Its identity must be stable:

java
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. Entity equality is typically based on the same type and a stable identity.

Value Object Defined by Value

Value objects do not have independent identity and are well-suited for expressing measured quantities and concepts with rules:

java
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) {
        return confirmedEntries < value;
    }
}

Value objects should be prioritized as immutable, 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.

java
public final class Tournament {
    private final TournamentId id;
    private final Capacity capacity;
    private TournamentStatus status;
    private final Set<PlayerId> confirmed = new HashSet<>();

    public RegistrationConfirmed register(PlayerId 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);
        }

        confirmed.add(playerId);
        return new RegistrationConfirmed(id, playerId, confirmed.size());
    }
}

"The number of participants does not exceed capacity" and "a player does not confirm duplicate entries" are both enforced by the same root. External code cannot obtain a mutable collection and directly add.

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

java
public interface TournamentRepository {
    Optional<Tournament> findById(TournamentId id);
    void save(Tournament tournament, long expectedVersion);
}

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 be handled by an unbounded domain service:

java
public final class SeedingPolicy {
    public Bracket seed(List<QualifiedPlayer> players, RankingSnapshot ranking) {
        // Domain algorithm, does not access HTTP or database
    }
}

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

text
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 emitted

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 generated

Tests should cover invariants, idempotency and repeated commands, concurrent versioning, and failure atomicity, rather than getter methods alone.

The next lesson addresses model translation between contexts, domain events and integration events, and how models evolve in response to production feedback.

References

Built with VitePress | Software Systems Atlas