Contents

The domain layer and error handling

The domain layer is the heart of your application, where business rules are defined and enforced. One critical aspect of these rules is error handling, which must be as detailed as the business requirements dictate. It’s the domain layer that determines the granularity of error handling, ensuring that errors are managed appropriately based on the specific needs of the business.

Let’s consider the following example:

1
2
3
4
5
data class Data(val a: Int, val b: Int)

interface Repository {
    fun getData(): Data?
}

In this case, the domain layer doesn’t concern itself with why the getData() function fails to fetch Data. The specific reasons for the failure—whether due to network issues or other problems—are not its concern. Depending on the type of product, this decision might impact the user experience, often resulting in a generic error message displayed to the user.

Tools for error handling you don’t need!

While the kotlin.Result class is a popular choice for wrapping errors, and Arrow offers extensive tools for this purpose, these tools are not strictly necessary. Kotlin’s sealed interfaces can provide comprehensive error handling capabilities. Let’s demonstrate this with a coffee machine example that uses various ingredients:

1
2
3
4
class Beans
class Powder
class Water
class Coffee(water: Water, powder: Powder)

Defining business rules

Let’s naively assume the machine works like the following:

  1. Fetch water.
  2. Fetch beans.
  3. Grind beans into powder.
  4. Brew coffee using the powder and water.

Each step can fail in different ways. Let’s see how the domain layer can define the coffee machine’s behavior together with the expected errors.

Brewing coffee

Starting with the brewing process, the BrewResult can be either Success or Failure, with Failure having specific cases like InsufficientPowder:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
interface Repository {
    fun brew(powder: Powder, water: Water): BrewResult
}

sealed interface BrewResult {
    class Success(val coffee: Coffee) : BrewResult
    sealed interface Failure : BrewResult {
        data object InsufficientPowder : Failure
        data object InsufficientWater : Failure
        data object BrewerBroken : Failure
    }
}

Grinding beans

Next, we handle the grinding process. Notice how GrindBeansResult.Failure extends BrewResult.Failure. This is logical because a failure in grinding means a failure in brewing—no powder means no coffee. However, GrindBeansResult.Success is not of type BrewResult.Success, as grinding is just one step in making a cup of coffee. The same pattern is used with the following definitions as well.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
interface Repository {
    fun grindBeans(beans: Beans): GrindBeansResult
    fun brew(powder: Powder, water: Water): BrewResult
}

sealed interface GrindBeansResult {
    class Success(val powder: Powder) : GrindBeansResult
    sealed interface Failure : GrindBeansResult, BrewResult.Failure {
        data object GrinderBroken : Failure
        data object InsufficientBeans : Failure
    }
}

Fetching beans

The business expects the following from the fetch beans step. The only reason GetBeansResult may end up in a failure is running out of beans - GetBeansResult.Empty.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
interface Repository {
    fun getBeans(): GetBeansResult
    fun grindBeans(beans: Beans): GrindBeansResult
    fun brew(powder: Powder, water: Water): BrewResult
}

sealed interface GetBeansResult {
    class Success(val beans: Beans) : GetBeansResult
    data object Empty : GetBeansResult, BrewResult.Failure
}

Fetching water

Similarly, fetching water can fail with no water available-GetWaterResult.Empty:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
interface Repository {
    fun getWater(): GetWaterResult
    fun getBeans(): GetBeansResult
    fun grindBeans(beans: Beans): GrindBeansResult
    fun brew(powder: Powder, water: Water): BrewResult
}

sealed interface GetWaterResult {
    class Success(val water: Water) : GetWaterResult
    data object Empty : GetWaterResult, BrewResult.Failure
}

Implementing coffee machine

These definitions convey all the necessary information about what the coffee machine needs to function. From the user’s perspective, making coffee is as simple as pushing a button. Here’s the interface for our coffee machine:

1
2
3
interface CoffeeMachine {
    fun brew(): BrewResult
}

Now, let’s implement the CoffeeMachine leveraging the flexibility of sealed interfaces for error handling:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class CoffeeMachineImpl(
    private val repository: Repository,
) : CoffeeMachine {
    override fun brew(): BrewResult {
        val water = when(val getWaterResult = repository.getWater()) {
            is GetWaterResult.Success -> getWaterResult.water
            is GetWaterResult.Empty -> return getWaterResult
        }
        val beans = when (val getBeansResult = repository.getBeans()) {
            is GetBeansResult.Success -> getBeansResult.beans
            is GetBeansResult.Empty -> return getBeansResult
        }
        val powder = when (val grindBeansResult = repository.grindBeans(beans)) {
            is GrindBeansResult.Success -> grindBeansResult.powder
            is GrindBeansResult.Failure -> return grindBeansResult
        }
        return repository.brew(powder, water)
    }
}

In this implementation, lines #7, #11, and #15 directly return the failure. This approach avoids nested checks or type mappings. Moreover, the caller benefits from a detailed hierarchy of errors, clearly indicating where the error occurred:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
when (val brewResult = coffeeMachine.brew()) {
    is BrewResult.Success -> println("Enjoy your ${brewResult.coffee}")
    is BrewResult.Failure.BrewerBroken -> println("Brewer is broken")
    is BrewResult.Failure.InsufficientWater -> println("You did not use enough water")
    is BrewResult.Failure.InsufficientPowder -> println("You did not use enough powder")
    is GetWaterResult.Empty -> println("Sorry, we're out of water")
    is GetBeansResult.Empty -> println("Sorry, we're out of beans")
    is GrindBeansResult.Failure.GrinderBroken -> println("Sorry, your grinder seems to be broken")
    is GrindBeansResult.Failure.InsufficientBeans -> println("Sorry, you need more beans to create powder")
}