commanded
commanded copied to clipboard
Explicit aggregate factory functions
A function that creates an aggregate root is currently the same as any function expecting an existing aggregate instance.
You must use some field from the aggregate's state to determine whether it is a new (default state) or existing (populated from the first raised event) instance.
Here's an example using the bank account aggregate in the unit tests. It uses pattern matching on the bank account's account_number field checking that it is nil to ensure a new account is being opened
Current: Open account with default %BankAccount{} state
The execute/2 function receives default state when no events exist for the aggregate:
defmodule Commanded.ExampleDomain.BankAccount do
defstruct [
account_number: nil,
balance: 0,
]
def execute(%BankAccount{account_number: nil}, %OpenAccount{account_number: account_number, initial_balance: initial_balance})
when is_number(initial_balance) and initial_balance > 0
do
%BankAccountOpened{account_number: account_number, initial_balance: initial_balance}
end
end
Proposal: Open account with no state
An execute/1 function would be called when there are no events for the given aggregate (i.e. we are creating a new instance):
defmodule Commanded.ExampleDomain.BankAccount do
defstruct [
account_number: nil,
balance: 0,
]
def execute(%OpenAccount{account_number: account_number, initial_balance: initial_balance})
when is_number(initial_balance) and initial_balance > 0
do
%BankAccountOpened{account_number: account_number, initial_balance: initial_balance}
end
end
Alternatively, rename the execute function to create when executing a command against a new aggregate instance:
defmodule Commanded.ExampleDomain.BankAccount do
defstruct [
account_number: nil,
balance: 0,
]
def create(%OpenAccount{account_number: account_number, initial_balance: initial_balance})
when is_number(initial_balance) and initial_balance > 0
do
%BankAccountOpened{account_number: account_number, initial_balance: initial_balance}
end
end
This makes it explicit that certain aggregate function(s) expect to create a new instance. Pattern matching will cause an exception when attempting to create more than one instance (e.g. open an account for the same account number).
How will this translate to non-execute version? Or will it just stay the same (which is ok I think)?
@drozzy Good catch, I think that also needs to change, proposal as below.
Current: Command handler receives default %BankAccount{} state
defmodule OpenAccountHandler do
@behaviour Commanded.Commands.Handler
def handle(%BankAccount{} = aggregate, %OpenAccount{account_number: account_number, initial_balance: initial_balance}) do
BankAccount.open_account(aggregate, account_number, initial_balance)
end
end
Proposal: Command handler receives no state
When there are no persisted events for the aggregate.
defmodule OpenAccountHandler do
@behaviour Commanded.Commands.Handler
def handle(%OpenAccount{account_number: account_number, initial_balance: initial_balance}) do
BankAccount.open_account(account_number, initial_balance)
end
end
Wouldn't that need to percolate down to the apply function?
It presently takes a state. Unless you do:
def apply(%AccountOpened{..}) do
%InitialStateHere{}
end
@drozzy I'm undecided about changing the apply/2 function.
Is there any update on this issue?
Still undecided? Abandoned ?
@slashdotdash
I would prefer not using a apply but a create function, since the aggregate does not exists if it is not created.
It does makes more sense to say that a command was processed and created and event and and aggregate, that say that the aggregate already existed with nil fields
It would also help with pattern matching on execute(), if there's no aggregate it should return nil on the execute function and the default call should return {:error, :aggregate_not_created}
Prefer something like new or initial_state; otherwise, it would be confusing. Also, some folks are aligned around https://thinkbeforecoding.com/post/2021/12/17/functional-event-sourcing-decider in terms of naming things to some extent.
Being said, since structs allow you to have default values, I am wondering if this should be a feature.