Skip to content

Anemic Domain Model — Tasks

Reference: Martin Fowler, AnemicDomainModel (https://martinfowler.com/bliki/AnemicDomainModel.html), 2003.

Eight progressive exercises. Each one starts from anemic code and asks you to produce a rich-model refactor with tests. Estimated time: 6–8 hours total.

Exercise 1 — Money Value Object

Objective. Replace BigDecimal amount + String currency field pairs across the codebase with a single immutable Money Value Object.

Constraints. - Money must be a record. - Validation in the canonical constructor: scale must match currency.getDefaultFractionDigits(), amount cannot be null, currency cannot be null. - Provide add, subtract, multiply(BigDecimal), all returning new Money. - All arithmetic methods must reject currency mismatches with CurrencyMismatchException. - No public setters anywhere.

Acceptance criteria. - 100% line coverage on Money. - Money.of("USD", 10).add(Money.of("EUR", 5)) throws. - new Money(new BigDecimal("10.123"), Currency.getInstance("USD")) throws. - Used as @Embeddable in at least one entity.

Exercise 2 — Account aggregate

Objective. Convert an anemic Account { id, balance, frozen } into a rich aggregate with open, deposit, withdraw, freeze, unfreeze.

Constraints. - Static factory open(AccountId, Money initialDeposit); no public constructor. - withdraw must throw InsufficientFundsException rather than allow negative balance. - All operations on a frozen account must throw AccountFrozenException. - balance is exposed via accessor balance(), not getBalance().

Acceptance criteria. - A property-based test (jqwik) verifies that for any sequence of valid deposit/withdraw, the balance equals the algebraic sum. - A test verifies that withdraw on a frozen account fails before the balance check (i.e., frozen takes precedence).

Exercise 3 — ArchUnit guard

Objective. Add ArchUnit rules to the project that detect new anemic classes in the domain package.

Constraints. - Rule 1: no public method in ..domain.. may start with set. - Rule 2: every class annotated with @Entity in ..domain.. must have at least one public method that is not a getter/setter/equals/hashCode/toString. - Rule 3: classes in ..domain.vo.. must have only final fields. - Rules must run in CI; failing rules fail the build.

Acceptance criteria. - Adding public void setX(int x) {} to an existing domain entity causes ArchUnit to fail with a clear message. - The existing rich model passes all rules.

Exercise 4 — MapStruct mapper

Objective. Replace hand-written DTO conversion code with MapStruct.

Constraints. - OrderResponse is a record with no setters. - OrderMapper is a @Mapper(componentModel = "spring") interface. - The mapper must extract Money.amount and Money.currency.currencyCode into separate response fields. - No setters added to Order entity to make the mapper work.

Acceptance criteria. - mvn compile generates OrderMapperImpl. - Manual conversion methods are deleted. - An integration test confirms OrderMapper.toResponse(order) round-trips correctly.

Exercise 5 — CQRS split

Objective. Split a single anemic OrderSearchResult (used by both write and read paths) into a rich Order aggregate and a flat OrderListItem projection.

Constraints. - Order is annotated with @Entity, lives in domain, has behavior methods. - OrderListItem is a record, lives in query, has no behavior. - The query side uses JdbcTemplate, not the JPA repository. - The two types never appear in the same class.

Acceptance criteria. - Write tests instantiate Order via Order.place(...). - Read tests assert that OrderListItem is returned from OrderQueryRepository.findRecent(10). - No field overlap is required between the two — OrderListItem may include customerName (denormalized) that Order does not have.

Exercise 6 — Domain event on cancel

Objective. Add a cancel(Reason) method to Order that emits an OrderCancelledEvent on success.

Constraints. - Use Spring's ApplicationEventPublisher outside the aggregate; the aggregate only registers events on itself. - The repository publishes registered events on save. - cancel throws if the order is already shipped.

Acceptance criteria. - A test verifies that calling order.cancel(reason) followed by repo.save(order) triggers an event listener. - The aggregate has zero references to Spring classes.

Exercise 7 — Email Value Object replaces three layers

Objective. Find every place that validates email format (DTO @Email, service-level regex, JPA @Column(length = 255)) and consolidate behind a single Email VO.

Constraints. - Email is a record with validation in the canonical constructor. - Jackson deserializes JSON strings into Email via @JsonCreator. - JPA persists Email as a VARCHAR(255) via an AttributeConverter. - No regex appears outside the Email class.

Acceptance criteria. - A grep -r "email.*regex" finds zero matches outside Email.java. - POST /users with "email": "not-an-email" returns 400 with a clear error. - The persisted column is still VARCHAR(255) (Liquibase changelog unchanged).

Exercise 8 — Refactor a real service-driven flow

Objective. Given a legacy InvoiceService of 600 lines that manipulates an anemic Invoice entity, extract three behavior methods to Invoice: applyDiscount(Percentage), finalize(), voidInvoice(Reason).

Constraints. - Move all invariant checks into Invoice. - InvoiceService shrinks by at least 40% lines. - No new public setters are added to Invoice. - Existing tests still pass; add new tests for the moved behavior on Invoice directly.

Acceptance criteria. - InvoiceService becomes an orchestration layer (load → call domain method → save → publish event). - ArchUnit rule (from Exercise 3) still passes. - Coverage on Invoice jumps to ≥ 90%.

Validation table

Exercise Primary skill Verifies Common failure
1 VO design Immutability, validation Mutable field smuggled in via collection
2 Aggregate design Invariant enforcement withdraw checks balance before frozen state
3 Architectural enforcement ArchUnit rule writing Rule too broad — fails legitimate code
4 DTO mapping MapStruct without setters Adding a setter "just for the mapper"
5 CQRS split Write/read separation Sharing one DTO across both sides
6 Domain events Event-on-mutation Publishing inside the aggregate
7 Consolidation One source of validation truth Leaving the DTO @Email annotation in place
8 Legacy refactor Service-to-aggregate migration Moving logic but leaving the setters

Worked solution sketch — Exercise 2

public final class Account {
    private final AccountId id;
    private Money balance;
    private boolean frozen;

    private Account(AccountId id, Money initial) {
        this.id = id;
        this.balance = initial;
        this.frozen = false;
    }

    public static Account open(AccountId id, Money initialDeposit) {
        Objects.requireNonNull(id);
        Objects.requireNonNull(initialDeposit);
        if (initialDeposit.amount().signum() < 0)
            throw new IllegalArgumentException("Negative initial deposit");
        return new Account(id, initialDeposit);
    }

    public void deposit(Money amount) {
        requireActive();
        balance = balance.add(amount);
    }

    public void withdraw(Money amount) {
        requireActive();              // frozen check FIRST
        if (balance.amount().compareTo(amount.amount()) < 0)
            throw new InsufficientFundsException(id, balance, amount);
        balance = balance.subtract(amount);
    }

    public void freeze() {
        if (frozen) throw new IllegalStateException("Already frozen");
        frozen = true;
    }

    public void unfreeze() {
        if (!frozen) throw new IllegalStateException("Already active");
        frozen = false;
    }

    private void requireActive() {
        if (frozen) throw new AccountFrozenException(id);
    }

    public AccountId id() { return id; }
    public Money balance() { return balance; }
    public boolean isFrozen() { return frozen; }
}

Property-based test (jqwik):

@Property
void balance_equals_algebraic_sum(@ForAll List<@From("validAmount") Money> ops) {
    Account a = Account.open(AccountId.newId(), Money.zero(Currency.getInstance("USD")));
    BigDecimal expected = BigDecimal.ZERO;
    for (Money m : ops) {
        if (m.amount().signum() >= 0) {
            a.deposit(m);
            expected = expected.add(m.amount());
        } else {
            Money toWithdraw = m.negate();
            if (a.balance().amount().compareTo(toWithdraw.amount()) >= 0) {
                a.withdraw(toWithdraw);
                expected = expected.subtract(toWithdraw.amount());
            }
        }
    }
    assertThat(a.balance().amount()).isEqualByComparingTo(expected);
}

Pass criteria: 1000 random sequences, all produce a consistent balance and never violate the invariants.