Anemic vs Rich Domain Models: Where Business Rules Should Live
#Backend

Anemic vs Rich Domain Models: Where Business Rules Should Live

Backend Reporter
8 min read

A small duplication problem in login checks exposes a larger design choice: whether your domain objects merely store state or actively protect business invariants.

Featured image

Problem

The DEV Community article uses a familiar Spring Boot example: a UserService checks whether a user is banned, enabled, or locked before allowing login. Then another service method repeats part of the same logic before updating a profile. At first, this looks like normal service-layer code. In practice, it is often the first sign that business rules are leaking into every workflow that touches the same entity.

This is the core tension between an Anemic Domain Model and a Rich Domain Model. In an anemic model, entities mostly expose fields through getters and setters. The service layer performs the work. In a rich model, entities own behavior that protects their own state. A User does not just have isBanned and lockoutTime fields. It has methods such as ban(), activate(), recordFailedLogin(), and canLogin().

The distinction matters because duplicated rules rarely stay synchronized. A system may start with two paths that check account state. Later, password reset, profile update, admin impersonation, token refresh, and session restoration all need similar checks. One path misses the new email verification rule. Another resets failed login attempts without clearing lockout state. The bug is not that a developer forgot a line. The bug is that the design required every caller to remember the same invariant.

This is the same failure mode distributed systems engineers see at larger scale. If every service owns its own interpretation of an invariant, the system eventually has several truths. One API says the account is active. Another treats it as locked. A background worker still sends notifications. A cache returns stale access state. The domain model is smaller than a distributed system, but the shape of the problem is familiar: consistency is not automatic, it has to be designed.

Solution approach

A rich domain model moves rules toward the state they govern. Instead of allowing arbitrary mutation through setters, it exposes behavior that represents valid transitions. For example, a User can be activated, banned, locked, or asked whether login is allowed. The entity becomes the consistency boundary for local business rules.

That lines up with the core idea behind Domain-Driven Design: model the software around the business concepts and rules that matter. Eric Evans popularized this approach in 2003, and the pattern remains useful because it gives teams a vocabulary for deciding where logic belongs. Martin Fowler’s older critique of the Anemic Domain Model is still relevant because many applications accidentally drift into procedural code wrapped around data containers.

A rich Spring Boot entity might avoid Lombok @Data and use @Getter with restricted constructors instead. That matters. @Data generates setters for every field, which means any caller can run user.setBanned(false) or user.setFailedLoginAttempts(0) without going through a rule. With a richer model, callers ask the entity to perform a business action, and the entity decides whether the transition is valid.

A service then becomes an application workflow coordinator rather than the owner of every rule. It loads the aggregate from a repository, calls domain behavior, persists the result, and coordinates outside concerns such as password hashing, messaging, transactions, or API responses. The service still matters, but it no longer becomes a dumping ground for every conditional branch.

That distinction also maps well to API design. A public API should usually expose actions that match intent, not raw state mutation. POST /users/{id}/ban or POST /sessions communicates a business operation. PATCH /users/{id} with { "isBanned": false, "failedLoginAttempts": 0 } exposes internal state and pushes too much responsibility to clients. The first style gives the server room to enforce rules. The second style makes every client a potential bypass path.

In larger systems, that API boundary becomes even more important. If user state is consumed by authentication, billing, notification, and analytics services, the command API should preserve invariants at the write boundary. Events can then publish facts such as UserBanned, UserActivated, or LoginFailedRecorded. Consumers react to those facts, but they do not decide whether the state transition was legal. That separation reduces rule drift.

Scalability and consistency implications

A rich domain model does not make distributed consistency disappear. It gives you a cleaner local consistency boundary. In DDD terms, that boundary is usually an aggregate. Inside the aggregate, rules should be enforced transactionally. Outside it, consistency may become eventual.

For example, locking an account after five failed login attempts is a local invariant. The current count and lockout time belong together, and they should be updated atomically. If multiple login attempts arrive concurrently, the implementation also needs a database-level strategy: optimistic locking with a version column, pessimistic locking for high-risk flows, or an atomic update statement. Otherwise, two requests can both read four failed attempts, each write five, and lose part of the history.

This is where rich modeling and database behavior have to cooperate. A method like recordFailedLogin() describes the rule, but the repository and transaction boundary decide whether concurrent execution preserves it. In a Spring Boot application, that means understanding Spring transaction management, JPA entity lifecycle rules, and the locking support offered by Hibernate ORM.

Read paths introduce another trade-off. A rich entity may be ideal for commands, but query-heavy systems often use projections, read models, or DTOs. A login command needs the behavior-rich User aggregate. A dashboard listing 10,000 users by status probably does not. Mixing those concerns can create performance problems, especially when ORM mappings trigger unnecessary lazy loads. This is why many systems combine rich command models with simpler read models, similar to CQRS without adopting a heavy framework.

Caching adds another layer. If canLogin() depends on fields stored in the primary database, but an API gateway or session service caches access decisions, the system needs an invalidation story. A ban operation that updates the database but leaves a token cache warm for ten minutes is not a domain model failure by itself. It is a consistency model decision. For security-sensitive paths, stale authorization is usually unacceptable, so the design may need short-lived tokens, central introspection, event-driven revocation, or a synchronous check against the account service.

The practical lesson is that rich domain models protect invariants where the write happens. They do not automatically protect every replica, cache, queue, or client. The farther state travels from the aggregate, the more explicit the consistency contract has to be.

Trade-offs

An anemic model is not always wrong. It can be simple, predictable, and efficient for CRUD-heavy applications where behavior is thin and rules are mostly field validation. Admin screens, reference tables, import tools, and internal data maintenance workflows may not justify rich entities. In those cases, putting logic in services can be acceptable if the team keeps the boundaries clear.

The cost appears when the domain starts accumulating rules. Account state, payment authorization, inventory reservation, subscription lifecycle, order fulfillment, and access control are all areas where state transitions matter. If those rules live as repeated conditionals across services, the system becomes harder to reason about. Tests also become scattered because every workflow has to prove it remembered the same rule set.

A rich model improves locality. The rule has one home. Tests can target behavior directly: banned users cannot log in, failed attempts lock the account, activation cannot run twice, and lockout expiry changes access. That gives the team executable documentation around the business policy.

The trade-off is that rich models require discipline. Entities should not start sending emails, calling payment providers, reading HTTP headers, or publishing Kafka messages. Those are application or infrastructure concerns. A good rich entity protects its own state and expresses domain decisions. It should stay persistence-aware only to the degree required by the ORM, and even that can become awkward with frameworks such as JPA that require protected constructors and proxy-friendly mappings.

There is also a testing trade-off. Rich entities are easier to unit test because they have behavior without infrastructure. Application services still need integration tests for transactionality, repository behavior, and API contracts. The model clarifies what to test at each layer, but it does not remove the need for layered verification.

API design follows the same compromise. Command-style endpoints make invariants easier to enforce, but clients sometimes need partial updates. A pragmatic design can support both by reserving raw patch operations for low-risk profile fields while using explicit commands for sensitive transitions. Updating a display name is not the same category of operation as unbanning an account or resetting failed login counters.

The article’s central example is small, but the design pressure is real. Repeated if statements in service methods are not only duplication. They are a signal that the system has no clear owner for a business invariant. Once that happens, scale makes the problem louder. More endpoints, more services, more caches, more async consumers, and more developers all increase the chance that one path will apply yesterday’s rules.

A rich domain model is not a silver bullet. It is a way to make illegal states harder to create and business rules harder to skip. For systems with meaningful lifecycle rules, that is usually a better default than treating entities as public structs with database annotations.

Comments

Loading comments...