home..

Unmanaged Hazards ( Exceptions )

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:

Losing the information

The heart of the domain model is the decider component. It represents the main decision-making algorithm.

decider image

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:

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 a flow 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.

aggregates-application

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:

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:

onion architecture image

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!


  1. 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. 

  2. 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. 

  3. Arrow is a functional companion to Kotlin’s Standard Library. 

  4. 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”. 

© 2024 fraktalio d.o.o.