Anemic Domain Model vs. Rich Domain Model

Matthias Schenk
11 min readJan 14, 2023

--

Taken from https://www.oreilly.com/

At the moment I’m reading the “Implementing Domain Driven Design” book of Vaughn Vernon and for a better undestanding I try to implement the different concepts and patterns by example. The domain model is a central part of the domain driven design and therefore I want to dedicate the first article to it.

In this article I want to compare two different approaches to design and work with a domain model. The first one uses an anemic domain model and the second one a rich domain model according to domain driven design.

A domain model is a visual representation of real situation objects in a domain (like a banking account in the bank domain). A domain is an area of concern. Its used to refer to the area you are dealing with. A domain normally consists of multiple different real world objects, that are related to each other.

So as an example, if your domain is an webshop selling electronic devices, the user account is such a domain model. It represent the account of an user with all related data (e.g. reference to owning user, account type, reference to payment information, current balance) and all its related use cases (e.g. add payment information, add purchase).

To show both different approaches I first need an example domain. I start with describing part of a domain, with requirements and use-cases, which should be modeled.

Domain

For the used domain I come back to the above mentioned webshop example. I don’t want to cover the complete domain, because that is out of scope of this article. The focus is the user domain model. There are the following requirements for an user :

  • Needs to have an unique username.
  • Needs to have a password.
  • Contains contact details consists of first name, last name, street, street number, zip code and city.
  • The user can have multipe payment information types.
  • A payment information consists of payment type and additional information depending on type (e.g. paypal needs to specify the mail address of paypal account).
  • The user has a history of purchasing/retoure transactions

There are also requirements for parts of the user domain model. It’s not important what concrete requirements that are, just that a kind of validation needs to take part. Following parts are affected:

  • user password
  • contact details
  • amount of payment information
  • payment information
  • amount of items in transactions

There are the following use-case available for the user domain model:

  • The user can set a new password.
  • The user can update the contact details.
  • The user can add a new payment information type
  • The user can delete payment informations.
  • The user can add a transaction.

This requirements for the user should be represented in code. The focus should be on how the use-cases are implemented, not on providing a full working application example.

So let’s start with the implementation…

Data Objects

In the first step I define the DTO objects, which are used as input for calling the business service, which is then dealing with the domain model.

data class UserDto(
val username: String,
val password: String,
)

data class ContactDetailsDto(
val street: String,
val streetNumber: String,
val zipCode: Int,
val city: String
)

sealed interface PaymentInformationDto{
val id: UUID

data class PaypalPaymentInformationDto(
override val id: UUID,
val mailAddress: String
) : PaymentInformationDto

data class BankPaymentInformationDto(
override val id: UUID,
val iban: String,
val owner: String
) : PaymentInformationDto

data class CreditCardPaymentInformationDto(
override val id: UUID,
val number: String,
val owner: String,
val securityNumber: Int
) : PaymentInformationDto
}

data class TransactionDto(
val amount: Double,
val items: List<String>
)

I use data classes for the representation, because it’s an easy way to create objects which are by default treated as different depending on the values of the properties. With this I don’t have to deal with equals()/hashCode(). It also provides an immutable data structure with a copy(…) constructor method for creation of new objects on basis of existing ones.

For the PaymentInformationDto I use a sealed interface to model the different concrete implementations and limit it to the provided three ones.

This DTO classes are used in both realization versions of the domain model. I start with the anemic domain model (whose name is related to the anti-pattern mentioned later in the article).

Anemic Domain Model

An anemic domain model is characterized by containing only less or no business logic. The business logic itself is mainly part of the service, which is working on top of the domain model.

The domain model therefore looks very clean. Just slightly different from the DTO classes.

data class User(
val username: String,
val password: String,
val contactDetails: ContactDetails,
val paymentInformation: List<PaymentInformation>,
val transactions: List<Transaction> = emptyList()
)

data class ContactDetails(
val street: String,
val streetNumber: String,
val zipCode: Int,
val city: String
)

sealed interface PaymentInformation {
val id: UUID

data class PaypalPaymentInformation(
override val id: UUID,
val mailAddress: String
) : PaymentInformation

data class BankPaymentInformation(
override val id: UUID,
val iban: String,
val owner: String
) : PaymentInformation

data class CreditCardPaymentInformation(
override val id: UUID,
val number: String,
val owner: String,
val securityNumber: Int
) : PaymentInformation
}

enum class TransactionType {
BUY,
RETOURE
}

data class Transaction(
val transactionType: TransactionType,
val amount: FastMoney,
val items: List<String>
)

There are just 2 changes compared to the DTO classes, which I’ve done (to simulate a kind of real world requirement). I used a TransactionType type for separte BUY from RETOURE transactions and I used a FastMoney type for representation for the amount of a transaction.

As you can see the domain model contains no business logic, it’s just a data holder. The business logic completely is part of the related service. To have a clear api, I define an interface with the required use-cases:

interface UserService {
fun createUser(userDto: UserDto, contactDetailsDto: ContactDetailsDto, paymentInformationDto: PaymentInformationDto): User
fun updatePassword(username: String, password: String): User
fun updateContactDetails(username: String, contactDetailsDto: ContactDetailsDto): User
fun addPaymentInformation(username: String, paymentInformationDto: PaymentInformationDto): User
fun removePaymentInformation(username: String, paymentInformationId: UUID): User
fun addTransaction(username: String, transactionDto: TransactionDto): User
}

Because I don’t want to handle the connection to a persistence storage inside the UserService, I add an interface, which just contains the methods which are necessary to deal with persistence, there is no implementation available.

interface UserRepository {

fun isUsernameAlreadyUsed(username: String): Boolean

fun save(user: User): User

fun findBy(username: String): User?

fun update(user: User): User
}

In the next step I add the implementation of the UserService.

class UserServiceImpl(
private val userRepository: UserRepository
) : UserService {
override fun createUser(userDto: UserDto, contactDetailsDto: ContactDetailsDto, paymentInformationDto: PaymentInformationDto): User {
if (userRepository.isUsernameAlreadyUsed(userDto.username)) {
throw IllegalArgumentException("User with username '${userDto.username}' already exists.")
}
validatePassword(userDto.password)
validateContactDetails(contactDetailsDto)
validatePaymentInformation(paymentInformationDto)

return userRepository.save(mapToUser(userDto, contactDetailsDto, paymentInformationDto))
}

private fun mapToUser(userDto: UserDto, contactDetailsDto: ContactDetailsDto, paymentInformationDto: PaymentInformationDto): User {
return User(
username = userDto.username,
password = userDto.password,
contactDetails = ContactDetails(
street = contactDetailsDto.street,
streetNumber = contactDetailsDto.streetNumber,
zipCode = contactDetailsDto.zipCode,
city = contactDetailsDto.city
),
paymentInformation = listOf(
paymentInformationDto.toPaymentInformation()
)
)
}

private fun validatePassword(password: String) {
require(password.length >= MINIMUM_PASSWORD_LENGTH) {
"Password (current: ${password.length}) must be at minimum 16 characters long."
}
require(password.contains(lowerCaseCharacterRegex)) {
"Password must contain at minimum 1 lowercase character."
}
require(password.contains(upperCaseCharacterRegex)) {
"Password must contain at minimum 1 uppercase character."
}
require(password.contains(specialCharacterRegex)) {
"Password must contain at minimum 1 special character of (!&%?<>-)."
}
}

private fun validateContactDetails(contactDetailsDto: ContactDetailsDto) {
require(contactDetailsDto.streetNumber.isNotEmpty()) {
"Street number must not be empty."
}
require(contactDetailsDto.street.isNotEmpty()) {
"Street must not be empty."
}
require(contactDetailsDto.zipCode in 10000..99999) {
"Zip code must be within range of 10.000 to 99.999."
}
require(contactDetailsDto.city.isNotEmpty()) {
"City must not be empty."
}
}

private fun validatePaymentInformation(paymentInformationDto: PaymentInformationDto) {
when (paymentInformationDto) {
is PaymentInformationDto.BankPaymentInformationDto -> {
require(paymentInformationDto.iban.matches(Regex("^DE[0-9]{20}\$"))) {
"IBAN has not the correct format."
}
require(paymentInformationDto.owner.isNotEmpty()) {
"Bank owner must not be empty."
}
}

is PaymentInformationDto.CreditCardPaymentInformationDto -> {
require(paymentInformationDto.number.matches(Regex("^[0-9]{4}([ -]?[0-9]{4}){3}\$"))) {
"Credit card number has not the correct format."
}
require(paymentInformationDto.owner.isNotEmpty()) {
"Credit card owner must not be empty."
}
}

is PaymentInformationDto.PaypalPaymentInformationDto -> {
require(paymentInformationDto.mailAddress.matches(Regex("\"^[A-Za-z](.*)(@)(.+)(\\\\.)(.+)\""))) {
"Mail address has not the correct format."
}
}
}
}

override fun updatePassword(username: String, password: String): User {
...
}

override fun updateContactDetails(username: String, contactDetailsDto: ContactDetailsDto): User {
...
}

override fun addPaymentInformation(username: String, paymentInformationDto: PaymentInformationDto): User {
...
}

override fun removePaymentInformation(username: String, paymentInformationId: UUID): User {
...
}

override fun addTransaction(username: String, transactionDto: TransactionDto): User {
...
}

...
}

All the requirements for the domain model and the use-cases are implemented in the service. The DTO input objects are validated and then the repository is used for working with the persistence storage. I just show section of the service, to keep the code part short. But I think it’s clear how the other methods are implemented in the same way.

Advantages:

  • All business logic is handled in one place.
  • All business logic can be tested with the service.

Disadvantages:

  • If using domain models without the service, it is possible to create not valid instances. So in all places using the domain model I need to check for its validity.
  • The domain model does not represent the behavior of the real world object. It contains the data but not the behavior.

Martin Fowler named the Anemic Domain Model an anti-pattern. The domain model is using a real name of the domain space but not containing any behavior describing the use-cases. The behavior (validation, business rules, calculations) is all part of services, that live on top of the domain model. This is contrary to the concept of object-oriented design, where objects represent both data and behavior.

It’s important to mention, that Martin Fowler does not mean, moving all functionality to the domain model and not having any business service at all. There should still be a kind of layering. Tasks like transaction handling or persistence should still be done from separate components, not the domain model itself.

Summing it up, there should be a thin service layer and a rich domain model, containing the key business logic.

So in the second approach I implement the same requirements, that are used for the anemic domain model approach, using a rich domain model version.

Rich Domain Model

The rich domain model is characterized by combining data and behavior and only keeping a thin service above.

Instead of being only a holder of data, regardless of whether valid or not, the domain model is responsible for representation of the use-cases and care about the requirements for valid internal state. It should not be possible to create an object with an invalid state. So I need to add the parts, which are done by the service in the anemic domain model example, related to validation of input, to the initialization part of the domain model.

class User(
val username: String,
val password: String,
val contactDetails: ContactDetails,
val paymentInformation: List<PaymentInformation>,
val transactions: List<Transaction> = emptyList()
){
init {
PasswordValidator.validatePassword(password)

require(paymentInformation.isNotEmpty()) {
"There must be at minimum one payment information available."
}
}
}

private const val MINIMUM_PASSWORD_LENGTH = 16

private val lowerCaseCharacterRegex= Regex("[a-z]+")
private val upperCaseCharacterRegex= Regex("[A-Z]+")
private val specialCharacterRegex= Regex("[!&%?<>-]+")

object PasswordValidator {

fun validatePassword(password: String) {
require(password.length >= MINIMUM_PASSWORD_LENGTH) {
"Password (current: ${password.length}) must be at minimum 16 characters long."
}
require(password.contains(lowerCaseCharacterRegex)) {
"Password must contain at minimum 1 lowercase character."
}
require(password.contains(upperCaseCharacterRegex)) {
"Password must contain at minimum 1 uppercase character."
}
require(password.contains(specialCharacterRegex)) {
"Password must contain at minimum 1 special character of (!&%?<>-)."
}
}
}

...

So I add the validation for the particluar domain model to the init-Block of the corresponding class. With this it is not possible to create an instance, which is not treated as valid according to the business rules. It is possible to move parts of the validation to separate classes, to not let the domain model grow too much. Therefor I moved the password validation to an own object. Again in above code I only show sections of the domain model to keep the code example short.

To make the creation of the user domain model more explicit, I add a factory method and make the constructor private. The creation of user can now only be done using this factory.

companion object {
fun createFrom(
userDto: UserDto, contactDetailsDto: ContactDetailsDto, paymentInformationDto: PaymentInformationDto
) = User(
username = userDto.username,
password = userDto.password,
contactDetails = contactDetailsDto.toContactDetails(),
paymentInformation = listOf(
paymentInformationDto.toPaymentInformation()
)
)
}

Instead of moving the use-cases to the service on top of the domain model, they are part of it. The information about how this is done is encapsulated inside the domain model.

 fun updatePassword(newPassword: String) = User(
username = this.username,
password = newPassword,
contactDetails = this.contactDetails,
paymentInformation = this.paymentInformation,
transactions = this.transactions
)

fun updateStreetNumber(newStreetNumber: String) = User(
username = this.username,
password = this.password,
contactDetails = ContactDetails(
street = this.contactDetails.street,
streetNumber = newStreetNumber,
zipCode = this.contactDetails.zipCode,
city = this.contactDetails.city
),
paymentInformation = this.paymentInformation,
transactions = this.transactions
)

...

fun addBankPayment(iban: String, owner: String) = User(
username = this.username,
password = this.password,
contactDetails = this.contactDetails,
paymentInformation = this.paymentInformation + PaymentInformation.BankPaymentInformation(
id = UUID.randomUUID(),
iban = iban,
owner = owner,
),
transactions = this.transactions
)

fun removePaymentInformation(paymentInformationId: UUID) = User(
username = this.username,
password = this.password,
contactDetails = this.contactDetails,
paymentInformation = this.paymentInformation.filter { it.id != paymentInformationId },
transactions = this.transactions
)

fun addTransaction(amount: Double, items: List<String>) = User(
username = this.username,
password = this.password,
contactDetails = this.contactDetails,
paymentInformation = this.paymentInformation,
transactions = this.transactions + Transaction(
transactionType = if (amount > 0) TransactionType.BUY else TransactionType.RETOURE,
amount = amount,
items = items
)
)

The service working with the domain model does not need to know how to update the internal state, the model itself cares about this.

The repository part is very similar to the anemic domain model example, I just move the handling of missing user to it. With this the business service does not need to handle the missing user case.

interface UserRepository {

fun isUsernameAlreadyUsed(username: String): Boolean

fun save(user: User): User

fun getBy(username: String): User

fun update(user: User): User
}

The last part which is missing is the implementation of the business service. Because the main logic is already part of the domain model, the service is very clearly arranged.

class UserServiceImpl(
private val userRepository: UserRepository
) : UserService {
override fun createUser(userDto: UserDto, contactDetailsDto: ContactDetailsDto, paymentInformationDto: PaymentInformationDto): User {
val user = User.createFrom(userDto, contactDetailsDto, paymentInformationDto)
return userRepository.save(user)
}

override fun updatePassword(username: String, password: String): User {
val user = userRepository.getBy(username)
return user.updatePassword(password)
}

override fun updateContactDetails(username: String, contactDetailsDto: ContactDetailsDto): User {
val user = userRepository.getBy(username)
return user.updateStreet(contactDetailsDto.street)
.updateStreetNumber(contactDetailsDto.streetNumber)
.updateCity(contactDetailsDto.city)
.updateZipCode(contactDetailsDto.zipCode)
}

override fun addPaymentInformation(username: String, paymentInformationDto: PaymentInformationDto): User {
val user = userRepository.getBy(username)
return when (paymentInformationDto) {
is PaymentInformationDto.PaypalPaymentInformationDto -> user.addPaypalPayment(
mailAddress = paymentInformationDto.mailAddress
)

is PaymentInformationDto.BankPaymentInformationDto -> user.addBankPayment(
iban = paymentInformationDto.iban,
owner = paymentInformationDto.owner
)

is PaymentInformationDto.CreditCardPaymentInformationDto -> user.addCreditCardPayment(
number = paymentInformationDto.number,
owner = paymentInformationDto.owner,
securityNumber = paymentInformationDto.securityNumber
)
}
}

override fun removePaymentInformation(username: String, paymentInformationId: UUID): User {
val user = userRepository.getBy(username)
return user.removePaymentInformation(paymentInformationId = paymentInformationId)
}

override fun addTransaction(username: String, transactionDto: TransactionDto): User {
val user = userRepository.getBy(username)
return user.addTransaction(
amount = transactionDto.amount,
items = transactionDto.items
)
}

}

The only task which remains is the orchestration between the input of the service consumer, the domain model and the repository.

Advantages:

  • The domain model represents both the data and the behavior. So it’s close to the real world object.
  • The domain model enforces valid state.
  • The service working on top of the domain model does not need to know about interna of the domain model and how it works.
  • Encapsulation of data inside the domain model.
  • Better separation of responsibility between domain model and service, also when testing.

Disadvantage:

  • It takes more time to think about which functionality should be part of the domain model and which belongs to the service/other components.

Summary

There are mainly 3 arguments against the anemic domain model approach, which leads to recommending the rich domain model.

  • Separation of data and behavior leads to lack of discoverability. This means when looking on the domain model it is not clear which functionality it provides, because this may be distributed over different services/components.
  • The lack of discoverability itself leads to risk of duplication. The functionality is spread accross multiple places and therefore existing methods are overlooked and again implemented (in worst case in a slightly different version).
  • Missing encapsulation. The service working on the domain model needs to know about interna of the domain model inorder to implement the use-cases.

The full code for this article can be found in the Github repository: https://github.com/PoisonedYouth/kotlin-domain-driven-design

--

--