Topic 1.5
Domain Modeling Essentials
In one line
Entities, value objects, invariants, immutability, and a deliberate error model. Most LLD bugs come from anemic data bags that let invalid state exist, not from missing patterns.
Think of it like this
A bank's cash counter. A ₹500 note is a value object: any ₹500 note is as good as any other. Your bank account is an entity: it has a unique number and stays 'yours' even as its balance changes.
Key ideas
- 01
Entity: has an identity that persists through state changes (Order #42 is the same order whether PENDING or SHIPPED). equals/hashCode use the id.
- 02
Value object: defined only by its values, immutable, and freely replaceable (Money, Address, TimeSlot). equals/hashCode use all fields. Java records are perfect for this.
- 03
Invariants belong inside the object. A Money can't be negative and a TimeSlot's end must be after its start. Validate in the constructor so invalid objects can't exist.
- 04
Tell, don't ask: call order.cancel() rather than reading order.getStatus(), deciding outside, and calling setStatus(). Behaviour lives next to the data it guards.
- 05
Immutability by default: final fields, no setters, defensive copies of collections (List.copyOf). Immutable objects are thread-safe for free, which matters in Phase 5.
- 06
Never use double for money. Use BigDecimal or long minor units (cents/paise) plus a currency code. Floating-point rounding errors are real production incidents.
- 07
Enums for closed sets (VehicleType, OrderStatus). Enums can carry behaviour and data, and a switch over a sealed type or enum is checked for exhaustiveness.
- 08
Error model: use domain exceptions for business rule violations (InsufficientFundsException, SpotUnavailableException), IllegalArgumentException for programmer errors, and Optional for 'may legitimately be absent'. Never return null collections.
- 09
Aggregates (DDD): a cluster of objects changed together through one root (Order owns its OrderLines). External code holds references to the root only. The aggregate is also your transaction and locking boundary.
Java / Spring map
- →
record Money(long minor, Currency currency) with a compact constructor for validation gives immutability, equals/hashCode, and toString for free.
- →
JPA entities need a no-arg constructor and mutable fields. Keep them in the persistence layer and map them to rich domain objects, or at least keep setters package-private.
- →
Sealed interfaces (Java 17+) plus pattern-matching switch model closed hierarchies such as PaymentResult = Success | Declined | Pending, with exhaustiveness checks.
Code & diagrams
Value objects with invariants, an entity with behaviour, and a sealed result type.
import java.util.*;
// VALUE OBJECT — immutable, validated, compared by value.
public record Money(long minor, String currency) {
public Money {
if (minor < 0) throw new IllegalArgumentException("negative money");
Objects.requireNonNull(currency);
}
public Money plus(Money o) {
requireSameCurrency(o);
return new Money(Math.addExact(minor, o.minor), currency);
}
private void requireSameCurrency(Money o) {
if (!currency.equals(o.currency)) throw new IllegalArgumentException("currency mismatch");
}
}
public record TimeSlot(java.time.Instant start, java.time.Instant end) {
public TimeSlot {
if (!end.isAfter(start)) throw new IllegalArgumentException("end must be after start");
}
public boolean overlaps(TimeSlot o) { return start.isBefore(o.end) && o.start.isBefore(end); }
}
// ENTITY — identity + guarded state transitions ("tell, don't ask").
public final class Order {
public enum Status { CREATED, PAID, SHIPPED, CANCELLED }
private final UUID id;
private final List<OrderLine> lines;
private Status status = Status.CREATED;
public Order(UUID id, List<OrderLine> lines) {
if (lines.isEmpty()) throw new IllegalArgumentException("order needs a line");
this.id = id;
this.lines = List.copyOf(lines); // defensive, immutable copy
}
public void markPaid() {
if (status != Status.CREATED) throw new IllegalStateException("cannot pay in " + status);
status = Status.PAID;
}
public void cancel() {
if (status == Status.SHIPPED) throw new IllegalStateException("already shipped");
status = Status.CANCELLED;
}
public Money total() {
return lines.stream().map(OrderLine::subtotal)
.reduce(new Money(0, "INR"), Money::plus);
}
@Override public boolean equals(Object o) { return o instanceof Order x && x.id.equals(id); }
@Override public int hashCode() { return id.hashCode(); }
}
public record OrderLine(String sku, int qty, Money unitPrice) {
public OrderLine { if (qty <= 0) throw new IllegalArgumentException("qty"); }
public Money subtotal() { return new Money(unitPrice.minor() * qty, unitPrice.currency()); }
}
// SEALED RESULT — the compiler forces callers to handle every case.
public sealed interface ChargeResult {
record Success(String txnId) implements ChargeResult {}
record Declined(String reason) implements ChargeResult {}
record Pending(String pollToken) implements ChargeResult {}
}
// String msg = switch (result) {
// case ChargeResult.Success s -> "ok " + s.txnId();
// case ChargeResult.Declined d -> "declined: " + d.reason();
// case ChargeResult.Pending p -> "retry later";
// };Explain without notes
Why is Money a value object but Order an entity? What goes wrong if Order.equals compares all fields?
Why is 0.1 + 0.2 != 0.3 a production bug in a billing system, and what are two correct representations?
What is an aggregate root, and why does it matter for locking and transactions?
Practice
Model a hotel booking domain: Room (entity), DateRange (value object with overlaps()), Booking (aggregate root with confirm/cancel transitions). Make invalid states unrepresentable.
Take an existing JPA entity with public setters and refactor it to expose only intention-revealing methods.
Trade-offs
- ↔
Rich domain objects vs anemic DTOs: rich models protect invariants but need mapping at the persistence and API edges. For CRUD-only screens anemic is fine; for money, bookings, and state machines, go rich.
- ↔
Immutability costs allocations. That is usually negligible on modern JVMs, but it matters for hot-loop, high-frequency objects.
Completion checklist
I can distinguish entity vs value object and implement equals/hashCode correctly for each.
My LLD models validate invariants in constructors and expose behaviour instead of setters.
I never represent money with double.