Functional Domain Models with Event Sourcing in Elixir
Build functional, event-sourced domain models.
- Aggregate root public methods accept the current state and a command, returning the new state (including any applied events).
- Aggregate root state is rebuilt from events by applying a
reduce
function, starting from an empty state.
MIT License
Creating a new aggregate root and invoking command functions
account =
BankAccount.new("1234")
|> BankAccount.open_account("ACC123", 100)
|> BankAccount.deposit(50)
|> BankAccount.withdraw(75)
Populating an aggregate root from a given list of events
events = [
%BankAccountOpened{account_number: "ACC123", initial_balance: 100},
%MoneyDeposited{amount: 50, balance: 150},
%MoneyWithdrawn{amount: 75, balance: 75}
]
account = BankAccount.load("1234", events)
Event-sourced domain model
State may only be updated by applying an event. This is to allow internal state to be reconstituted by replaying a list of events. We Enum.reduce
the events against the empty state.
An apply/2
function must exist for each event the aggregate root may publish. It expects to receive the aggregate's state (e.g. %BankAccount.State{}
) and the event (e.g. %BankAccount.Events.MoneyDeposited{}
). It is responsible for updating the internal state using fields from the event.
Using the EventSourced.AggregateRoot
macro, the example bank account example listed above is implemented as follows.
defmodule BankAccount do
use EventSourced.AggregateRoot, fields: [account_number: nil, balance: nil]
defmodule Events do
defmodule BankAccountOpened do
defstruct account_number: nil, initial_balance: nil
end
defmodule MoneyDeposited do
defstruct amount: nil, balance: nil
end
defmodule MoneyWithdrawn do
defstruct amount: nil, balance: nil
end
end
alias Events.{BankAccountOpened,MoneyDeposited,MoneyWithdrawn}
def open_account(%BankAccount{} = account, account_number, initial_balance) when initial_balance > 0 do
account
|> update(%BankAccountOpened{account_number: account_number, initial_balance: initial_balance})
end
def deposit(%BankAccount{} = account, amount) when amount > 0 do
balance = account.state.balance + amount
account
|> update(%MoneyDeposited{amount: amount, balance: balance})
end
def withdraw(%BankAccount{} = account, amount) when amount > 0 do
balance = account.state.balance - amount
account
|> update(%MoneyWithdrawn{amount: amount, balance: balance})
end
# event handling callbacks that mutate state
def apply(%BankAccount.State{} = state, %BankAccountOpened{} = account_opened) do
%BankAccount.State{state |
account_number: account_opened.account_number,
balance: account_opened.initial_balance
}
end
def apply(%BankAccount.State{} = state, %MoneyDeposited{} = money_deposited) do
%BankAccount.State{state |
balance: money_deposited.balance
}
end
def apply(%BankAccount.State{} = state, %MoneyWithdrawn{} = money_withdrawn) do
%BankAccount.State{state |
balance: money_withdrawn.balance
}
end
end
This is an entirely functional event-sourced aggregate root.
Testing
The domain models can be simply tested by invoking a public command method and verifying the correct event(s) have been applied.
test "deposit money" do
account =
BankAccount.new("123")
|> BankAccount.open_account("ACC123", 100)
|> BankAccount.deposit(50)
assert account.pending_events == [
%BankAccountOpened{account_number: "ACC123", initial_balance: 100},
%MoneyDeposited{amount: 50, balance: 150}
]
assert account.state == %BankAccount.State{account_number: "ACC123", balance: 150}
assert account.version == 2
end
Handling business rule violations
:ok
or :error
tuples
Return This is the most common and idiomatic Elixir approach to writing functions that may error.
The aggregate root must return either an {:ok, aggregate}
or {:error, reason}
tuple from each public API function on success or failure.
defmodule BankAccount do
use EventSourced.AggregateRoot, fields: [account_number: nil, balance: nil]
# ... event and command definition as above
def open_account(%BankAccount{} = account, account_number, initial_balance) when initial_balance <= 0 do
{:error, :initial_balance_must_be_above_zero}
end
def open_account(%BankAccount{} = account, account_number, initial_balance) when initial_balance > 0 do
{:ok, update(account, %BankAccountOpened{account_number: account_number, initial_balance: initial_balance})}
end
end
Following this approach allows strict pattern matching on success and failures. An error indicates a domain business rule violation, such as attempting to open an account with a negative initial balance.
You cannot use the pipeline operator (|>
) to chain the functions. Use the with
special form instead. This is demonstrated in the example below.
with account <- BankAccount.new("123"),
{:ok, account} <- BankAccount.open_account(account, "ACC123", 100),
{:ok, account} <- BankAccount.deposit(account, 50),
do: account
Raise an exception
Prevent the aggregate root function from successfully executing by using one of the following tactics.
- Use guard clauses and pattern matching on functions to prevent invalid invocation.
- Raise an exception when a business rule violation is encountered.
defmodule BankAccount do
use EventSourced.AggregateRoot, fields: [account_number: nil, balance: nil]
# ... event and command definition as above
defmodule InvalidOpeningBalanceError do
defexception message: "initial balance must be above zero"
end
def open_account(%BankAccount{} = account, account_number, initial_balance) when initial_balance <= 0 do
raise InvalidOpeningBalanceError
end
def open_account(%BankAccount{} = account, account_number, initial_balance) when initial_balance > 0 do
update(account, %BankAccountOpened{account_number: account_number, initial_balance: initial_balance})
end
end
This allows you to use the pipeline operator (|>
) to chain functions.
account =
BankAccount.new("123")
|> BankAccount.open_account("ACC123", 100)
|> BankAccount.deposit(50)