Who may change state
Rich domain model vs anemic model
A rich model keeps invariants on the object that owns the data. An anemic model is mostly getters and setters, with rules scattered in services. Example: Ticket has getStatus and setStatus. ParkingLotService writes if (ticket.getStatus() == ACTIVE) ticket.setStatus(PAID). A webhook forgets the check and sets CLOSED to ACTIVE. The never lived on the ticket — it lived in whoever remembered.
Anemic
An is a bag of fields. Getters, setters, maybe a constructor. All the rules sit in services that reach in and mutate. Two services will eventually disagree.
- Public setters for status, spot, fee.
- if (getX) setY scattered across helpers.
- Looks “testable” until every test rebuilds the same ifs.
Rich
A protects its own invariants. Callers ask for a . They do not assign fields. The object that owns the data owns the rules that belong to that data.
- pay(), occupy(), close() refuse illegal moves.
- Queries like status() are fine. setStatus is not.
- You can test Ticket.pay() without a database.
Anemic — the service holds the rules
class Ticket {
private TicketStatus status;
TicketStatus getStatus() { return status; }
void setStatus(TicketStatus status) { this.status = status; }
}
class ParkingLotService {
Receipt exit(Ticket ticket, Instant now) {
if (ticket.getStatus() == TicketStatus.ACTIVE) {
ticket.setStatus(TicketStatus.PAID);
}
return new Receipt(ticket.getFee());
}
}Rich — the ticket refuses illegal change
class Ticket {
private TicketStatus status = TicketStatus.ACTIVE;
private final Instant entry;
private Money fee = Money.inr(0, 0);
void pay(Money amount) {
if (status != TicketStatus.ACTIVE) {
throw new IllegalStateException("pay from " + status);
}
this.fee = amount;
status = TicketStatus.PAID;
}
void close() {
if (status != TicketStatus.PAID) {
throw new IllegalStateException("close from " + status);
}
status = TicketStatus.CLOSED;
}
}Rich is not “the entity does everything”
Pricing policy can still be a strategy. Persistence can still be a repository. A domain service can still price a stay. Rich means the object that owns the data owns the rules that belong to that data. Ticket owns “you may only pay from ACTIVE.” It does not own airport surcharge tables. Confusing those two is how people swing from anemic to a God entity.
Interviewers have seen getters-plus-God-service. They grade you higher when Ticket refuses an illegal transition without asking a service for permission. That is also why state machines belong on the entity that has the status, not in a util that takes the enum as an argument.
The “testability” excuse
Keeping entities thin “for testability” does not get easier tests. It gets duplicated ifs. You can test Ticket.pay() with a few Instant and Money values and no Spring context. Anemic designs force you to construct a service, a repository fake, and a clock — then still miss the webhook path that called setStatus. Encapsulation and richness are what make the unit small.
Setters as the public API
setStatus, setSpot, setFee let any caller skip the story. Commands like pay() and occupy() are the API. If a setter exists, someone will use it — usually in a hurry, usually wrong.
Thin entities on purpose
“Keep the model free of logic” pushes every rule into services. Then two services disagree. Behavior belongs with the data. See modeling behavior.
How anemia spreads
It starts as “we will add the rules later.” Then a second use case needs the same check. Then a third path — refund, admin override, import — copies the if and changes one line. The ticket never learned the story. Domain services cannot save you if the entities will accept any assignment. A service that calls pay() is coordinating. A service that calls setStatus is the model wearing a different class name.
Frameworks that want a no-arg constructor and public setters for mapping are a persistence concern. Keep a mapping layer. Do not let that layer become the domain API. Keeping those layers apart is domain versus persistence model and encapsulation.
How to explain this in an interview
“No setters. pay and close are the only transitions. I will test those two methods. Pricing stays a strategy.” Then write the throws. A rich model that never refuses is just an anemic model with nicer method names.
The webhook that skipped the story
A payment provider calls you twice. The anemic path does ticket.setStatus(PAID) both times, or worse, sets CLOSED back to ACTIVE on a retry with the wrong flag. The rich path calls pay() the second time and throws or no-ops because status is already PAID. Idempotency becomes a decision on the entity, not a hope in the controller.
class Ticket {
void pay(Money amount) {
if (status == TicketStatus.PAID && fee.equals(amount)) return;
if (status != TicketStatus.ACTIVE) {
throw new IllegalStateException("pay from " + status);
}
this.fee = amount;
status = TicketStatus.PAID;
}
}Spot can be rich in ten lines
You do not need a framework to be rich. tryOccupy and release are enough. The anemic version is setOccupied and setVehicle as two independent knobs. Those knobs will be set out of sync: occupied with a null vehicle, or a vehicle on a free spot. One command updates both fields. That pairing is the model.
class ParkingSpot {
private SpotStatus status = SpotStatus.FREE;
private Vehicle occupant;
boolean tryOccupy(Vehicle vehicle) {
if (status != SpotStatus.FREE) return false;
occupant = Objects.requireNonNull(vehicle);
status = SpotStatus.OCCUPIED;
return true;
}
}What richness looks like on a whiteboard
Draw Ticket with pay and close, not with status, fee, spot as public boxes. Draw Spot with tryOccupy and release. Draw Money with plus. Then draw a thin ExitParking that calls those names. The diagram already argues against a God service. If you draw fields first, you will invent setters to fill them.
Follow-ups become transitions: refund from PAID, void from ACTIVE. You add a command. You do not add setStatus(REFUNDED) and a comment that says “don’t call this from the gate.” Comments are not encapsulation.
Anemia in Splitwise is two services arguing
Expense with setShares and Balance with setAmount look harmless. Then SettleService and RefundService both adjust balances. One checks non-negative. The other does not. A rich Balance.debit refuses to go below zero — or records a receivable, if that is the rule — in one place. The services only decide who pays whom.
The same split appears in inventory: OrderService.setQuantity versus Stock.reserve(qty). Reserve can fail. setQuantity will not. Interviewers have watched both versions. They are waiting to see which one you draw.
A DTO for the wire can stay anemic. That is its job: carry bytes. The mistake is using that DTO as the Ticket you call setStatus on. Map in. Call pay. Map out. Two types. One story.
Richness is a refusal
If every public method can succeed with any arguments, you are still anemic. A rich object has at least one path that throws or returns false. That path is the invariant speaking. Write it first. The happy path is easy after the object knows how to say no.
Then write one test that expects the refusal. A rich model without that test will grow a setter the first time someone is in a hurry. The test is how the refusal stays part of the design.