Unmanaged Hazards ( Exceptions )
Written by Ivan Dugalic - November 2021
In the previous blog post, we learned how to model the domain effectively by using types and functions and how to manage side effects (fetching and storing the data) effectively.
In this blog post, we are going to discuss exceptions
and how they fit the domain model through a couple of different perspectives:
- is an
exception
valuable information, or we are losing the information by throwing exceptions - will
exception
break referential transparency1 and purity of our domain functions (behavior) - can we treat exceptions as side effects and manage them in the outside layers / at the edge of the system for pragmatic reasons
Losing the information
The heart of the domain model is the decider component. It represents the main decision-making algorithm.
Essentially, the decisions we are making are formally represented as Events
.
If you look at the source code below, you will realize we have three options to model a negative/error
flow:
- error as an event
- ignore errors
- throw an exception
fun restaurantOrderDecider() = Decider<RestaurantOrderCommand?, RestaurantOrder?, RestaurantOrderEvent?>(
// Initial state of the Restaurant Order is `null`. It does not exist.
initialState = null,
// Exhaustive command handler(s): for each type of [RestaurantCommand] you are going to publish specific events/facts, as required by the current state/s of the [RestaurantOrder].
decide = { c, s ->
when (c) {
is CreateRestaurantOrderCommand ->
// ** positive flow **
if (s == null) flowOf(RestaurantOrderCreatedEvent(c.identifier, c.lineItems, c.restaurantIdentifier))
// ** negative flow - option 1 (publishing business error events) **
else flowOf(RestaurantOrderRejectedEvent(c.identifier, "Restaurant order already exists"))
// ** negative flow - option 2 (publishing empty flow of events - ignoring negative flows - we are losing information :( ) **
// else emptyFlow()
// ** negative flow - option 3 (throwing exception - we are losing information - filtering exceptions is fragile) **
// else flow { throw RuntimeException("Not on menu") }
is MarkRestaurantOrderAsPreparedCommand ->
// ** positive flow **
if (s != null && CREATED == s.status) flowOf(RestaurantOrderPreparedEvent(c.identifier))
// ** negative flow **
else flowOf(RestaurantOrderNotPreparedEvent(c.identifier, "Restaurant order does not exist / not in CREATED status"))
null -> emptyFlow() // We ignore the `null` command by emitting the empty flow. Only the Decider that can handle the `null` command can be combined (Monoid) with other Deciders.
}
},
// Exhaustive event-sourcing handler(s): for each event of type [RestaurantEvent] you are going to evolve from the current state/s of the [RestaurantOrder] to a new state of the [RestaurantOrder]
evolve = { s, e ->
when (e) {
is RestaurantOrderCreatedEvent -> RestaurantOrder(e.identifier, e.restaurantId, CREATED, e.lineItems)
is RestaurantOrderPreparedEvent -> s?.copy(status = PREPARED)
is RestaurantOrderErrorEvent -> s // Error events are not changing the state / We return current state instead.
null -> s // Null events are not changing the state / We return current state instead. Only the Decider that can handle `null` event can be combined (Monoid) with other Deciders.
}
}
)
Please mind the API of the
decide: (C, S) -> Flow<E>
function. It’s signature implies we should return aflow
of Events.
Publishing business error events
By modeling errors as events, we do not lose any information. Error events could be stored durably to help with future investigations.
flowOf(
RestaurantOrderRejectedEvent(
c.identifier,
"Not on the menu"
)
)
Notice that this error event was not published because the Command that caused this Event was structurally invalid. You should not be able to instantiate structurally invalid commands in the first place. Nevertheless, the command validation process should occur before it reaches the Decider component.
We are inline with the decide: (C, S) -> Flow<E>
signature.
Ignoring Errors
By ignoring errors, we lose information. There are no apparent benefits of this approach.
emptyFlow()
It only makes sense to return an emptyFlow()
/make no decisions on a NULL command.
The benefits of having the Decider component handle nullable commands (RestaurantOrderCommand?
) are going to be discussed in future blog posts.
We are inline with the decide: (C, S) -> Flow<E>
signature.
Throwing Exceptions
Exceptions are part of the Kotlin programming language, and we have to consider them. By throwing exceptions, we are going to lose information.
flow { throw RuntimeException("Not on menu") }
We are inline with the decide: (C, S) -> Flow<E>
signature.
It will work, but the exception is not an Event, and it will interrupt the program flow by jumping back to the caller.
It will skip evolve
function to build the new state, and it will not store that event/state at all.
You can choose to log that exception somewhere, but it will never be used to make further decisions/events in your flow, and you lose information.
Referential transparency and purity
Exceptions break referential transparency and lead to bugs that we detect late / at runtime.
There are a couple of definitions of referential transparency, and one would not agree that throwing exceptions is breaking the same.
For example, the function will always throw an exception for the same input of C/Command
and S/State
.
In mathematics, we do not have the notion of computation, and in that context, we might agree that referential transparency is not broken.
In programming (and computer science in general), you can easily modify the behavior of a pure function based on an exception (value) and make decisions based on it, which can introduce non-determinism as many exceptions may be thrown, and you can’t know which one / Throwable
is an open hierarchy where you may catch more than you originally intended to – thus breaking referential transparency.
This is hard to control, especially in async environments which lack some form of structured concurrency.
Try to avoid throwing exceptions at this level and treat them as side effects.
Technical errors
The benefits of publishing error events rather than throwing exceptions or simply ignoring them within our core domain logic are obvious.
Expecting the same on the adapter/infrastructure layer (the outside ring) is not realistic/pragmatic. We can not expect that many external libraries we use on this level are not throwing exceptions. This is fine; we want to model the domain and not implement the JDBC driver.
It is acceptable for something to throw a technical
exception at you in this infrastructure
layer. For example, the database is not accessible.
You have two options:
- do nothing - it will terminate the flow and bubble up to be handled eventually, not reaching core domain components at all.
- handle them on this level and wrap them in meaningful information, providing a better API to your users.
Fmodel library2 provides both options (StateStoredAggregate.kt as example):
do nothing:
fetchState
and save
methods can throw an exception:
interface StateRepository<C, S> {
suspend fun C.fetchState(): S?
suspend fun S.save(): S
}
The exception will be propagated from the handle
method before it reaches deep core domain components viacomputeNewState
:
suspend fun handle(command: C): S =
(command.fetchState() ?: decider.initialState)
.computeNewState(command)
.save()
handle them
Arrow3 and the Kotlin standard library provides proper data types and abstractions to represent exceptional cases.
When dealing with a known alternate path, we model return types as Either. It represents the presence of either a Left value or a Right value. By convention, most functional programming libraries choose Left as the exceptional case and Right as the success value.
fetchStateEither
and saveEither
methods use Arrow’s Either type to handle all exceptions and model them explicitly as either success or error returning data-type.
Either is the algebraic data type. It models the Sum/OR relationship, allowing us to steer the program flow deterministically and safely in compile time.
interface StateRepository<C, S> {
suspend fun C.fetchState(): S?
suspend fun S.save(): S
suspend fun C.eitherFetchStateOrFail(): Either<Error.FetchingStateFailed<C>, S?> =
Either.catch {
fetchState()
}.mapLeft { throwable -> Error.FetchingStateFailed(this, throwable) }
suspend fun S.eitherSaveOrFail(): Either<Error.StoringStateFailed<S>, S> =
Either.catch {
this.save()
}.mapLeft { throwable -> Error.StoringStateFailed(this, throwable) }
}
Arrow provides a Monad instance for Either. Except for the types signatures, our program remains unchanged when we compute over Either. All values on the left side are assumed to be Right biased and, whenever a Left value is found, the computation short-circuits, producing a result that is compatible with the function type signature.
suspend fun handleEither(command: C): Either<Error, S> =
either {
(command.eitherFetchStateOrFail().bind() ?: decider.initialState)
.eitherComputeNewStateOrFail(command).bind()
.eitherSaveOrFail().bind()
}
The final classification:
- business error (core domain)
- business error event
- ignore error
- throw exception
- technical error (application, infrastructure)
- technical error
- technical exceptions
- a bug - an error in the source code model made by a human (or AI :) )
We prefer publishing error events at the core of our software. All decision-making algorithms are located here, and we should make an effort to model them as events.
The outer rings (application, adapter/infrastructure) are not involved in the decision-making process.
All errors published at this level are of a technical nature: storage is not available, the network is partitioned.
Nevertheless, we prefer explicitly modeling errors as algebraic data types (Either
) rather than throwing exceptions on this level.
It will allow us to steer the program flow deterministically in compile time.
In the next blog post we are going to explore other interesting capabilities of our core Decider component like mappable (map
/ functor), aggregatable (combine
/ monoid).
We will observe the presence of duality4 to gain a deeper understanding of the combine
function.
The source code of the demo application is publicly available here.
Happy coding!
-
Referential transparency - An expression is called referentially transparent if it can be replaced with its corresponding value (and vice-versa) without changing the program’s behavior. ↩
-
FModel can be used as a library, or as an inspiration, or both. It provides just enough tactical Domain-Driven Design patterns optimized for Event Sourcing and CQRS. ↩
-
Arrow is a functional companion to Kotlin’s Standard Library. ↩
-
Duality principle in Boolean Algebra states that “The Dual of the expression can be achieved by replacing the AND operator with OR operator, along with replacing the binary variables, such as replacing 1 with 0 and replacing 0 with 1”. ↩