Why Business Logic Should Stay Out of Services: Moving from Anemic to Rich Domain Model
The article compares anemic and rich domain models using an order‑cancellation case, showing how setters and magic numbers in services lead to rule violations, and walks through a step‑by‑step refactor that moves business rules into the domain object for better encapsulation and consistency.
Fundamental Difference
An anemic model treats objects as data bags that only contain fields and getters/setters, while all business logic lives in Service classes. A rich (or “charged”) model places both data and behavior together, making the object a self‑contained worker.
Code Comparison (Order Cancellation)
Anemic implementation (traditional) :
// Anemic Order: pure data bag
public class Order {
private Long id;
private Integer status;
private BigDecimal totalAmount;
// getters / setters only
public Integer getStatus() { return status; }
public void setStatus(Integer status) { this.status = status; }
}
@Service
public class OrderService {
public void cancelOrder(Long orderId) {
Order order = orderMapper.selectById(orderId);
// Business rule in Service: shipped orders cannot be cancelled
if (order.getStatus() == 3 || order.getStatus() == 4) {
throw new RuntimeException("已发货/完成不能取消");
}
order.setStatus(5); // magic number for cancelled
orderMapper.updateById(order);
}
}Rule not enforced : Any code can call order.setStatus(5) and bypass the check.
Rule duplication and drift : The same "cannot cancel shipped" rule must be repeated in every place that changes status.
Magic numbers : Values like 3, 4, 5 are opaque.
Encapsulation violation : Data lives in Order, behavior lives in OrderService, breaking OO principles.
Rich implementation (DDD) :
// Rich Order: data + behavior together
public class Order {
private final OrderId id;
private OrderStatus status; // enum, no magic numbers
private Money totalAmount;
public void cancel() {
if (status == OrderStatus.SHIPPED || status == OrderStatus.COMPLETED) {
throw new IllegalStateException("已发货/已完成的订单不能取消");
}
this.status = OrderStatus.CANCELLED;
registerEvent(new OrderCancelledEvent(this.id));
}
// no setter for status
}
@Service
public class CancelOrderAppService {
@Transactional
public void cancel(OrderId orderId) {
Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new BusinessException("订单不存在"));
order.cancel(); // rule lives inside the entity
orderRepo.save(order);
}
}Single guard : The only way to cancel is via order.cancel(), which enforces the rule internally.
Rule centralized : All cancellation paths go through the same method, guaranteeing consistency.
Code expresses business : Methods like order.cancel() read like domain language.
True OO : Data and its invariants are encapsulated together.
Why DDD Prefers the Rich Model
The rich model gives business rules a clear, unique, and unavoidable home, preventing scattered and mutable logic.
Step‑by‑Step Refactor (Four Practical Steps)
Eliminate setters and replace direct state changes with domain methods such as order.cancel(), order.pay(), order.ship().
Move conditional checks like if (order.getStatus() == ...) from Service into the corresponding domain method.
Extract calculation logic (e.g., total amount, discount eligibility) into the entity or value objects like Money.
Replace magic numbers with expressive enums (e.g., status == 3 → OrderStatus.SHIPPED).
Extreme Cases and Trade‑offs
Fake rich : Simply moving code into the entity while still injecting repositories or other services pollutes the domain object.
Over‑rich : Trying to cram every piece of logic into entities makes them bloated; cross‑aggregate logic belongs in domain services.
Verification – Ensuring Behavior Is Unchanged
Write unit tests before refactoring:
class CancelOrderTest {
@Test
void shippedOrderCannotBeCancelled() {
Order order = Order.restore(orderId, OrderStatus.SHIPPED);
assertThrows(IllegalStateException.class, order::cancel);
}
@Test
void pendingPaymentOrderCanBeCancelled() {
Order order = Order.restore(orderId, OrderStatus.PENDING_PAYMENT);
order.cancel();
assertEquals(OrderStatus.CANCELLED, order.getStatus());
}
}After refactoring, search for remaining anemic remnants:
# Find setters that may indicate anemic model
grep -R "void set[A-Z]" src/main/java/com/youxuan/store/*/domain
# Find magic status values
grep -R "status.*[=<>]=.*[0-9]" src/main/java
# Find business checks still in application services
grep -R "if (.*status\|if (.*amount\|if (.*total" src/main/java/com/youxuan/store/*/applicationConclusion
The anemic model is not “wrong” for simple CRUD scenarios where speed and simplicity matter. The rich model shines for complex, evolving core business where rule consistency, encapsulation, and expressive code are essential. Choose the model based on the domain complexity.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Yumin Fish Harvest
A deep‑sea salvage fisherman sharing architecture insights, practical tips, and lessons learned.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
