Skip to content

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.

Order.java
@Entity
@Getter @Setter
public class Order {
private Long id;
private String status;
private BigDecimal total;
}
// Service has all the logic
@Service
public 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:

Multiple services with duplicate logic
@Service
public class OrderService {
public void cancelOrder(Order order) {
if (!"PENDING".equals(order.getStatus())) { ... }
}
}
@Service
public 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

OrderServiceTest.java
@SpringBootTest // Heavy context
class 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:

Order.java
@Entity
public 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:

OrderService.java
@Service
@RequiredArgsConstructor
public 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:

OrderTest.java
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

AspectAnemic ModelRich Domain Model
Logic locationScattered in servicesCo-located with data
TestingComplex mocksPure unit tests
Code reuseCopy-paste or utilitiesMethods on entity
Invalid statesPossible via settersImpossible 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:

Bad: Entity doing too much
@Entity
public class Order {
// WRONG - infrastructure concerns
public void sendEmail(EmailService emailService) { }
public void callPaymentGateway(PaymentGateway gateway) { }
}

Mistake 2: Still using setters

Bad: Setters expose internals
@Entity
@Getter @Setter // DON'T do this
public 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:

Correct JPA entity structure
@Entity
public class Order {
protected Order() {} // JPA only
public static Order create(BigDecimal total) {
// Factory method for actual creation
}
}

Mistake 4: Not using value objects

Bad: Primitive obsession
private String email;
private String phoneNumber;
// Good: Value objects with validation
private 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments