Rich Domain Model vs Anemic Domain model comparison

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.

Example of anemic and rich domain implementation

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:

  1. User credentials are userName and password
  2. If a user enters the wrong password 3 times in a row then the account is locked
  3. If a user enters the correct password then the counter of failed login attempts is reset
  4. Locked account can be unlocked by support staff

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.

Anemic domain model implementation

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.

Rich domain model implementation

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:

  • A user can try to log in into his UserAccount.
  • An attempt to login might be successful or not
  • After 3 failed login attempts account is locked
  • A locked account can be unlocked

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();
    }
}
Summary

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

References & literature
  1. Domain-Driven Design: Tackling Complexity in the Heart of Software, by Eric Evans, book
  2. Implementing Domain-Driven Design, by Vaughn Vernon, book
  3. Effective Aggregate Design by Vaughn Vernon
    1. Part I: Modeling a Single Aggregate
    2. Part II: Making Aggregates Work Together
    3. Part III: Gaining Insight Through Discovery
What we offerAbout UsTechnologiesWe're hiring!BlogContact
Timisoara, str. Cugir nr. 10
office@ozius.solutions
(+40)754931941
© Ozius Solutions S.R.L. 2023. All rights reserved.
Legal Entity