A simplified set of tools for Event Sourcing.
While there are many technical and business benefits to Event Sourcing, proper implementation tends to require understanding of many new concepts and technical details. This package aims to make implementation easier and more enjoyable by providing a small extensible API with few dependencies baked in.
Installation
If available in Hex, the package can be installed
by adding must to your list of dependencies in mix.exs:
def deps do
[
{:must, "~> 0.1.0"}
]
endInterface
Must.process_command/2: a unified function for processing a command.Must.Command: an extensible protocol for processing commands.Must.Event: an extensible protocol for processing events.Must.Storage: a behaviour for storing events.
When prototyping a system or testing Must's fitness, it may be unnecessary to fully implement the Must.Command and Must.Event protocols. Both protocols have a @fallback_to_any true directive, so it is possible to define a fallback implementation using Any.
For example, authorization may be bypassed by defining a fallback implementation that returns the command as-is:
defimpl Must.Command, for: Any do
def be_authorized!(command, _opts), do: command
endEvent Persistence
Several adapters are planned to support different persistence strategies:
- [ ] ETS
- [ ] DurableServer
- [ ] Ecto SQL
- [ ] Ecto SQLite3
- [ ] ClickHouse
- [ ] AVRO file
Each adapter will need to:
- Initialize a standardized data structure (see cloudevents spec)
- Persist events to a storage backend
- Handle event persistence errors
- Provide a way to query events from the storage backend
- Track the last seen event version
- Handle event version conflicts
- Support testing
Event Delivery
Several delivery mechanisms are planned to support different event delivery strategies:
- [ ] Phoenix PubSub
- [ ] GenStage
- [ ] Kafka
- [ ] RabbitMQ
- [ ] WebSockets
- [ ] Server-Sent Events (SSE)
Examples
Many systems have a process for activating a user .
defmodule ActivateUser do
@moduledoc "Command for activating a user."
use Ecto.Schema
@primary_key false
embedded_schema do
field :user_id, :integer
end
def changeset(%__MODULE__{} = command, params) do
fields = __MODULE__.__schema__(:fields)
command
|> Ecto.Changeset.cast(params, fields)
|> Ecto.Changeset.validate_required(fields)
end
defimpl Must.Command do
def be_authorized!(%__MODULE__{} = command, opts) do
actor = Keyword.fetch!(opts, :actor)
actor.user_id != command.user_id
actor.organization.status == :active
actor.organization.role in [:admin, :manager]
end
def be_valid!(command, opts) do
params = Keyword.fetch!(opts, :params)
command
|> changeset(params)
|> Ecto.Changeset.apply_action!(:validate)
end
def be_translated_to_events!(command, opts) do
metadata = Keyword.get(opts, :metadata, %{})
[
%UserActivated{user_id: command.user_id, metadata: metadata}
]
end
end
endThe example above demonstrates:
- How to define a command struct and changeset
- How to implement the
Must.Commandprotocol
Colocation
While Must.Command is implemented directly in the ActivateUser example, it is also possible to define implementations elsewhere. Having the command and its rules in one place may aid developers and LLMs to understand the behavior while minimizing context switching.
However, this is not a requirement. Some teams may prefer to consolidate implementations into a separate module/file, for example.
The simplest way to process a command is to use the Must.process_command/2 function, which takes a command struct and a keyword list of options. The option keys are determined by the Must.Command implementation.
%ActivateUser{}
|> Must.process_command(
params: %{"user_id" => 123},
metadata: %{"actor" => current_user}
)If the command is processed successfully, a list of events will be returned:
[
%UserActivated{
user_id: 123,
metadata: %{"actor_id" => 1, "timestamp" => ~U[2026-01-01 01:00:00.123456Z]}
}
]Design Decisions
To support a wide variety of use cases, the Must protocols may be implemented for structs or plain maps. For most systems, it is recommended to define commands as structs to provide clear intent to developers and coding tools. This approach also allows authorization, validation, and handling to be implemented close to the command definition. Readers can view a single file to understand the command definition and its behavior.
For best results, return the command struct if all conditions are met, or raise an error if any conditions are not met
Each protocol accepts two arguments: a struct/map and options. The protocol is intentionally agnostic about what data is passed as options. Some implementations may options as keyword lists, while others may use a struct/map. It is recommended to establish follow consistent patterns for each protocol implenentation to support effictient development and maintenance.
What Abouts
Experienced Event Sourcing developers may be wondering where several typical components and concerns are defined in this package.
- Projections
- Process Managers
- Value Objects
- Contexts
- Aggregates
- Dynamic consistency boundaries
- Snapshots
Must aims to empower engineers to be productive quickly, with or without prior Event Sourcing experience. The value of Event Sourcing is in its state management and reactivity properties, not in its jargon. With a simpler approach, the hope is to make Event Sourcing accessible to a wider audience. Technicians and leaders who are apprehensive about adopting Event Sourcing may find Must to be a more approachable alternative to implementations which strictly adhere to the academic concepts.
While the interface is simple, all of the traditional Event Sourcing concepts may be supported through Must's extensible design.