Types And Functions
Written by Ivan Dugalic - November 2021
Programming starts with types and functions.
Algebraic Data Types
In computer programming, especially functional programming, and type theory, an algebraic data type is a kind of composite / a type formed by combining other types.
Two standard classes of algebraic types are:
product
types (i.e., tuples, pair, and data classes)- and
sum
types.
They provide the necessary abstraction for structuring the various data of our domain model.
- Whereas
sum
types let you model the variations within a particular data type, product
types help cluster related data into a larger abstraction.
Sum
type models an OR
relationship, and Product
type models an AND
relationship.
So, the OR
and AND
operations constitute the algebra of our data types.
Structuring the various data of our domain model
In a previous blog post, you learned that any information system is responsible for handling the intent (Command
) and, based on
the current State,
produce new facts/decisions (Events
). The system’s new State
is then evolved out of these Events .
It is becoming clear that we have various classes of algebraic data types in our domain. Let’s categorize them:
- Command / C
- Event / E
- State / S
- Query / Q
We need statically typed, multi-paradigm programming language to exercise domain modeling, with functions
as first-class citizens included. To name some: Haskell, Kotlin, Scala, F#, Rust, TypeScript.
For this blog post, we are going to focus on Kotlin, showing you the power of a hybrid functional, object-oriented approach.
Commands
Commands represent the intent to change the state of the information system.
Sum/OR
The sealed
class in Kotlin represents data composition, also known as a Sum
type (models the OR
relationship).
The key benefit of using sealed
classes comes into play when using them in a when
expression. If it’s possible to verify that the statement covers all cases, you don’t need to add an else
clause to the statement.
We model our commands as a Sum
type (OR
relationship) by using the sealed
class. In this example, we have two possible
sub-classes of RestaurantOrderCommand
which are known at compile time: CreateRestaurantOrderCommand
and MarkRestaurantOrderAsPreparedCommand
.
sealed class Command
sealed class RestaurantOrderCommand : Command() {
abstract val identifier: RestaurantOrderId
}
data class CreateRestaurantOrderCommand(
override val identifier: RestaurantOrderId,
val restaurantIdentifier: RestaurantId = RestaurantId(),
val lineItems: ImmutableList<RestaurantOrderLineItem>
) : RestaurantOrderCommand()
data class MarkRestaurantOrderAsPreparedCommand(
override val identifier: RestaurantOrderId
) : RestaurantOrderCommand()
Other programming languages are using different mechanisms to model OR
/Sum
relationship. For example, TypeScript
has Unions
:
type RestaurantOrderCommand = CreateRestaurantOrderCommand | MarkRestaurantOrderAsPreparedCommand;
Product/AND
If you zoom in into the concrete command types, for example, CreateRestaurantOrderCommand,
you will notice that it is
formed by combining other types: RestaurantOrderId
, RestaurantId
, RestaurantOrderLineItem
.
Essentially, CreateRestaurantOrderCommand
data class is a Product
type which models AND
relationship:
CreateRestaurantOrderCommand = RestaurantOrderId & RestaurantId & list of [RestaurantOrderLineItem]
Events
Events represent the state change itself, a fact. These events represent decisions that have already happened (past tense).
Sum/OR
We model our events as a Sum
type (OR
relationship) by using sealed
class. In this example, we have four possible
sub-classes of RestaurantOrderEvent
which are known at compile time: RestaurantOrderCreatedEvent
, RestaurantOrderPreparedEvent
, RestaurantOrderRejectedEvent
and RestaurantOrderNotPreparedEvent
.
sealed class Event
sealed class RestaurantOrderEvent : Event() {
abstract val identifier: RestaurantOrderId
}
sealed class RestaurantOrderErrorEvent : RestaurantOrderEvent() {
abstract val reason: String
}
data class RestaurantOrderCreatedEvent(
override val identifier: RestaurantOrderId,
val lineItems: ImmutableList<RestaurantOrderLineItem>,
val restaurantId: RestaurantId
) : RestaurantOrderEvent()
data class RestaurantOrderPreparedEvent(
override val identifier: RestaurantOrderId,
) : RestaurantOrderEvent()
data class RestaurantOrderRejectedEvent(
override val identifier: RestaurantOrderId,
override val reason: String
) : RestaurantOrderErrorEvent()
data class RestaurantOrderNotPreparedEvent(
override val identifier: RestaurantOrderId,
override val reason: String
) : RestaurantOrderErrorEvent()
Product/AND
If you zoom in into the concrete event types, for example, RestaurantOrderCreatedEvent,
you will notice that it is
formed by combining other types: RestaurantOrderId
, RestaurantId
, RestaurantOrderLineItem
.
Essentially, RestaurantOrderCreatedEvent
data class is a Product
type which models AND
relationship:
RestaurantOrderCreatedEvent = RestaurantOrderId & RestaurantId & list of [RestaurantOrderLineItem]
State
The current state of the information system is evolved out of past events/facts.
data class RestaurantOrder(
val id: RestaurantOrderId,
val restaurantId: RestaurantId,
val status: Status,
val lineItems: ImmutableList<RestaurantOrderLineItem>
)
Product/AND
If you zoom in into the concrete state types, for example, RestaurantOrder,
you will notice that it is formed by
combining other types: RestaurantOrderId
, RestaurantId
, RestaurantOrderLineItem
, Status
.
Essentially, RestaurantOrder
data class is a Product
type that models AND
relationship:
RestaurantOrder = RestaurantOrderId & RestaurantId & Status & list of [RestaurantOrderLineItem]
Embrace Immutability
Kotlin encourages developers to write immutably, by using val
in your data types.
Immutable objects are thread safe. No race conditions, no concurrency problems, no need to sync.
We can afford it!
Encapsulation
One might object that algebraic data types violate encapsulation by making public the internal representation of a type. In functional programming, we approach concerns about encapsulation differently / we don’t typically have a
delicate mutable state
which could lead to bugs or violation of invariants if exposed publicly. Exposing the data constructors of a data type is often fine, and the decision to do so is approached much like any other decision about what the public API of a data type should be.
Book - Functional Programming in Kotlin
In order to achieve better encapsulation, one could use Interfaces instead of Data classes as a public API by restricting Data classes to package private
.
Taking into account that we achieved a great deal of immutability, this might not be needed.
Modeling the Behaviour of our domain
- algebraic data types form the
structure
of our entities (commands, state, and events) - functions/lambda offers the algebra of manipulating the entities in a compositional manner, effectively modeling
the
behavior.
This leads to modularity in design and a clear separation of the entity’s structure and functions/behaviour
of the entity.
Fmodel library1 offers generic and abstract components to specialize in for your specific case/expected behavior:
- Decider
- View
- Saga
Decider
The decider is a data type that represents the main decision-making algorithm.
initialState
- A starting point / An initial state of typeRestaurantOrder?
decide
(Exhaustive / pattern matching command handler) - A function/lambda that takes command of typeRestaurantOrderCommand?
and input state of typeRestaurantOrder?
as parameters, and returns/emits the flow of output eventsFlow
<RestaurantOrderEvent?
>evolve
(Exhaustive / pattern matching event-sourcing handler) - A function/lambda that takes input state of typeRestaurantOrder?
and input event of typeRestaurantOrderEvent
as parameters, and returns the output/new stateRestaurantOrder
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("Restaurant order already exists") }
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 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 the current state instead.
null -> s // Null events are not changing the state / We return the current state instead. Only the Decider that can handle `null` event can be combined (Monoid) with other Deciders.
}
}
)
View
The view is a data type that represents the event handling algorithm responsible for translating the events into the denormalized state, which is adequate for querying.
initialState
- A starting point / An initial state of typeRestaurantOrderView?
evolve
(Exhaustive / pattern matching event handler) - A function/lambda that takes input state of typeRestaurantOrderView?
and input event of typeRestaurantOrderEvent
as parameters, and returns the output/new stateRestaurantOrderView
fun restaurantOrderView() = View<RestaurantOrderView?, RestaurantOrderEvent?>(
// Initial state of the [RestaurantOrderView] is `null`. It does not exist.
initialState = null,
// Exhaustive event handler(s): for each event of type [RestaurantOrderEvent] you are going to evolve from the current state/s of the [RestaurantOrderView] to a new state of the [RestaurantOrderView].
evolve = { s, e ->
when (e) {
is RestaurantOrderCreatedEvent -> RestaurantOrderView(e.identifier, e.restaurantId, CREATED, e.lineItems)
is RestaurantOrderPreparedEvent -> s?.copy(status = PREPARED)
is RestaurantOrderErrorEvent -> s // We ignore the `error` event by returning current State/s.
null -> s // We ignore the `null` event by returning current State/s. Only the View that can handle `null` event can be combined (Monoid) with other Views.
}
}
)
Saga
Saga is a data type that represents the central point of control, deciding what to execute next. It is responsible for mapping different events from deciders into action results that the Saga then can use to calculate the subsequent actions to be mapped to the command of other deciders.
In the context of smart endpoints and dumb pipes, deciders would be smart endpoints, and saga would be a dumb pipe.
react
- A function/lambda that takes input action-result/event of typeRestaurantEvent?
, and returns the flow of actions/commandsFlow
<RestaurantOrderCommand
> that should be published.
fun restaurantOrderSaga() = Saga<RestaurantEvent?, RestaurantOrderCommand?>(
react = { e ->
when (e) {
is RestaurantOrderPlacedAtRestaurantEvent -> flowOf(
CreateRestaurantOrderCommand(
e.restaurantOrderId,
e.identifier,
e.lineItems
)
)
is RestaurantCreatedEvent -> emptyFlow()
is RestaurantMenuActivatedEvent -> emptyFlow()
is RestaurantMenuChangedEvent -> emptyFlow()
is RestaurantMenuPassivatedEvent -> emptyFlow()
is RestaurantErrorEvent -> emptyFlow()
null -> emptyFlow() // We ignore the `null` event by returning the empty flow of commands. Only the Saga that can handle `null` event/action-result can be combined (Monoid) with other Sagas.
}
}
)
Totality
A function is total
if it is defined for all of its possible inputs.
By having algebraic data types modeling the Sum/OR
relationship with sealed
class, it’s possible to verify that the when
expression covers all cases, you don’t need to add an else
clause to the statement.
This is known as Kotlin matching
. Many modern languages have support for some kind of pattern matching
.
The compiler will yell at you if you add a new command or event into the model/project (when
expression goes red), and you will have to fix it immediately.
It will positively influence the function (decide
, evolve
, react
) totality2 giving more guarantees about code correctness.
The essence of functional programming lies in the power of pure functions. Add static types to the mix, and you have algebraic abstractions—functions operating on types and honoring certain laws. Make the functions generic on types, and you have parametricity. The function becomes polymorphic, which implies more reusability, and if you’re disciplined enough not to leak any implementation details by sneaking in specialized types (or unmanaged hazards such as exceptions), you get free theorems.
In the following blog posts, we are going to zoom out and observe how this design fits the onion architecture / ports and adapters.
The source code of the demo application is publicly available here
Happy coding!
-
FModel can be used as a library, or as an inspiration, or both. It provides just enough tactical Domain-Driven Design patterns, optimised for Event Sourcing and CQRS. ↩
-
This blog post provides a comprehensive guide to the concept of totality in Functional Programming by demystifying its meaning, giving a lot of examples, and recommending how to get tools to help you write maintainable, testable code. ↩