Abaks: commands
Previous log has let us with the following events:
data AbaksEvent
= Started {periodId :: PeriodId, name :: Text, from :: Day, to :: Day, initialBalance :: Amount}
| EntryAdded {entry :: Entry}
| EntryAmountChanged {entryId :: EntryId, amount :: Amount}
| EntryValidated {entryId :: EntryId}
| EntryCommented {entryId :: EntryId, comment :: Text}
| EntryMarkedInConflict {entryId :: EntryId, reason :: Text}
| EntryDeleted {entryId :: EntryId, comment :: Text}
deriving stock (Eq, Show, Generic)
In order to emit these events, we needs Commands (or CommandHandlers) which will eventually create new Events.
Let's start with some structuring definitions regarding Commands handling:
type CommandHandler a e = Events a -> Either e (Events a)
type Events a = [a]
applyCommand = ($)
Here's is the controversy. Whenever I talk to other event sourcing practitioners (mostly coming from OOP/OOD world), I get the following feedback regarding my not-compliant design:
- You should model your aggregate, and apply
Commands against them Commands/CommandHandlers are not supposed to have 'return' values- You should have some kind of
IOto generate some value (e.g. UUIDs generation)
Here are my usual answers:
- I don't actually need an aggregate, there's multiple ways to look at it
- Your aggregate is just a projection of the
Events, so you can shortcut the whole process - Your aggregate computation can be buggy, without aggregate, you'll have less code, so less bugs
- You can have a fine-grains logic with
Events you won't be able to have without effort in an aggregate - An aggregate is design to answer all questions, while you can factor-out precise ones
- There's two parts in this topic
- These are just a
Commands/CommandHandlers, not the complete event sourcing system, which tends to produce no value (even though they use to shamelessly throw exceptions). Moreover, nothing should prevent you to emitEvents when things go wrong. - While I agree on the principle to put CQRS first, having some kind of
Reactorwhich would push feedback to the user fromEvents interpretation, I find that to be an unnecessarily complex default design. - This one is shocking to me, the last thing you want is to have two identical (same aggregates, same
Events, sameCommands' values)Commands have different behaviors. For UUIDs, let's imagine you're unlucky (picking and existing one), either don't handle the case, or provide alternative value.
Note: sometimes I also hear that, without aggregate, you cannot show the code to the business people. I have two issues with that:
- I don't really understand how a multiple part (aggregate-based) piece of code is easier to understand than an
Events-based one (which will be closer than an event storming session) - Just don't show the code to the business, period. A while ago, I had a friend which was studying to be top manager/C-Level in hospitals. One of her lesson was called "database modeling". She showed me a test she had to take, I did not even understood what they were asking. So, I could start by saying that it's not business job to understand the very small details of our work, but it's our job to articulated what we are doing. But instead, I would argue that I won't lower my code quality (not using relevant features, or adding obvious-but-to-be-maintained comments) or make the code harder-than-necessary to work with (renaming standard functions, be not idiomatic, which will anyway, make on-boarding harder and communication with the business harder), for people not supposed to directly work on it.
Then we can have a look at our CommandHandlers:
startPeriod periodId name from to balance events = do
unless (null events) $
Left "Period already started"
return
[ Started
{ periodId = periodId,
name = name,
from = from,
to = to,
initialBalance = balance
}
]
addEntry entry events = do
hasStarted events
inPeriod entry.date events
let entries = listEntries events
unless (Map.notMember entry.entryId entries) $
Left "Entry already existing"
return [EntryAdded entry]
changeAmountEntry entryId amount events = do
validEntry entryId events
return [EntryAmountChanged entryId amount]
validateEntry entryId events = do
validEntry entryId events
return [EntryValidated entryId]
commentEntry entryId comment events = do
validEntry entryId events
return [EntryCommented entryId comment]
markInClonflictEntry entryId reason events = do
validEntry entryId events
return [EntryMarkedInConflict entryId reason]
deleteEntry entryId comment events = do
validEntry entryId events
return [EntryDeleted entryId comment]
And we have our supporting functions:
newtype ExplainedError = ExplainedError {getExplainedError :: Text}
deriving stock (Eq, Ord, Show, Generic)
deriving newtype (IsString)
hasStarted =
\case
(Started {} : _) -> return ()
_ -> Left "Period is not properly defined"
inPeriod x =
\case
(Started {..} : _) ->
if x >= from && x <= to
then return ()
else Left "Out of period"
_ -> Left "Period is not properly defined"
listEntries = foldl' go mempty
where
go entries =
\case
Started {} -> entries
EntryAdded x -> Map.insert x.entryId (Right x) entries
EntryAmountChanged {} -> entries
EntryValidated {} -> entries
EntryCommented {} -> entries
EntryMarkedInConflict {} -> entries
EntryDeleted {..} -> Map.insert entryId (Left ()) entries
validEntry entryId events = do
let entries = listEntries events
case Map.lookup entryId entries of
Nothing -> Left "Unknown entry"
Just (Left ()) -> Left "Deleted entry"
Just (Right _) -> Right ()
Next time we'll briefly talk about testing.