Command Palette

Search for a command to run...

PHASE 1Beginner ~15 min· topic 5 of 5

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.

0/5 · 0%

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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.

  9. 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

DomainModel.javajava

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

01

Why is Money a value object but Order an entity? What goes wrong if Order.equals compares all fields?

02

Why is 0.1 + 0.2 != 0.3 a production bug in a billing system, and what are two correct representations?

03

What is an aggregate root, and why does it matter for locking and transactions?

Practice

01

Model a hotel booking domain: Room (entity), DateRange (value object with overlaps()), Booking (aggregate root with confirm/cancel transitions). Make invalid states unrepresentable.

02

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.

Back to phase