Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/slashdotdash/eventsourced
Functional domain models with event sourcing in Elixir
https://github.com/slashdotdash/eventsourced
Last synced: about 2 months ago
JSON representation
Functional domain models with event sourcing in Elixir
- Host: GitHub
- URL: https://github.com/slashdotdash/eventsourced
- Owner: slashdotdash
- License: mit
- Created: 2016-02-21T15:00:39.000Z (almost 9 years ago)
- Default Branch: master
- Last Pushed: 2017-05-15T19:46:19.000Z (over 7 years ago)
- Last Synced: 2024-10-13T14:55:59.822Z (2 months ago)
- Language: Elixir
- Homepage:
- Size: 25.4 KB
- Stars: 104
- Watchers: 13
- Forks: 8
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
- freaking_awesome_elixir - Elixir - Build functional, event-sourced domain models. (Macros)
- fucking-awesome-elixir - eventsourced - Build functional, event-sourced domain models. (Macros)
- awesome-elixir - eventsourced - Build functional, event-sourced domain models. (Macros)
README
# 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
[![Build Status](https://travis-ci.org/slashdotdash/eventsourced.svg?branch=master)](https://travis-ci.org/slashdotdash/eventsourced)
### Creating a new aggregate root and invoking command functions
```elixir
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
```elixir
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.
```elixir
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
enddefmodule MoneyDeposited do
defstruct amount: nil, balance: nil
enddefmodule MoneyWithdrawn do
defstruct amount: nil, balance: nil
end
endalias 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})
enddef deposit(%BankAccount{} = account, amount) when amount > 0 do
balance = account.state.balance + amountaccount
|> update(%MoneyDeposited{amount: amount, balance: balance})
enddef withdraw(%BankAccount{} = account, amount) when amount > 0 do
balance = account.state.balance - amountaccount
|> 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
}
enddef apply(%BankAccount.State{} = state, %MoneyDeposited{} = money_deposited) do
%BankAccount.State{state |
balance: money_deposited.balance
}
enddef 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.
```elixir
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
### Return `:ok` or `:error` tuples
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.
```elixir
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}
enddef 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.
```elixir
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.```elixir
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"
enddef open_account(%BankAccount{} = account, account_number, initial_balance) when initial_balance <= 0 do
raise InvalidOpeningBalanceError
enddef 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.
```elixir
account =
BankAccount.new("123")
|> BankAccount.open_account("ACC123", 100)
|> BankAccount.deposit(50)
```