Domain modelling in Java

Entities, value objects, aggregates, invariants, and the anaemic model that pushes every rule into a service.

5 min read📐 Low-Level Design

Most backend code is a Service that loads rows into objects with getters and setters, applies the rules in the service, and saves the objects back. It works, and it is why the rules of a business end up spread across forty service methods, duplicated, and impossible to find. Domain modelling is the discipline of putting the rules in the objects that the rules are about, so that an Order cannot be in a state the business says is impossible, whatever any service does to it. This lesson is the vocabulary and the Java shapes for it — and the honest costs, because a model that is richer than its domain is a different kind of mess.

Entities and value objects

An entity is something with an identity that persists through change: an order is the same order after its status changes, a customer is the same customer after a name change. Two entities are equal when their ids are equal. A value object is something defined entirely by its attributes: a Money of 500 paise in INR is interchangeable with any other 500 paise in INR, a postal address is its lines, a date range is its two dates. Two value objects are equal when their attributes are equal, and they are immutable — you do not change a Money, you compute a new one.

java
record Money(long paise, Currency currency) {
    Money {
        if (paise < 0) throw new IllegalArgumentException("negative money");
        Objects.requireNonNull(currency);
    }
    Money plus(Money other) {
        if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
        return new Money(paise + other.paise, currency);
    }
    static Money zero(Currency c) { return new Money(0, c); }
}

Records are Java's value objects: immutable, equal by attributes, with a compact constructor for the validation. The clean-code lesson's primitive obsession is this lesson's motivation — charge(Money amount, CustomerId to) cannot swap its arguments, long amount, long customerId can — and the JPA course's equals/hashCode lesson is the entity half: an entity's equality is its id, and a record is the wrong shape for one.

Aggregates and invariants

An invariant is a rule that must hold at every moment the outside world can observe: an order's total equals the sum of its lines; a paid order has a payment reference; a shipped order cannot gain lines. An aggregate is the cluster of objects that must be changed together to keep those invariants — the order and its lines — with one entity as the root through which every change passes:

java
class Order {
    private final OrderId id;
    private final List<Line> lines = new ArrayList<>();
    private Status status = Status.OPEN;
 
    void addLine(Sku sku, int qty, Money unitPrice) {
        if (status != Status.OPEN) throw new IllegalStateException("order " + id + " is " + status);
        if (qty <= 0) throw new IllegalArgumentException("qty");
        lines.add(new Line(sku, qty, unitPrice));
    }
    Money total() { return lines.stream().map(Line::subtotal).reduce(Money.zero(INR), Money::plus); }
    void markPaid(PaymentRef ref) {
        if (status != Status.OPEN) throw new IllegalStateException(...);
        if (lines.isEmpty()) throw new IllegalStateException("nothing to pay for");
        this.payment = ref; this.status = Status.PAID;
    }
    List<Line> lines() { return List.copyOf(lines); }        // a copy: nobody adds a line around the rule
}

Three properties make it an aggregate rather than a class with methods. Every change goes through the root — there is no getLines().add(...), because that would add a line to a shipped order; the getter returns a copy. The invariants are checked inside, so they hold whatever the caller intended. And the aggregate is the transaction boundary: one aggregate is loaded, changed, and saved in one transaction, and a rule that spans two aggregates (this customer may not have two open orders) is not an invariant of either — it is a policy checked by a service, eventually consistent, and the saga lesson's territory when it spans services.

Keep aggregates small. An Order that holds the customer, the customer's other orders and the products is one huge object loaded for every change, locked against every concurrent change, and the thing the JPA course's N+1 lesson warns about. Reference other aggregates by id (CustomerId), never by object.

The anaemic model, and why it is the default

An anaemic model is entities with fields and accessors and no behaviour; every rule lives in a service that reads the fields, decides, and writes them back:

java
// anaemic: the rule is in the service, and in the other six services that also change status
if (order.getStatus() == OPEN && !order.getLines().isEmpty()) { order.setStatus(PAID); order.setPaymentRef(ref); }

It is the default for a reason: JPA wants a no-arg constructor and setters, Jackson wants the same, the tutorial did it, and it is fast to write. Its cost arrives later — the same rule written in four services with three variations, an Order set to PAID with no lines by a batch job nobody tested, and the question "can an order be cancelled after shipping?" answered by reading every service rather than one class. The rich model above answers it in cancel().

The reconciliation with the frameworks: keep the JPA entity's setters package-private or absent (Hibernate uses field access and needs no setters; a protected no-arg constructor satisfies it), map DTOs at the edge (the REST course), and let the aggregate be the JPA entity when the two shapes agree — which for most services they do — or keep a separate persistence model when they do not, at the cost of mapping code. The SOLID lesson's warning applies: a model richer than the domain is speculative generality; a Status enum with a transitionTo method and a service that checks the rest is a fine middle for a small domain.

Ubiquitous language

The names in the code are the names the business uses, exactly. If the sales team says "quote" and the code says PreOrder, every conversation translates and every translation loses something; if the code says Quote with a method accept() that produces an Order, a product owner can read the class. The test: a domain expert can follow the aggregate's public methods as a description of the process. Where the language is ambiguous — "customer" meaning both the buyer and the account — that is a bounded context boundary, the microservices course's term, and the two meanings get two models rather than one class with a mode flag.

Progress is saved on this device and to your account when signed in.