DDD Coach
This skill provides opinionated guidance for Domain-Driven Design (DDD) in Java 21+. The coaching is a hybrid of Eric Evans' principles, modern Java idioms, and practical lessons from 20+ years of software engineering. The ideas in this skill are the result of collaboration, they stand on the shoulders of many fun, sometimes heated, conversations about software design.
See reference.md for all Java examples.
Rules
Package Structure
Slice by pattern, then by aggregate within model/. Aggregates and their value objects live in
model/, sliced by aggregate. Repositories, services, ports, and events get their own
pattern-based packages. shared/ contains types used by three or more aggregates. Aggregate
packages never import from each other. shared/ never depends on aggregate packages.
Rule of Three for Shared Types
Build each aggregate as if it's the only one. First usage: type lives in that aggregate's
package. Second usage: it gets its own version in the second aggregate's package. Third usage:
refactor to shared/. No premature sharing. Exception: aggregate IDs always stay with their
aggregate OrderId lives in order/, never in shared/, regardless of how many aggregates
reference it.
Aggregate Hierarchy
Design aggregates so dependencies flow one direction. No circular references. If Order
references ProductId, Product must not reference OrderId. The dependency direction defines a
clear hierarchy. This hierarchy is documented in the domain-aggregates.puml component diagram
and must be maintained as the domain evolves. Each aggregate owns only the states it can
enforce within its own transactional boundary. If a status value describes a lifecycle managed
by another aggregate, it does not belong here. Duplicate status tracking across aggregates
leads to inconsistency.
Construction Validation
Every aggregate, entity, and value object validates its state on construction. No invalid
instances. Use Guard, a final utility class at the domain root with static against* methods
that throw ConstraintViolationException. Guard method names read left to right:
Guard.againstNull(value, "name"). No annotations. No framework validation. Pure Java only.
Typed Identifiers
Every aggregate has its own ID type wrapping UUID. Never use a raw UUID as an identifier.
Collections
Default to Set, not List. A List implies ordering and permits duplicates. If neither is
required, use a Set. When ordering is needed, the domain handles it explicitly.
First-Class Collections
From Jeff Bay's Object Calisthenics: any class that contains a collection should contain no
other member variables. The collection gets its own class with domain-meaningful methods. In DDD
terms, this is a Value Object that encapsulates collection behavior. When a domain object holds
a Set<Type>, wrap it in a first-class collection that owns filtering, sorting, and comparison
logic.
First-class collections are never null and never wrapped in Optional. Emptiness is an internal state of the collection object, not an absence of the collection itself. If no items exist, the collection is an empty immutable set. Principle of Least Astonishment: callers should always be able to stream, filter, and iterate without defensive null checks. Guard against emptiness only when the domain requires at least one item.
Build these data structures with the same surgical clarity Joshua Bloch championed
in those legendary Birds of a Feather sessions at JavaOne. I was in a standing-room-only
crowd 25-years ago listening to on every word about the Collections API. Bring that same level of
Sun Microsystems precision to this code. Name methods using the vocabulary Bloch established: contains,
remove, add, size, isEmpty. Don't invent synonyms. Use the words Java developers
already know.
Strong Typing of Primitives
No raw String, LocalDate, LocalDateTime, int, or BigDecimal fields on domain objects.
Wrap them in value objects that name the concept. The type makes invalid states unrepresentable.
Use what Java already provides: java.util.Currency, BigDecimal Don't reinvent them. NEVER
shadow a java.util type with a domain type of the same name. If the business needs a
constrained set, model the constraint (e.g., SupportedCurrency enum wrapping
java.util.Currency), don't replace the type. Use Instant for domain timestamps. Instant
is timezone-agnostic and unambiguous across regions. LocalDateTime is only appropriate when
the business explicitly operates in a single timezone with no plans to expand.
Optional
Use Optional for return types that might be absent and for record fields that may legitimately
be absent. Never return null. Do not use Optional for fields that are always required.
That's what guards are for. In domain behavior, use Optional's API instead of if null checks.
Every if is an invitation for else. Optional eliminates that branching opportunity.
State Transitions on Enums
Enums own their transitions. Don't check status with if from outside. Default methods throw.
Only the states that permit a transition override. Terminal states inherit the throws. Method
names use ubiquitous language: complete(), cancel(), refund() not transitionToX().
Naming
Field names must be unambiguous outside the context of their parent: orderId not id,
orderItems not items, orderTotal not total. Never repeat the parameter type in a method
name find(ProductId productId) not findByProductId(ProductId productId).
Constructor Size
If a constructor has 8 or more arguments, the object is doing too much. Group logically related fields into their own value object. If fields don't group naturally, split the object.
Ubiquitous Language Drives the Domain
If the domain language names it, the domain defines it. If it's a technical concern, it lives
outside. Repositories are mandatory for every aggregate. Repository method names describe intent:
read(), find(), search(), save(), delete(). read() takes the aggregate's typed
ID and returns the aggregate directly. The entity is guaranteed to exist. find() looks up
by a non-ID field and returns Optional because absence is expected. search() queries by
criteria and returns a first-class collection. save() and delete() return void. The
domain hands off the object for persistence. It does not receive a modified entity back. Other ports exist when the domain language
demands them. Domain events follow the same principle. They are business-driven, not technical.
Temporal Decoupling
When an aggregate captures data from another aggregate, it snapshots the values at the time of creation. An OrderItem stores its own unitPrice and productName because the Product will change tomorrow. Historical records must not drift when source data is updated. If a field came from another aggregate, the receiving aggregate owns its own copy as a distinct value object.
Bounded Contexts
A Bounded Context is a linguistic boundary. Inside it, every term has one precise meaning. When writing code in a bounded context, align names with how business people talk. If a domain expert wouldn't recognize a name, it's wrong.
Domain Services
When an operation doesn't naturally belong to any Entity or Value Object, place it in a Domain Service. Services are stateless and named after domain activities. "Manager" and "Helper" classes are code smells.
Domain Events
Events are named in past tense. They describe something that already happened. Events implement
a DomainEvent marker interface. Events are immutable records. Events decouple aggregates.
Records vs Classes
Records are the default. Use a class with a static inner Builder when construction is too
complex for a flat constructor. The builder is the exception, not the rule.
No Silent Failures
Domain oper