How Do I Fix an Anemic Domain Model in Spring Boot Applications?
Problem
My Spring Boot entities are just data containers with getters and setters. All the business logic lives in service classes, making them huge and hard to test.
@Entity@Getter @Setterpublic class Order { private Long id; private String status; private BigDecimal total;}
// Service has all the logic@Servicepublic class OrderService { public void cancelOrder(Order order) { if (!"PENDING".equals(order.getStatus())) { throw new IllegalStateException("Can only cancel pending orders"); } order.setStatus("CANCELLED"); // ... more logic scattered here }}When I write unit tests for OrderService, I need to mock repositories, other services, and sometimes the Spring context. The tests are slow and fragile.
Environment
- Spring Boot 3.x
- Spring Data JPA
- JUnit 5
What happened?
I built an anemic domain model. My entities have no behavior, only data. All logic lives in service classes.
This causes several problems:
1. Logic scattered across services
The same “can only cancel pending orders” check appears in multiple places:
@Servicepublic class OrderService { public void cancelOrder(Order order) { if (!"PENDING".equals(order.getStatus())) { ... } }}
@Servicepublic class RefundService { public void processRefund(Order order) { if (!"PENDING".equals(order.getStatus())) { ... } // Duplicated! }}2. Invalid states are possible
Anyone can call setters directly:
order.setStatus("RANDOM_STRING"); // No validation!order.setTotal(new BigDecimal("-100")); // Negative total!3. Testing requires too many mocks
@SpringBootTest // Heavy contextclass OrderServiceTest { @MockBean OrderRepository orderRepo; @MockBean PaymentService paymentService; @MockBean NotificationService notificationService; @MockBean AuditService auditService; // Test explosion waiting to happen}How to solve it?
I moved business logic into the entity itself. The entity now protects its own state:
@Entitypublic class Order { @Id private Long id;
private OrderStatus status; private BigDecimal total;
protected Order() {} // JPA only
// Factory method for creation public static Order create(BigDecimal total) { if (total == null || total.compareTo(BigDecimal.ZERO) <= 0) { throw new InvalidOrderTotalException(total); } Order order = new Order(); order.status = OrderStatus.PENDING; order.total = total; return order; }
// Behavior method - encapsulates the business rule public void cancel() { if (status != OrderStatus.PENDING) { throw new OrderCannotBeCancelledException(status); } this.status = OrderStatus.CANCELLED; }
public boolean canBeCancelled() { return status == OrderStatus.PENDING; }
public void applyDiscount(BigDecimal discount) { if (status == OrderStatus.CANCELLED) { throw new CannotDiscountCancelledOrderException(); } if (discount.compareTo(BigDecimal.ZERO) < 0) { throw new InvalidDiscountException(discount); } this.total = this.total.subtract(discount); }
// Only getters, no setters public OrderStatus getStatus() { return status; } public BigDecimal getTotal() { return total; }}
enum OrderStatus { PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED}Now the service becomes thin orchestration:
@Service@RequiredArgsConstructorpublic class OrderService { private final OrderRepository orderRepository; private final NotificationService notificationService;
public Order createOrder(BigDecimal total) { Order order = Order.create(total); // Logic in entity! return orderRepository.save(order); }
public void cancelOrder(Long orderId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new OrderNotFoundException(orderId)); order.cancel(); // Logic in entity! orderRepository.save(order); notificationService.notifyCancellation(order); }}Testing becomes simple without mocks:
class OrderTest {
@Test void shouldCreateOrderWithValidTotal() { Order order = Order.create(new BigDecimal("100.00"));
assertEquals(OrderStatus.PENDING, order.getStatus()); assertEquals(new BigDecimal("100.00"), order.getTotal()); }
@Test void shouldRejectNegativeTotal() { assertThrows(InvalidOrderTotalException.class, () -> Order.create(new BigDecimal("-50.00"))); }
@Test void shouldCancelPendingOrder() { Order order = Order.create(new BigDecimal("100.00"));
order.cancel();
assertEquals(OrderStatus.CANCELLED, order.getStatus()); }
@Test void shouldNotCancelAlreadyCancelledOrder() { Order order = Order.create(new BigDecimal("100.00")); order.cancel();
assertThrows(OrderCannotBeCancelledException.class, () -> order.cancel()); }}You can see that I succeeded to test the entity without any mocks or Spring context.
The reason
An anemic domain model is procedural code disguised as OOP. Entities are passive data containers, and services are giant procedure bags.
A rich domain model puts behavior where it belongs:
┌─────────────────────────────────────────────────────────────┐│ Anemic Domain Model │├─────────────────────────────────────────────────────────────┤│ ││ Order (data only) ────────▶ OrderService (all logic) ││ - getStatus() - cancelOrder() ││ - setStatus() - applyDiscount() ││ - getTotal() - validateOrder() ││ - calculateTotal() ││ ││ Entity is passive. Service is bloated. Tests need mocks. │└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐│ Rich Domain Model │├─────────────────────────────────────────────────────────────┤│ ││ Order (data + behavior) ────▶ OrderService (orchestrate) ││ - cancel() - cancelOrder() ││ - applyDiscount() (just loads, calls ││ - canBeCancelled() entity, saves) ││ ││ Entity protects itself. Service is thin. Tests are simple. │└─────────────────────────────────────────────────────────────┘Comparison
| Aspect | Anemic Model | Rich Domain Model |
|---|---|---|
| Logic location | Scattered in services | Co-located with data |
| Testing | Complex mocks | Pure unit tests |
| Code reuse | Copy-paste or utilities | Methods on entity |
| Invalid states | Possible via setters | Impossible by design |
Common mistakes
Mistake 1: Putting ALL logic in entities
Entities should have logic that’s intrinsic to the concept. Cross-entity orchestration belongs in services:
@Entitypublic class Order { // WRONG - infrastructure concerns public void sendEmail(EmailService emailService) { } public void callPaymentGateway(PaymentGateway gateway) { }}Mistake 2: Still using setters
@Entity@Getter @Setter // DON'T do thispublic class Order { private OrderStatus status;}
// Anyone can now do:order.setStatus(OrderStatus.CANCELLED); // Bypasses business rules!Mistake 3: Forgetting JPA requirements
JPA needs a no-arg constructor. Make it protected:
@Entitypublic class Order { protected Order() {} // JPA only
public static Order create(BigDecimal total) { // Factory method for actual creation }}Mistake 4: Not using value objects
private String email;private String phoneNumber;
// Good: Value objects with validationprivate Email email;private PhoneNumber phoneNumber;Summary
In this post, I showed how to fix an anemic domain model by moving business logic into entities. The key point is that entities should encapsulate behavior and protect their own state, not just hold data. This makes services thin orchestration layers and enables simple unit tests without mocks. Start by replacing setters with behavior methods, and refactor incrementally.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Martin Fowler: Anemic Domain Model
- 👨💻 Domain-Driven Design by Eric Evans
- 👨💻 Spring Framework Documentation: Domain Events
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments