The Anemic Domain Model in Kotlin — explained

Andrew Larsen
7 min readDec 19, 2023

--

Does the below code look familiar?

data class Product(val id: Int, var name: String, var price: Double)

class ProductService(val id: Int, var name: String, private var price: Double) {
fun applyDiscount(discountRate: Double) {
if (discountRate > 0) {
price -= price * discountRate
}
}
}

So, what are we looking at? This is an example of the “anti-pattern”, coined originally by Martin Fowler, as the Anemic Domain Model.

The Anemic Domain Model

In essence, the Anemic Domain Model involves creating domain classes that don’t actually contain business logic. These models contain the data of our domain, but are manipulated by outside actors. These actors are often called “Service” classes.

So, why is this a problem? I can cite various “best practice” reasons why we shouldn’t do this like that it breaks encapsulation, but in practice, the number one reason I avoid this pattern is because it makes testing too hard.

As an aside, I hate the term “best practice”. It’s a tool, too often used, to try and make a point without having the necessary knowledge to back it up.

Why ADM’s make testing hard

Let’s take a look at how we might test our ProductService class. The logic is pretty simple, so let’s just write two junit tests to test:

  1. Applying a Valid Discount: This test will apply a positive discount rate and verify if the price is reduced correctly.
  2. Applying a Zero or Negative Discount: This test will apply a zero or negative discount rate and verify that the price remains unchanged.
import org.junit.Assert.assertEquals
import org.junit.Test

class ProductServiceTest {

@Test
fun `apply discount reduces price for positive discount rate`() {
// Arrange
val product = Product(id = 1, name = "Test Product", price = 100.0)
val productService = ProductService(product)

// Act
productService.applyDiscount(0.1) // 10% discount

// Assert
val expectedPrice = 90.0 // 100 - 10% of 100
assertEquals("Price should be reduced by 10%", expectedPrice, product.price, 0.001)
}

@Test
fun `apply discount does not change price for non-positive discount rate`() {
// Arrange
val product = Product(id = 2, name = "Another Test Product", price = 200.0)
val productService = ProductService(product)

// Act
productService.applyDiscount(0.0) // 0% discount

// Assert
val expectedPrice = 200.0
assertEquals("Price should remain unchanged", expectedPrice, product.price, 0.001)
}
}

Well that doesn’t look too bad, so what’s the problem? Unfortunately, the problems we solve in the real world aren’t this simple. As the complexity of our system grows, we begin to add more domains. A Product has Inventory, Inventory has a Location, etc. The use cases we need to support (i.e business logic) will also continue to expand. In addition, we will begin to have external sources for our data, such as APIs and Databases. If we follow our same Service-based pattern, this will cause our service classes to quickly become bloated. Let’s add some of these additional domains, use cases, and external services to our app:

data class Product(val id: Int, var name: String, var price: Double, var isDiscontinued: Boolean = false)
data class InventoryLocation(val id: Int, val name: String)
data class ProductInventory(val product: Product, val location: InventoryLocation, var quantity: Int)

class ProductService(
private val productRepository: ProductRepository,
private val productInventoryRepository: ProductInventoryRepository
) {
fun applyDiscount(productId: Int, discountRate: Double) {
val product = productRepository.findById(productId)
product?.let {
if (discountRate > 0) {
it.price -= it.price * discountRate
productRepository.save(it)
}
}
}

fun addInventory(productId: Int, locationId: Int, quantity: Int) {
val inventory = productInventoryRepository.findByProductIdAndLocationId(productId, locationId)
if (inventory != null) {
inventory.quantity += quantity
productInventoryRepository.save(inventory)
} else {
// Handle the case where the inventory item doesn't exist, e.g., create a new entry
}
}

fun removeInventory(productId: Int, locationId: Int, quantity: Int) {
val inventory = productInventoryRepository.findByProductIdAndLocationId(productId, locationId)
inventory?.let {
it.quantity = (it.quantity - quantity).coerceAtLeast(0)
productInventoryRepository.save(it)
}
}

fun discontinueProduct(productId: Int) {
val product = productRepository.findById(productId)
product?.let {
it.isDiscontinued = true
productRepository.save(it)
}
}
}

We’ve now introduced Repository classes which are responsible for reading and writing the Product & Inventory data to our third party systems. Our class is slowly growing in complexity. While I can still mostly grok what this class is doing, let’s take a look at what our tests look like:

import org.junit.Before
import org.junit.Test
import org.mockito.Mockito.*
import org.junit.Assert.*

class ProductServiceTest {

private lateinit var productService: ProductService
private lateinit var productRepository: ProductRepository
private lateinit var productInventoryRepository: ProductInventoryRepository

@Before
fun setUp() {
productRepository = mock(ProductRepository::class.java)
productInventoryRepository = mock(ProductInventoryRepository::class.java)
productService = ProductService(productRepository, productInventoryRepository)
}

@Test
fun `apply discount should reduce product price`() {
val product = Product(1, "Test Product", 100.0)
`when`(productRepository.findById(1)).thenReturn(product)

productService.applyDiscount(1, 0.1) // 10% discount

verify(productRepository).save(product)
assertEquals(90.0, product.price, 0.001)
}

@Test
fun `add inventory should increase quantity at location`() {
val product = Product(2, "Another Product", 200.0)
val location = InventoryLocation(1, "Warehouse")
val productInventory = ProductInventory(product, location, 20)
`when`(productInventoryRepository.findByProductIdAndLocationId(2, 1)).thenReturn(productInventory)

productService.addInventory(2, 1, 30)

verify(productInventoryRepository).save(productInventory)
assertEquals(50, productInventory.quantity)
}

@Test
fun `remove inventory should decrease quantity at location`() {
val product = Product(3, "Third Product", 300.0)
val location = InventoryLocation(1, "Warehouse")
val productInventory = ProductInventory(product, location, 40)
`when`(productInventoryRepository.findByProductIdAndLocationId(3, 1)).thenReturn(productInventory)

productService.removeInventory(3, 1, 10)

verify(productInventoryRepository).save(productInventory)
assertEquals(30, productInventory.quantity)
}

@Test
fun `discontinue product should mark product as discontinued`() {
val product = Product(4, "Fourth Product", 400.0)
`when`(productRepository.findById(4)).thenReturn(product)

productService.discontinueProduct(4)

verify(productRepository).save(product)
assertTrue(product.isDiscontinued)
}
}

