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.

Yumin Fish Harvest
Yumin Fish Harvest
Yumin Fish Harvest
Why Business Logic Should Stay Out of Services: Moving from Anemic to Rich Domain Model

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 == 3OrderStatus.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/*/application

Conclusion

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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaDDDRefactoringService LayerAnemic ModelRich ModelDomain Logic
Yumin Fish Harvest
Written by

Yumin Fish Harvest

A deep‑sea salvage fisherman sharing architecture insights, practical tips, and lessons learned.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.