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:
|
|
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:
|
|
Defining business rules
Let’s naively assume the machine works like the following:
- Fetch water.
- Fetch beans.
- Grind beans into powder.
- 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
:
|
|
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.
|
|
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
.
|
|
Fetching water
Similarly, fetching water can fail with no water available-GetWaterResult.Empty
:
|
|
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:
|
|
Now, let’s implement the CoffeeMachine
leveraging the flexibility of sealed interfaces for error handling:
|
|
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:
|
|