In order to isolate the scenarios we want to test, we’ve had to introduce additional setup and mocking libraries. And, as the system continues to grow, the test setup complexity will only get worse!

So, what can we do about it? Let’s pump up our domain models! We can rewrite our app to push the business logic out of the service class and into our models

data class Product(val id: Int, var name: String, var price: Double, var isDiscontinued: Boolean = false) {
fun applyDiscount(discountRate: Double) {
if (discountRate > 0) {
price -= price * discountRate
}
}

fun discontinue() {
isDiscontinued = true
}
}

data class InventoryLocation(val id: Int, val name: String)

data class ProductInventory(val product: Product, val location: InventoryLocation, var quantity: Int) {
fun add(amount: Int) {
if (amount > 0) {
quantity += amount
}
}

fun remove(amount: Int) {
if (amount > 0) {
quantity = (quantity - amount).coerceAtLeast(0)
}
}
}

class ProductService(
private val productRepository: ProductRepository,
private val productInventoryRepository: ProductInventoryRepository
) {
fun applyDiscount(productId: Int, discountRate: Double) {
val product = productRepository.findById(productId)
product?.applyDiscount(discountRate)
product?.let { productRepository.save(it) }
}

fun addInventory(productId: Int, locationId: Int, quantity: Int) {
val inventory = productInventoryRepository.findByProductIdAndLocationId(productId, locationId)
inventory?.add(quantity)
inventory?.let { productInventoryRepository.save(it) }
}

fun removeInventory(productId: Int, locationId: Int, quantity: Int) {
val inventory = productInventoryRepository.findByProductIdAndLocationId(productId, locationId)
inventory?.remove(quantity)
inventory?.let { productInventoryRepository.save(it) }
}

fun discontinueProduct(productId: Int) {
val product = productRepository.findById(productId)
product?.discontinue()
product?.let { productRepository.save(it) }
}
}

This is certainly easier for me to read. But what about our tests? We can now focus on testing our model classes, where the business logic now lives.

import org.junit.Test
import org.junit.Assert.*

class ProductTest {

@Test
fun `applyDiscount should reduce product price`() {
val product = Product(id = 1, name = "Test Product", price = 100.0)

product.applyDiscount(0.1) // Applying a 10% discount

assertEquals(90.0, product.price, 0.001)
}

@Test
fun `discontinue should mark product as discontinued`() {
val product = Product(id = 2, name = "Another Product", price = 200.0)

product.discontinue()

assertTrue(product.isDiscontinued)
}
}
import org.junit.Test
import org.junit.Assert.*

class ProductInventoryTest {

@Test
fun `add should increase inventory quantity`() {
val product = Product(id = 1, name = "Test Product", price = 100.0)
val location = InventoryLocation(id = 1, name = "Warehouse")
val inventory = ProductInventory(product, location, quantity = 50)

inventory.add(20) // Adding 20 units

assertEquals(70, inventory.quantity)
}

@Test
fun `remove should decrease inventory quantity but not below zero`() {
val product = Product(id = 2, name = "Another Product", price = 200.0)
val location = InventoryLocation(id = 2, name = "Store")
val inventory = ProductInventory(product, location, quantity = 30)

inventory.remove(15) // Removing 15 units

assertEquals(15, inventory.quantity)

inventory.remove(20) // Attempting to remove 20 more units, should not go below zero

assertEquals(0, inventory.quantity)
}
}

I love it. While some setup is still required there are some major improvements to these tests vs the service tests:

  1. Setup only requires creating real domain objects
  2. No mocks are required
  3. Easier to grok
  4. No more when().then() !

In our previous bloated ProductService example each scenario requires mocking various dependencies, ensuring our repositories are properly wired up, etc, etc, etc. For me, writing those tests are a huge pain. And when something is painful, we don’t do it.

Do we test our service class still?

You may be wondering, do we still need to test the service class? My answer tends to be: it depends. In this instance, our service class is just retrieving an object, telling that object to do something to its state, then saving it back. I personally would not write unit tests for this, but rather rely on higher level integration tests to ensure the end to end is working.

There are situations where I would unit test my service classes. Usually I end up doing this when there is logic in my service class that doesn’t fall neatly into a domain. For example, if I am keeping track of of the status of an operation or similar.

Wrapping it up

I will admit, I’ve written a lot of bloated service classes and anemic domain models in my career. And while I still managed to find ways to test these systems, it was far more painful than it needed to be. The key, I think, is to let that pain guide you to to making better decisions!

If you are interested in learning more about what we do at Compoze Labs, please reach out!

connect@compozelabs.com

--

--