Last updated on 20/10/2023
The anemic domain model does not incorporate business logic into entities. All the business logic lives in Domain Services while entities are just data bags.
In order to compare how Anemic and Rich Domain Model differs and what the advantages are, we will implement the same problem using both approaches.
Problem statement: Implement basic user login functionality. A User can try to log in into the system. If the user enters valid credentials then login is successful. If the user enters the wrong credentials then the account is locked after 3 failed attempts. A locked account can be unlocked by support staff.
Requirements:
userName
and password
NOTE: for simplicity reasons, the password is stored as plain text. In real-world applications, we would store the hash of the password, but to keep things simple in this example we'll work with the plain password.
In the Anemic Domain Model entities do not have any business logic and usually expose public getters and setters. Business logic is placed in Domain Services instead.
First, let's create UserAccount
entity which stores user account attributes in the database.
public class UserAccount { private String password; private boolean isLocked; private int failedLoginAttempts; private String userName; public String getPassword() { return password; } public boolean getIsLocked() { return isLocked; } public int getFailedLoginAttempts() { return failedLoginAttempts; } public String getUserName() { return userName; } public void setPassword(String password) { this.password = password; } public void setIsLocked(boolean isLocked) { this.isLicked = isLocked; } public void setFailedLoginAttempts(boolean failedLoginAttempts) { this.failedLoginAttempts = failedLoginAttempts; } public void setUserName(String userName) { this.userName = userName; } }
Now let's create a domain service for business logic.
enum LoginResult { SUCCESS, INVALID_CREDENTIALS, ACCOUNT_LOCKED } class AuthenticationService { // constructor and private fields code omitted for simplicity public LoginResult loginUser(String userName, String password) { UserAccount account = accountRepository.getByUserName(userName); if (account == null) { return LoginResult.INVALID_CREDENTIALS; } if (account.getIsLocked()) { return LoginResult.ACCOUNT_LOCKED; } if (account.getPassword().equals(password)) { account.setFailedLoginAttempts(0); return LoginResult.SUCCESS; } int newFailedLoginAttempts = account.getFailedLoginAttempts() + 1; account.setFailedLoginAttempts(newFailedAttepts); if (newFailedLoginAttempts >= 3) { account.setIsLocked(true); return LoginResult.ACCOUNT_LOCKED; } return LoginResult.INVALID_CREDENTIALS; } public void unlockAccount(String userName){ UserAccount account = accountRepository.getByUserName(userName); if (account == null) { throw new DomainException("User with given userName does not exist"); } account.setIsLocked(false); account.setFailedLoginAttempts(0); } }
As can be seen in the previous example, all business logic is placed in Domain Service and Entity is just a DTO that transfers data from DB to application.
Now let's try to enrich the model from the previous example. We'll remove public setters and add update
method with some logic in it.
public class UserAccount { private String password; private boolean isLocked; private int failedLoginAttempts; private String userName; public String getPassword() { return password; } public boolean getIsLocked() { return isLocked; } public int getFailedLoginAttempts() { return failedLoginAttempts; } public String getUserName() { return userName; } public void update(String password, boolean isLocked, int failedLoginAttempts, String userName) { if(password == null || password == "") { throw new IllegalArgumentException("password must be a non empty string"); } if(failedLoginAttempts < 0) { throw new IllegalArgumentException("failedLoginAttempts must be a greater or equal to 0"); } if(userName == null || userName == ""){ throw new IllegalArgumentException("userName must be a non empty string"); } this.password = password; this.isLocked = isLocked; this.failedLoginAttempts = failedLoginAttempts; this.userName = userName; } }
With these changes AuthenticationService
becomes
class AuthenticationService { // constructor and private fields code omitted for simplicity public LoginResult loginUser(String userName, String password) { UserAccount account = accountRepository.getByUserName(userName); if (account == null) { return LoginResult.INVALID_CREDENTIALS; } if (account.getIsLocked()) { return LoginResult.ACCOUNT_LOCKED; } if (account.getPassword().equals(password)) { account.update(account.getPassword(), account.getIsLocked(), 0, account.getUserName()); return LoginResult.SUCCESS; } int newFailedLoginAttempts = account.getFailedLoginAttempts() + 1; account.update(account.getPassword(), account.getIsLocked(), newFailedAttepts, account.getUserName()); if (newFailedLoginAttempts >= 3) { account.update(account.getPassword(), true, newFailedAttepts, account.getUserName()); return LoginResult.ACCOUNT_LOCKED; } return LoginResult.INVALID_CREDENTIALS; } public void unlockAccount(String userName){ UserAccount account = accountRepository.getByUserName(userName); if (account == null) { throw new DomainException("User with given userName does not exist"); } account.update(account.getPassword(), false, 0, account.getUserName()); } }
Is this version better than the first one? Not really,
it is the same anemic domain model with most of the business logic outside the entity.
Even though we don't have public setters, update
method acts as one big public setter.
Another problem with this approach is that the update
method is not part of our ubiquitous language
-
a user can try to log in, the account can be locked or unlocked, and so on, but update
is
not part of this language and the entity does not explicitly tell
what are the operations which can be done with it.
In Rich Domain Model, an entity does not expose public setters or update methods.
Instead, an entity exposes methods that change the entity state from one valid state to another.
More than that - public methods are named using words from ubiquitous language
.
It allows developers and Domain experts to speak the same language.
Also, new team members can learn ubiquitous language by reading source code.
Let's take a look at how UserAccount
entity can look in Rich Domain Model.
public class UserAccount { private String password; private boolean isLocked; private int failedLoginAttempts; private String userName; public boolean getIsLocked() { return isLocked; } public int getFailedLoginAttempts() { return failedLoginAttempts; } public String getUserName() { return userName; } public boolean tryLogin(String password) { if(this.isLocked){ throw new IllegalStateException("Account is locked"); } if( this.password.equals(password)) { this.failedLoginAttempts = 0; return true; } this.failedLoginAttempts++; if(this.failedLoginAttempts >= 3) { this.isLocked = true; } return false; } public void unlockAccount(){ this.isLocked = false; this.failedLoginAttempts = 0; } }
Now UserAccount
entity clearly defines what are operations (use cases) which change the state
of it. We use terms from ubiquitous language
like tryLogin
and unlockAccount
for method names which makes it easier to understand what are the valid use cases when
an entity changes. Think about these statements:
In Rich Domain Model we hardcode valid uses cases that change our entity.
Also, it makes developers use the same language as domain experts -
compare tryLogin
with setIsLocked
or update
.
We also removed the public getter getPassword
from AccountEntity
because we
don't want to show the password on UI.
Now let's take a look at how AuthenticationService
changes.
class AuthenticationService { // constructor and private fields code omitted for simplicity public LoginResult loginUser(String userName, String password) { UserAccount account = accountRepository.getByUserName(userName); if (account == null) { return LoginResult.INVALID_CREDENTIALS; } if (account.getIsLocked()) { return LoginResult.ACCOUNT_LOCKED; } boolean loginSuccess = account.tryLogin(password); if(loginSuccess) { return LoginResult.SUCCESS; } if (account.getIsLocked()) { return LoginResult.ACCOUNT_LOCKED; } return LoginResult.INVALID_CREDENTIALS; } public void unlockAccount(String userName){ UserAccount account = accountRepository.getByUserName(userName); if (account == null) { throw new DomainException("User with given userName does not exist"); } account.unlockAccount(); } }
DDD implemented with Rich Domain Model encapsulates business logic into entities and exposes ubiquitous language
as public interface which allows model state manipulation. It helps developers and domain experts to ask more questions and better understand Domain. Also, it makes all team members (developers and domain experts) to use same language - ubiquitous language