Enriching the Anemic Model
Enriching the Anemic Model
The Smell
The logistics platform’s Shipment class has 14 fields, 14 getters, and 14 setters. It has no behavior. It does not validate itself. It does not enforce its own invariants. It does not know which status transitions are valid. It is a struct with extra syntax.
Every operation on a shipment lives in a service class. To find out how a shipment’s weight is calculated, search for setTotalWeight. Six service classes call it. To find out which status transitions are valid, search for setStatus. Four service classes call it, each with different validation logic. Two of them allow a transition from DELIVERED to IN_TRANSIT. One does not. The shipment’s invariants are scattered, contradictory, and impossible to discover without reading every service class that touches the shipment.
// The search for all status mutations:
grep -r "setStatus" --include="*.java" src/
// Returns:
// ShipmentCreationService.java: shipment.setStatus(PENDING);
// ShipmentStatusService.java: shipment.setStatus(newStatus);
// ShipmentTrackingService.java: shipment.setStatus(event.getStatus());
// ReturnService.java: shipment.setStatus(RETURNED);
// BulkUpdateService.java: shipment.setStatus(status);
// MigrationService.java: shipment.setStatus(legacyStatusMap.get(old));
Six places where the shipment’s status is set. Each with different or no validation. A developer modifying status transition logic must find and read all six.
The Cognitive Cost
An anemic model forces the reader to assemble the domain’s rules from scattered fragments. The Shipment class tells the reader what data a shipment has. It tells the reader nothing about what a shipment can do, what it cannot do, or what invariants it maintains. That information is distributed across service classes, and the reader must discover each one through search.
The cognitive cost of the anemic model is search cost. Each question about the domain (“Can a delivered shipment be returned?”) requires a codebase search, followed by reading each result, followed by reconciling potentially contradictory answers. A domain object with behavior answers the question in one place.
The Before
// HARD TO READ: Shipment is a data bag. All behavior is in service classes.
// To understand shipment rules, search every class that calls a setter.
public class Shipment {
private Long id;
private String trackingNumber;
private ShipmentStatus status;
private Address origin;
private Address destination;
private BigDecimal totalWeight;
private Long carrierId;
private Long customerId;
private Instant createdAt;
private Instant updatedAt;
private boolean international;
private ServiceLevel serviceLevel;
private BigDecimal calculatedRate;
private List<ShipmentItem> items;
// 14 getters and 14 setters. No behavior. No validation.
public ShipmentStatus getStatus() { return status; }
public void setStatus(ShipmentStatus status) { this.status = status; }
public BigDecimal getTotalWeight() { return totalWeight; }
public void setTotalWeight(BigDecimal totalWeight) {
this.totalWeight = totalWeight;
}
// ... 12 more getter/setter pairs
}
// Status validation is in a service, not the domain object:
public class ShipmentStatusService {
public void updateStatus(Long shipmentId, ShipmentStatus newStatus) {
Shipment shipment = repository.findById(shipmentId).orElseThrow();
// Validation is here, not on the Shipment object
if (shipment.getStatus() == ShipmentStatus.DELIVERED
&& newStatus != ShipmentStatus.RETURNED) {
throw new InvalidStatusTransitionException(
shipment.getStatus(), newStatus);
}
shipment.setStatus(newStatus); // Setter allows any status, any time
repository.save(shipment);
}
}
The Fix
// READABLE: Shipment enforces its own invariants.
// Status transitions are validated inside the domain object.
// No service class can set an invalid status.
public class Shipment {
private final ShipmentId id;
private final TrackingNumber trackingNumber;
private ShipmentStatus status;
private final ShipmentRoute route;
private final ShipmentCargo cargo;
private final CarrierId carrierId;
private final CustomerId customerId;
private final Instant createdAt;
// No public setters. State changes go through methods that enforce invariants.
public StatusTransition transitionTo(ShipmentStatus newStatus) {
validateTransition(this.status, newStatus);
ShipmentStatus previousStatus = this.status;
this.status = newStatus;
return new StatusTransition(this.id, previousStatus, newStatus, Instant.now());
}
private void validateTransition(ShipmentStatus from, ShipmentStatus to) {
if (!VALID_TRANSITIONS.get(from).contains(to)) {
throw new InvalidStatusTransitionException(from, to);
}
}
private static final Map<ShipmentStatus, Set<ShipmentStatus>> VALID_TRANSITIONS =
Map.of(
ShipmentStatus.PENDING, Set.of(PICKED_UP, CANCELLED),
ShipmentStatus.PICKED_UP, Set.of(IN_TRANSIT, CANCELLED),
ShipmentStatus.IN_TRANSIT, Set.of(IN_CUSTOMS, OUT_FOR_DELIVERY, CANCELLED),
ShipmentStatus.IN_CUSTOMS, Set.of(IN_TRANSIT, CANCELLED),
ShipmentStatus.OUT_FOR_DELIVERY, Set.of(DELIVERED, CANCELLED),
ShipmentStatus.DELIVERED, Set.of(RETURNED),
ShipmentStatus.CANCELLED, Set.of(),
ShipmentStatus.RETURNED, Set.of()
);
public boolean isDeliverable() {
return this.status == ShipmentStatus.OUT_FOR_DELIVERY;
}
public boolean isModifiable() {
return this.status == ShipmentStatus.PENDING
|| this.status == ShipmentStatus.PICKED_UP;
}
public ShipmentCargo recalculateCargo(List<ShipmentItem> updatedItems) {
if (!isModifiable()) {
throw new ShipmentNotModifiableException(this.id, this.status);
}
return ShipmentCargo.fromItems(updatedItems);
}
// Value objects replace primitive fields
public record ShipmentId(Long value) {}
public record TrackingNumber(String value) {
public TrackingNumber {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(
"Tracking number cannot be blank");
}
}
}
}
The Shipment class now contains the status transition rules. A developer asking “Can a delivered shipment be returned?” opens the Shipment class, finds the VALID_TRANSITIONS map, and reads one line: DELIVERED -> {RETURNED}. Answer found. One file. One lookup. No search across six service classes.
The public setters are gone. No service class can bypass the transition validation by calling setStatus directly. The invariant is enforced by the domain object, not by convention, not by code review, not by hoping every service class remembers to validate first.
The behavior that moved to the domain object is behavior that uses only the object’s own data. transitionTo uses this.status. isDeliverable uses this.status. isModifiable uses this.status. recalculateCargo uses this.status to check the precondition. These methods belong on the domain object because they operate on the object’s state and enforce the object’s invariants.
Behavior that remains in service classes is behavior that requires external dependencies: querying the database, calling carrier APIs, sending notifications. The service classes become thinner. They orchestrate domain objects and external dependencies. They do not contain domain logic.
The Rule
If a class has public setters and zero methods that enforce invariants, it is an anemic model. Move validation and state-transition logic onto the domain object. The test: search for every call to setX() on the class. If multiple callers validate differently before calling the setter, the validation belongs on the object, not on the callers. Remove the setter. Add a method that validates and transitions. One source of truth for each invariant. If a developer needs to know “what are the valid status transitions?”, they should find the answer in one place: the domain object.