- Published on
Many faces of DDD Aggregates in F#
- Authors
- Name
- Damian PΕaza
- @raimeyuu
This tale is a tiny contribution to the great F# Advent Calendar 2022.
Take your time and check other contributions too (of course, after reading this tale)!
Plan?
In this little tale, we are going to explore an interesting building block, coming from "tactical design" of Domain-Driven Design approach, called Aggregate.
The key aspect will be the language I personally love - F#. We will see (opinionated) way of representing this model for solving a particular set of problems.
To make it more "real" (i.e. close to the business), we are going to help our customer (we'll meet him soon!). So we'll be challenged with the real business problem!
So, what is our plan? Here is a list of topics we are going to sequentially visit:
- Problem?
- DDD 101: strategic and tactical design
- Aggregate?!
- Problem Analysis
- Solution Recommendations (Solution #1 Solution #2 Solution #3)
- Summary
This tale grew a bit, so I might divide it into smaller tales based on the bullet points above.
But for now my dear Reader, just sit comfortably in your chair, take a deep breath and immerse.
Problem?
Who has the problem?
Meet Tom.
(this is the time when we are simulating a vivid discussion with Tom)
What do you think, can we help? Good that we paid attention to the language, used by Tom, which resulted in catching the important word. Let's try to ask a bit more about the details, maybe we will learn something?
This sounds serious, isn't it? Seems like we need to do some analysis and try to figure out what it is all about with those bouncers. Shall we?
There are multiple ways of approaching such activity, but in this particular example we will try to practice DDD.
DDD 101: strategic and tactical design
Let me give an "extended" version of the DDD shortcut, I personally like:
It is not a definition, but rather a formula, I find particuarly interesting.
A function, when fed with domain knowledge, should yield an understanding that might lead to the problem-adjusted software design.
Exploring the problem, its nature, its essential complexity, might bring us closer to suggesting a proper solution (or solutions) - so-called models.
Models are related to the accidental complexity, our approach for providing value by solving the problem (what's the difference between those two type of complexity? Please check "Essentially bounded, Accidentally unlimited").
From the helicopter view, we can divide "the design" into two categories: strategic design and tactical design (you can find more about it in the original "Blue Book" by Eric Evans).
Both of them consist of various techniques, building blocks, or methods that one can utilize to model different aspects of the problem space.
What is very important, strategic perspective should drive the tactical perspective, not the other way around (then we might arrive at Design-Driven Domain, one of the two evil sibilings of Domain-Driven Design - what is the second evil twin? Database-Driven Design)
But jumping into the tactical design without exploring and analyzing the strategic aspect might be dangerous.
DDD 101: the language
The essence of strategic design is the language, called Ubiquitous Language.
Talk to me in your language.I will talk to you in your language.Code will express your language.
In other words, we are going to capture the language of "the problem", that Tom speaks about. This language will be the central piece of our work.
Aggregate?!
I must admit I misunderstood this concept, or rather a building block, in 100%. The name might be a bit...Misleading?
I like to go down to definitions (I used the same approach in the tale "(Fr)Agile"), to check if we can refine the understanding.
a cluster of domain objects that can be treated as a single unit.
Googling for a definition made me lucky because this one sounds interesting.
Especially the highlighted part.
One could ask: a single unit of what?
With my current understanding (this assumes it might change in the future so bear with me), it is a single unit of consistency (also referred to a unit of bussines transaction).
Wasn't Tom mentioning some issues with consistency?
Aggregate?! - "whoa there cowboy"
As it got mentioned before, if we look down into a further analysis of the "aggregate" word, its main purpose might be depicted as data aggregation.
As an example, we could take C# LINQ Aggregate
method that is related to data transformation. Next examples might be SQL Aggregate Functions: SUM
, MIN
, AVG
.
As information or data is mentioned, the understanding might orbit around entities or "bags of data structures".
But data is the secondary citizen here.
Aggregate protects (or enforces) the consistency rules. Such rules might be also called invariants.
The internal integrity is guarded by those rules.
One could ask: the integrity of what? This is hidden, taken away from the sight.
Hence there's another, supplementary building block - Aggregate Root - that is the interface to the underlying world. Typically Aggregate
and Aggregate Root
is used interchangeably to refer to the same thing - consistency rules guard.
Aggregate?! - "real" example
Working with abstract terms like "abstraction", "loose coupling" might be intimidating and I am a huge beliver for finding corresponding real world examples that are more "tangible" (thus tales like "Organization-Driven Design" and "Microoffices" vs "Officeolith").
Then, what could be possible example of an aggregate, in "real world"?
Any parents there?
Imagine a kid having a high fever - they need to get ibuprofen or paracetamol. You measure the temperature, note it down on the piece of paper.
There is a hard rule that the intake must have to comply with a particular time regime.
Where is the aggregate? A parent is enforcing ("protecting") this rule, so parent represents the aggregate here.
Taking into account what we just discussed, isn't the name "aggregate" misleading?
How could we name such a "guy"? What would you say for a bouncer?
Problem Analysis
Let's go back to our friend, Tom. We still have some learning to do!
Hey Tom! Could you please tell us more about your problem? (let's pretend we have a conversation here).
(hint: you can interact with Tom in two different ways by using buttons below)
By carefully listening to Tom's speech, we found out some rules.
How to proceed?
- all rules need to be satisfied
- we need to suggest aggregate boundary and check if rules are satisfied
- align to the language used by the Tom
Let's go!
Note that this is a toy example and all the solutions are provided in order to show various perspectives.
I am totally aware that there are other ways of approaching this modeling task - if you have your preferred one, please share! ππ»
Solution #1: Object-oriented approach
Firstly, as a warm-up, let us start with "typical" OO way of modeling.
What a bouncer can do?
- have a break
- start guarding
- report new people coming, only when guarding.
Let put those behaviors in a proper place.
type Bouncer(name: string) =
member this.HaveABreak() =
// details
member this.StartGuarding() =
// details
member this.ReportNewPeople(newPeopleCount: int) =
// details
I like to focus on behaviors firstly, to see how they drive the inforomation needed.
Clearly, we need some "status" to indicate whether a bouncer is guarding or not.
type Bouncer(name: string) =
let mutable isGuarding = false // π new!
member this.HaveABreak() =
isGuarding <- false // π new!
member this.StartGuarding() =
isGuarding <- true // π new!
member this.ReportNewPeople(newPeopleCount: int) =
// details
Great. What about ReportNewPeople
and information needed?
type Bouncer(name: string) =
let mutable isGuarding = false
let mutable peopleCount = 0 // π new!
member this.HaveABreak() =
isGuarding <- false
member this.StartGuarding() =
isGuarding <- true
member this.ReportNewPeople(newPeopleCount: int) =
// details
Finally, let's apply the first consistency rule.
type Bouncer(name: string) =
let mutable isGuarding = false
let mutable peopleCount = 0
member this.HaveABreak() =
isGuarding <- false
member this.StartGuarding() =
isGuarding <- true
member this.ReportNewPeople(newPeopleCount: int) =
if isGuarding // π new!
then peopleCount <- peopleCount + newPeopleCount // π new!
else failwith $"Bouncer {name} is not guarding at the moment" // π new!
Note that a Bouncer
starts with a break!
We got it, isn't it?
But let's come back to our rules. Can a Bouncer satisfy all of them?
There are at least three rules we are clearly missing. Let's implement them and see where they fit in.
Rule: when scheduling a break, at least two bouncers needs to be guardinglet atLeastTwoGuarding (bouncers: Bouncer list): bool =
let guardingBouncersCount =
bouncers // Bouncer list
|> List.filter (fun bouncer -> bouncer.IsGuarding) // bool list
|> List.length // int
guardingBouncersCount > 2
Easy. NEXT!
Rule: no more than 100 people partyinglet reportedLessThan100PeopleWith (newPeopleComing: int) (bouncers: Bouncer list): bool =
let currentlyPartyingPeople =
bouncers // Bouncers list
|> List.map(fun bouncer -> bouncer.PeopleCounted) // int list
|> List.sum // int
currentlyPartyingPeople + newPeopleComing <= 100
Business rules might be interesting, isn't it? Next please!
We have exposed to the external world some new information, let's add them to the Bouncer
.
type Bouncer(name: string) =
let mutable isGuarding = false
let mutable peopleCount = 0
member this.IsGuarding with get() = isGuarding // π new!
member this.PeopleCount with get() = peopleCount // π new!
member this.HaveABreak() =
isGuarding <- false
member this.StartGuarding() =
isGuarding <- false
member this.ReportNewPeople(newPeopleCount: int) =
if isGuarding
then peopleCount <- peopleCount + newPeopleCount
else failwith $"Bouncer {name} is not guarding at the moment"
Finally, the last missing rule!
Rule: at least three bouncerslet ensureAtLeastThreeBouncers (bouncerNames: string list) =
let bouncersCount = List.length bouncerNames
if bouncersCount < 3
then failwith "At least three bouncers are required!" // β consistency violation
No way a single bouncer could satisfy those three rules. We need something "bigger".
Thanks to carefully listening to Tom's explanation, we know that bouncers work together on a shift.
Let's model that and see if this makes any sense!
type BouncersShift(bouncerNames: string list) =
do ensureAtLeastThreeBouncers bouncerNames // π consistency rule
let mutable bouncers = bouncerNames |> List.map (fun name -> (name, Bouncer name)) |> dict
// Dictionary<string, Bouncer>
member private this.Bouncers with get() = bouncers.Values |> Seq.cast |> List.ofSeq
member this.ScheduleBreakFor(bouncerName: string) =
let bouncer = bouncers[bouncerName]
if atLeastTwoGuarding this.Bouncers // π consistency rule
then bouncer.HaveABreak()
else failwith "Can't schedule break because less than two bouncers are guarding" // β consistency violation
member this.StartGuarding(bouncerName: string) =
let bouncer = bouncers[bouncerName]
bouncer.Guard()
member this.ReportNewPeopleComing(bouncerName: string, newPeopleComing: int) =
let bouncer = bouncers[bouncerName]
if this.Bouncers |> reportedLessThan100PeopleWith newPeopleComing // π consistency rule
then bouncer.ReportNewPeople(newPeopleComing)
else failwith "Can't allow more people enter the club" // β consistency violation
In this example we can safely assume that visibility of the Bouncer
class is scoped only to BouncersShift
class.
Cool, BouncersShift
captures the three missing rules, the rest is delegated to a specific Bouncer
.
How could we use such a model?
let shift = new BouncersShift(["mike";"john";"derek"])
shift.StartGuarding("mike")
shift.StartGuarding("derek")
shift.StartGuarding("john")
shift.ReportNewPeopleComing("mike", 20)
shift.ScheduleBreakFor("mike")
try shift.ReportNewPeopleComing("mike", 20) with ex -> printfn $"{ex}"
// \__ System.Exception: mike bouncer is not guarding at the moment
shift.ReportNewPeopleComing("derek", 40)
shift.ReportNewPeopleComing("derek", 40)
try shift.ReportNewPeopleComing("derek", 40) with ex -> printfn $"{ex}"
// \__ System.Exception: Can't allow more people enter the club
try shift.ScheduleBreakFor("derek") with ex -> printfn $"{ex}"
// \__ System.Exception: Can't schedule break because less than two bouncers are guarding
shift.StartGuarding("mike")
shift.ScheduleBreakFor("john")
Whenever there is a consistency violation, our shift shouts with an exception.
How about the rules, is the BouncersShift
satisfying all of them?
"Whatever happens during the shift, stays in the shift" one might say, but it is quite visible that the shift protects the rules we defined, so the aggregate boundary is suitable for this particular problem.
We found good boundaries!
From now on, we are going to work with both Bouncer
and BouncersShift
.
Ok, let's move on to the next model.
Solution #2: Functional-oriented approach
This time, we are going to do some lightweight functional acrobatics in order to encode invariants in types.
Type definitions first!
[<RequireQualifiedAccess>]
module BouncersShift =
[<RequireQualifiedAccess>]
module internal Bouncers =
type GuardingBouncer = private GuardingBouncer of peopleCount: int * name: string
type BouncerHavingABreak = private BouncerHavingABreak of peopleCount: int * name: string
type Bouncer = Gaurding of GuardingBouncer | HavingABreak of BouncerHavingABreak
type private StartGuarding = BouncerHavingABreak -> GuardingBouncer
type private HaveABreak = GuardingBouncer -> BouncerHavingABreak
type private ReportNewPeople = int -> GuardingBouncer -> GuardingBouncer
This time we decided to model a bouncer with a two possible states.
We do not have the implementation yet, but we delegated the rules to the compiler.
Let the types guide us.
// still within internal Bouncers module
let startGuarding: StartGuarding = // BouncerHavingABreak -> GuardingBouncer
fun (BouncerHavingABreak (count, name)) ->
GuardingBouncer (count, name)
let haveABreak: HaveABreak = // GuardingBouncer -> BouncerHavingABreak
fun (GuardingBouncer (count, name)) ->
BouncerHavingABreak (count, name)
let reportNewPeople: ReportNewPeople = // int -> GuardingBouncer -> GuardingBouncer
fun newPeople (GuardingBouncer (count, name)) ->
GuardingBouncer (count + newPeople, name)
let countPeople (bouncer: Bouncer) = // Bouncer -> int
match bouncer with
| Guarding (GuardingBouncer (count, _)) -> count
| HavingABreak (BouncerHavingABreak (count, _)) -> count
let startShift (name: string) = (BouncerHavingABreak (0, name)) // string -> BouncerHavingABreak
The most intersting part here is reportNewPeople
function that works only with GuardingBouncer
. Also, we added a helper function countPeople
so that we are able to provide this number outside of the module.
Yet again, a Bouncer
starts with a break.
Now, a BouncersShift
. This time we will try to encode problems within a shift (previously we used exceptions).
T Y P E S first!
// within public BouncersShift module
type BouncersShift = private BouncersShift of (Map<string, Bouncers.Bouncer>)
// __________ we are representing problems explicitly
// /
type BouncersShiftProblem =
private
| CountingRefused of name: string * reason: string
| PartyingPeopleLimitReached
| SchedulingABreakRejected of reason: string
// ______ we are encoding problems in type signature
type StartGuarding = // /
string -> BouncersShift -> Result<BouncersShift, BouncersShiftProblem * BouncersShift>
type ScheduleBreakFor =
string -> BouncersShift -> Result<BouncersShift, BouncersShiftProblem * BouncersShift>
type ReportNewPeopleComing =
string -> int -> BouncersShift -> Result<BouncersShift, BouncersShiftProblem * BouncersShift>
I hope you are wondering why such a strange result of those three operations? Let's wait to see the example.
To be truly functional (and maybe a bit slow?), we are using Map
to represent a bouncers within a given shift. We need to adjust our invariants, but this should be pretty simple.
let private atLeastTwoGuarding (bouncers: Map<string, Bouncers.Bouncer>) =
let guardingBouncersCount =
bouncers.Values
|> (Seq.cast >> List.ofSeq)
|> List.sumBy (fun bouncer ->
match bouncer with
| Bouncers.Guarding _ -> 1
| _ -> 0
)
guardingBouncersCount > 2
I told ya, nothing fancy.
Rule: no more than 100 people partyinglet private reportedLessThan100PeopleWith (newPeopleComing: int) (bouncers: Map<string, Bouncers.Bouncer>) =
let currentlyPartyingPeople =
bouncers.Values
|> (Seq.cast >> List.ofSeq)
|> List.sumBy Bouncers.countPeople
currentlyPartyingPeople + newPeopleComing <= 100
Let's assume we're going to leave the last rule with the same implementation. Exception will work as a "fail fast".
let start (bouncerNames: string list) = // string list -> BouncersShift
ensureAtLeastThreeBouncers bouncerNames
let bouncers =
bouncerNames
|> List.map (fun name -> (name, Bouncers.HavingABreak (Bouncers.startShift name)))
|> Map.ofList
BouncersShift (bouncers)
Neat. We have all building blocks to start implementing three functions: startGuarding
, haveABreak
and reportNewPeopleComing
.
First, let a Bouncer
start guarding:
let startGuarding: StartGuarding =
// string -> BouncersShift -> Result<BouncersShift, BouncersShiftProblem * BouncersShift>
fun (name: string) (BouncersShift bouncers) ->
let bouncer = bouncers |> Map.find name
match bouncer with
| Bouncers.HavingABreak bouncerHavingABreak ->
let guardingBouncer =
bouncerHavingABreak
|> Bouncers.startGuarding
|> Bouncers.Guarding
Ok (BouncersShift (bouncers |> Map.update name guardingBouncer))
| _ -> Ok (BouncersShift bouncers)
No problems here as a Bouncer
can always start guarding. As this is within the BouncersShift
module, we can match on the particular state and then wrap the given bouncer again.
This might be a bit tiresome, but hey, we wanted the "type-safety", right?
Next? Scheduling a break for a given Bouncer
.
let scheduleBreakFor: ScheduleBreakFor =
// string -> BouncersShift -> Result<BouncersShift, BouncersShiftProblem * BouncersShift>
fun (name: string) (BouncersShift (bouncers)) ->
if atLeastTwoGuarding bouncers // π consistency rule
then
let bouncer = bouncers |> Map.find name
match bouncer with
| Bouncers.Guarding guardingBouncer ->
let bouncerHavingABreak =
guardingBouncer
|> Bouncers.haveABreak // π consistency rule guided by a compiler
|> Bouncers.HavingABreak
Ok (BouncersShift (bouncers |> Map.update name bouncerHavingABreak))
| Bouncers.HavingABreak _ -> Ok (BouncersShift bouncers)
else Error(SchedulingABreakRejected "at least two bounces need to be guarding", BouncersShift bouncers) // β consistency violation
As this function is scoped to the BouncersShift
module, we are able to reach Bouncer
type and match on it.
Ok, without further ado, we are missing the last operation, reportNewPeopleComing
, here it comes!
let reportNewPeopleComing: ReportNewPeopleComing =
// string -> int -> BouncersShift -> Result<BouncersShift, BouncersShiftProblem * BouncersShift>
fun (name: string) (newPeopleComing: int) (BouncersShift (bouncers): BouncersShift) ->
if bouncers |> reportedLessThan100PeopleWith newPeopleComing // π consistency rule
then
let bouncer = bouncers |> Map.find name
match bouncer with
| Bouncers.Guarding guardingBouncer ->
let guardingBouncer =
guardingBouncer
|> Bouncers.reportNewPeople newPeopleComing // π consistency rule guided by a compiler
|> Bouncers.Guarding
Ok (BouncersShift (bouncers |> Map.update name guardingBouncer))
| Bouncers.HavingABreak _ -> Error(CountingRefused (name, "having a break"), BouncersShift bouncers) // β consistency violation
else Error(PartyingPeopleLimitReached, BouncersShift bouncers) // β consistency violation
This is probably the most interesting function so far! Runtime checks didn't disappear, so still we need to match on cases.
But yet again, we are guided by the compiler on "what is possible" within the domain we defined.
What is not possible and should result with a problem, gets encoded in types properly.
As we are dealing with problems explicitly in types, we need to have some helper function:
let handleProblems handleProblem bouncersShift =
// (BouncersShiftProblem -> unit) -> Result<BouncersShift, BouncersShiftProblem * BouncersShift> -> BouncersShift
match bouncersShift with
| Ok bouncersShift -> bouncersShift
| Error (error, bouncersShift) ->
handleProblem error
bouncersShift
// ____ built-in function!
// /
let ignoreProblems = handleProblems ignore
It's not a brave attitude to ignore problems (in one's life), but this time we are going to do so.
Finally, we arrived at the point when we can use our model. Note that the same sequence of operations were used in the OOP solution.
let shift = BouncersShift.start ["mike";"john";"derek"]
shift
|> BouncersShift.startGuarding "mike"
|> (ignoreProblems >> BouncersShift.startGuarding "derek")
|> (ignoreProblems >> BouncersShift.startGuarding "john")
|> (ignoreProblems >> BouncersShift.reportNewPeopleComing "mike" 20)
|> (ignoreProblems >> BouncersShift.scheduleBreakFor "mike")
|> (ignoreProblems >> BouncersShift.reportNewPeopleComing "mike" 20)
// \_______ we could throw an exception here!
|> (ignoreProblems >> BouncersShift.reportNewPeopleComing "derek" 40)
|> (ignoreProblems >> BouncersShift.reportNewPeopleComing "derek" 40)
|> (ignoreProblems >> BouncersShift.reportNewPeopleComing "derek" 40)
// \_______ we could throw an exception here!
|> (ignoreProblems >> BouncersShift.scheduleBreakFor "derek")
// \_______ we could throw an exception here!
|> (ignoreProblems >> BouncersShift.startGuarding "mike")
|> (ignoreProblems >> BouncersShift.scheduleBreakFor "derek")
It might be hard to grasp the output of such pipeline so let me help you with visualizing it:
Ok
BouncersShift
(map
[("derek", HavingABreak (BouncerHavingABreak (80, "derek")));
("john", Guarding (GuardingBouncer (0, "john")));
("mike", Guarding (GuardingBouncer (20, "mike")))])
As mentioned at the beginning, in this solution we wanted to let our friend, the compiler, help us, mere mortals, leading to the pits of success in "a lightweight fashion".
But wait a minute. As we encoded some possible states of a single Bouncer
in types, this means that some states are possible for some operations, whereas other combinations should never happen π€
I bet there is a good model for tackling such problem in which some transitions between states are possible and others do not affect state changes.
Solution #3: Functional, State Machine-like approach
What would you say if a Bouncer
was represented by a state machine?
We've already seen that there are three behaviors of a bouncer: start guarding, have a break and report new people coming.
It turns out those are commands we are sending to the machine. And in the "Solution #2: Functional-oriented approach", we know that there are two possible states: GuardingBouncer
and HavingABreakBouncer
.
To work with lightweight state machines, we need to define a new module: StateMachines
.
module StateMachines =
type FSM<'Input, 'Output> = FSM of 'Output
// \______ used mostly for documentation - what input is possible
type Evolve<'Input, 'Output, 'FSM> = 'Input -> 'FSM -> 'Output * 'FSM
type Aggregate<'Command, 'Event, 'State> = FSM<'Command, 'Event list * 'State>
// 'Input ____________/ \________ 'Output with implicit state
FSM
stands for Finite State Machine
.
Note that Evolve
function and FSM
type are handled separately. There is a way to handle them both together, but this will come in the future tale (is it a clickbait? Well, maybe).
What is really interesting, an Aggregate
works with commands, events and state. As we implicitly encoded state, when we will evolve our finite state machine, we are going to emit the current state and some facts that happened during the evolution.
As it is still functional approach, let us start with types firstly.
[<RequireQualifiedAccess>]
module internal Bouncers =
open StateMachines
// \_________ we use types from new module
type BouncerCommand = private StartGuarding | HaveABreak | ReportNewPeopleComing of count: int
type BouncerDetails = private { name: string; count: int }
type BouncerState = Guarding of bouncer: BouncerDetails | HavingABreak of bouncer: BouncerDetails
// ______ we are introducing facts happening inside of the state machine
// /
type BouncerEvent =
| StartedGuarding | StartedABreak | CountingRefused of reason: string | ReportedCount of count: int
type Bouncer = Bouncer of Aggregate<BouncerCommand, BouncerEvent, BouncerState>
type private EvolveBouncer = Evolve<BouncerCommand, BouncerEvent list * BouncerState, Bouncer>
So now we defined all our operations as commands. Instead of "problems", that we used in "Solution #2: Functional-oriented approach", we are naming those as "facts" or "events".
We need to bring evolution of a Bouncer
state machine into life!
let private NoEvents = List.empty
let private evolve: EvolveBouncer = // BouncerCommand -> Bouncer -> (BouncerEvent list * BouncerState) * Bouncer
fun (cmd: BouncerCommand) (bouncer: Bouncer) ->
let (Bouncer(FSM (events, bouncer'))) = bouncer
let output = // (BouncerEvent list * BouncerState) * Bouncer
match cmd, bouncer' with
| StartGuarding, Guarding _ -> NoEvents, bouncer'
| HaveABreak, Guarding bouncer'' -> [StartedABreak], HavingABreak bouncer''
| StartGuarding, HavingABreak bouncer'' -> [StartedGuarding], Guarding bouncer''
| HaveABreak, HavingABreak _ -> NoEvents, bouncer'
| ReportNewPeopleComing count, Guarding bouncer'' -> // π consistency rule
[ReportedCount count], Guarding { bouncer'' with count = bouncer''.count + count }
| ReportNewPeopleComing _, HavingABreak _ -> [CountingRefused "I am having a break"], bouncer' // β consistency violation
let (newEvents, newBouncerState) = output
output, (Bouncer(FSM(events |> with' newEvents, newBouncerState)))
Everything is in this single function. All possible transitions between the states.
As this function is private, which is a good thing, we need to give some public API.
let startShift name = Bouncer(FSM(NoEvents, HavingABreak { name = name; count = 0}))
let haveABreak = evolve HaveABreak
let startGuarding = evolve StartGuarding
let reportNewPeopleComing count = evolve (ReportNewPeopleComing count)
let countPeople ((Bouncer (FSM((_, bouncer')))): Bouncer) =
match bouncer' with
| Guarding guardingBouncerDetails -> guardingBouncerDetails.count
| HavingABreak havingABreakBouncerDetails -> havingABreakBouncerDetails.count
Yet again, we need to expose countPeople
helper function and we already know it will be used by the BouncersShift
.
Can we represent a BouncersShift
as a state machine too?
Tom clearly stated that we need to maintain the "luxury" level of their clubs. So keeping people outside of the club when limits are reached sounds like a good indicator for having the state when there are no more people allowed and people are allowed to enter.
You know with what we are going to start firstly, right?
type BouncersShiftCommand =
private StartGuarding of name: string | ScheduleBreakFor of name: string | ReportNewPeopleComing of name: string * count: int
type BouncersShiftDetails = private { bouncers: Map<string, Bouncers.Bouncer> }
// ______ we are introducing facts happening inside of the state machine
// /
type BouncersShiftEvent =
| StartedGuarding of name: string | ScheduledBreakFor of name: string
| CountReported of name: string * count: int | CountingRefused of name: string * reason: string
| PartyingPeopleLimitReached | SchedulingABreakRejected of reason: string
type BouncersShiftState =
private NoMorePeopleAllowed of BouncersShiftDetails | MorePeopleAllowed of BouncersShiftDetails
type BouncersShift =
private BouncersShift of Aggregate<BouncersShiftCommand, BouncersShiftEvent, BouncersShiftState>
type private EvolveBouncersShift =
Evolve<BouncersShiftCommand, BouncersShiftEvent list * BouncersShiftState, BouncersShift>
We modeled aforementioned NoMorePeopleAllowed
and MorePeopleAllowed
states explicitly. From the state machine transition diagram we can see that even though the club does not allow for more people, bouncers can go for breaks as usual.
Let's see how could we evolve such machine.
let evolve: EvolveBouncersShift = // BouncersShiftCommand -> BouncersShift -> (BouncersShiftEvent list * BouncersShiftState) * BouncersShift
fun (cmd: BouncersShiftCommand) (bouncersShift: BouncersShift) ->
let (BouncersShift(FSM(events, (shiftDetails)))) = bouncersShift
let result = // (BouncersShiftEvent list * BouncersShiftState) * BouncersShift
match shiftDetails with
| NoMorePeopleAllowed shiftDetails' ->
handleShiftWhenNoMorePeopleAllowed cmd shiftDetails'
| MorePeopleAllowed shiftDetails' ->
handleShiftWhenMorePeopleAllowed cmd shiftDetails'
let (newEvents', squadState) = result
result, BouncersShift(FSM(events |> List.append newEvents', squadState))
It might be too intimidating to show all at once, so let's assume we decided to abstract away some details. As handleShiftWhenMorePeopleAllowed
function is much more interesting, because it contains the transition to a "quasi-terminate" state, let's see how it could look like.
let handleShiftWhenMorePeopleAllowed (cmd: BouncersShiftCommand) (shiftDetails': BouncersShiftDetails) = // BouncersShiftCommand -> BouncersShiftDetails -> (BouncersShiftEvent list * BouncersShiftState) * BouncersShift
match cmd with
| StartGuarding bouncerName ->
handleBouncersWhenMorePeopleAllowed (Bouncers.startGuarding) bouncerName shiftDetails'
| ReportNewPeopleComing (_, newPeopleComingCount)
when shiftDetails'.bouncers |> (not << reportedLessThan100PeopleWith newPeopleComingCount) -> // π consistency rule
[PartyingPeopleLimitReached], NoMorePeopleAllowed shiftDetails' // β consistency violation
| ReportNewPeopleComing (bouncerName, newPeopleComingCount) ->
handleBouncersWhenMorePeopleAllowed (Bouncers.reportNewPeopleComing newPeopleComingCount) bouncerName shiftDetails'
| ScheduleBreakFor _
when (not << atLeastTwoGuarding) shiftDetails'.bouncers -> // π consistency rule
[SchedulingABreakRejected "Can't schedule break because less than two bouncers are guarding"], MorePeopleAllowed shiftDetails' // β consistency violation
| ScheduleBreakFor bouncerName ->
handleBouncersWhenMorePeopleAllowed (Bouncers.haveABreak) bouncerName shiftDetails'
Some cases use handleBouncersWhenMorePeopleAllowed
function, it's because we wanted to track all facts that happen during the shift.
Now we need to "translate" facts mentioned by each bouncer. Also, this is the place when are are actually delegating the work to a particular bouncer.
let private translateFacts bouncerName (bouncerFacts: Bouncers.BouncerEvent list): BouncersShiftEvent list =
bouncerFacts
|> List.map (fun fact ->
match fact with
| Bouncers.StartedGuarding -> StartedGuarding bouncerName
| Bouncers.StartedABreak -> ScheduledBreakFor bouncerName
| Bouncers.ReportedCount count -> CountReported (bouncerName, count)
| Bouncers.CountingRefused reason -> CountingRefused (bouncerName, reason)
)
let private handle cmd bouncerName shift =
let bouncer = shift.bouncers[bouncerName]
let ((bouncerFacts, _), bouncer') = bouncer |> cmd
let bouncers =
shift.bouncers |> Map.update bouncerName bouncer'
(translateFacts bouncerName bouncerFacts), { shift with bouncers = bouncers }
let private handleBouncersWhenMorePeopleAllowed cmd bouncerName shift =
handle cmd bouncerName shift
|> fun (events, shiftState) -> events, MorePeopleAllowed shiftState
As with a Bouncer
finite state machine, evolve
function is a private function which we don't want to show (it is the implenentation details).
So let's bring a public API for a BouncersShift
.
let startGuarding name = evolve (StartGuarding name)
let scheduleBreakFor name = evolve (ScheduleBreakFor name)
let reportNewPeopleComing name count = evolve (ReportNewPeopleComing (name, count))
let private toBouncers bouncerNames =
bouncerNames
|> List.map (fun name -> name, Bouncers.startShift name)
|> Map.ofList
let private NoEventsWhenStarting = List.empty
let start bouncerNames =
ensureAtLeastThreeBouncers bouncerNames
let bouncers = bouncerNames |> toBouncers
BouncersShift(FSM (NoEventsWhenStarting, MorePeopleAllowed { bouncers = bouncers }))
It's pretty straightforward, isn't it? The interesting part is the verbosity of the last line - we are starting with no events and when more people are allowed.
How about the usage of such shift?
let ignoreFacts = snd // built-in function!
let shift = BouncersShift.start ["mike";"john";"derek"]
shift
|> BouncersShift.startGuarding "mike"
|> (ignoreFacts >> BouncersShift.startGuarding "derek")
// \________________ each operation yields both facts that happened
// during execution and current state machine
|> (ignoreFacts >> BouncersShift.startGuarding "john")
|> (ignoreFacts >> BouncersShift.reportNewPeopleComing "mike" 20)
|> (ignoreFacts >> BouncersShift.scheduleBreakFor "mike")
|> (ignoreFacts >> BouncersShift.reportNewPeopleComing "mike" 20)
// \______ public API haven't changed
|> (ignoreFacts >> BouncersShift.reportNewPeopleComing "derek" 40)
|> (ignoreFacts >> BouncersShift.reportNewPeopleComing "derek" 40)
|> (ignoreFacts >> BouncersShift.reportNewPeopleComing "derek" 40)
|> (ignoreFacts >> BouncersShift.scheduleBreakFor "derek")
|> (ignoreFacts >> BouncersShift.startGuarding "mike")
|> (ignoreFacts >> BouncersShift.scheduleBreakFor "derek")
Yet again, we are totally not interested what happened during the shift. It might be we could utilize such information for some interesting business metric.
In "Solution #2: Functional-oriented approach" example, we haven't tracked anything, since we were dealing with "problems".
Now it is a different story - there are some facts happening and the interpretation of them is on a caller's hands ("the beauty is the observer's eyes").
Nothing is lost, because in this example we are keeping entire history for a particular shift. Let us help ourselves with a simple output visualization.
BouncersShift(FSM
([StartedGuarding "mike"; StartedGuarding "derek"; StartedGuarding "john";
CountReported ("mike", 20); ScheduledBreakFor "mike";
CountingRefused ("mike", "I am having a break");
CountReported ("derek", 40); CountReported ("derek", 40);
PartyingPeopleLimitReached;
SchedulingABreakRejected "Can't schedule break because less than two bouncers are guarding";
StartedGuarding "mike"; ScheduledBreakFor "derek"],
NoMorePeopleAllowed
{ bouncers = map
[("derek", Bouncer(FSM
([StartedGuarding; ReportedCount 40; ReportedCount 40;
StartedABreak], HavingABreak { name = "derek" count = 80 })));
("john", Bouncer (FSM ([StartedGuarding], Guarding { name = "john" count = 0 })));
("mike", Bouncer (FSM
([StartedGuarding; ReportedCount 20; StartedABreak;
CountingRefused "I am having a break"; StartedGuarding],
Guarding { name = "mike" count = 20 })))] })))
What's is more, each bouncer also remembers what he did during the shift!
Does it look familiar? How could we utilize such information? More in the next tales! (I know, I know, again clitbait-ish, but I couldn't resist!)
WHAT THE HELL JUST HAPPENED?
It was tough, bumpy, exhaustive, but enjoyable journey (at least for me).
We were working on a single business problem concerned around consistency.
Thanks to the analysis we did, using strategic DDD with language modeling, our problem became "approachable".
We used Aggregate building block from tactical DDD.
We provided three solutions (models) for the problem that Tom challenged us with:
- Object-oriented approach
- "Lightweight" Type-driven functional approach
- "Lightweight" functional, State Machine-like approach
This is also very important - we should always think of multiple models for solving a problem to "promote" the best one (at the current moment). Note that it does not mean all of them need to be perfectly implemented - the main purpose is to experiment and analyze!
Conclusions?
- OO achieves evolvability through mutability
- FP achieves evolvability through immutability
- F# is able to keep implementation details hidden, even though data and behavior are separated
- There are various ways for handling output from an operation on an aggregate: exception, Result or Event
- Domain language can be expressed on various levels
- Type correctness isn't "free"
- Handling all cases might be tiresome, but might help with crafting. bug-free code
There is also another observation - F# is a great language for domain modeling on variety of levels. Use its powers, let it empower you (I am looking at you F# Compiler!).
Because it is functional-first and not functional-only we use the most suitable approach. I always say that F# made me better Object-oriented programmer.
Ok, we arrived at the place when we can stop. I hope you enjoyed this little exercise my dear Reader.
Thank you for your patience and time!