No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
renovate-bot cbccee8814
All checks were successful
ci/woodpecker/push/test Pipeline was successful
chore(deps): update dependency erlang to v29.1.2
2026-10-21 22:23:13 +00:00
.woodpecker fix: Fix mise in CI 2026-10-20 09:49:30 -06:00
lib docs: Document Tragedy.Listener funcs 2026-10-21 16:24:33 -06:00
test fix: give handle/2 one shape for the commands a saga issues 2026-08-21 21:46:17 -06:00
.formatter.exs Initial commit 2024-10-25 10:47:35 -06:00
.gitignore Add dialyzer and start typespecs 2024-10-25 13:43:04 -06:00
LICENSE.md Add a README and LICENSE 2024-10-25 12:19:05 -06:00
mise.toml chore(deps): update dependency erlang to v29.1.2 2026-10-21 22:23:13 +00:00
mix.exs chore(deps): update dependency decimal to ~> 3.2.0 2026-10-17 20:11:32 +00:00
mix.lock chore: Lockfile update 2026-10-21 16:24:20 -06:00
README.md fix: give execute/2 one shape for events and one for a refusal 2026-08-21 21:27:23 -06:00
renovate.json Update renovate.json 2026-05-27 16:03:54 +00:00

Tragedy

status-badge

Tragedy is a framework for event-sourced applications, inspired heavily by Commanded.

Define your domain using the protocols and behaviors provided, then you can start dispatching events to Tragedy where they will update aggregates and publish events.

Warning

This project is called Tragedy because that's what will happen if you try to use this in production. It is currently very experimental.

Usage

Tragedy organizes processes into three categories: Aggregates, Sagas, and Listeners.

  • Aggregates form the core of your business logic, receiving commands from your application, and producing events that describe what actions take place.
  • Sagas receive events and can issue commands of their own, so that you can coordinate aggregates and implement complex logic with steps that can fail.
  • Listeners also receive events and allow you to plug in arbitrary business logic, like read model projections. Write one with use Tragedy.Listener.

First, define an aggregate with Tragedy:

defmodule MyApp.BankAccount do
  use Tragedy, :aggregate

  defstruct [
    :account_id,
    :balance
  ]

  def new(id) do
    %__MODULE__{account_id: id}
  end

  defimpl Tragedy.Aggregate do
    def id(account) do
      account.account_id
    end

    def execute(%{account_id: account_id}, %MyApp.CreateAccount{account_id: account_id}) do
      [%MyApp.AccountCreated{account_id: account_id}]
    end

    def apply(account, %MyApp.AccountCreated{}) do
      %{account | balance: 0}
    end
  end
end

Then, you must define a command that will affect that aggregate, as well as one or more events that indicate what the command caused to happen:

defmodule MyApp.CreateAccount do
  @derive {Tragedy.Command, mod: MyApp.BankAccount, key: :account_id}
  defstruct [
    :account_id
  ]
end

defmodule MyApp.AccountCreated do
  defstruct [
    :account_id
  ]
end

Once you have these data structures, you can hand them to Tragedy to be run.

{:ok, pid} =
  Tragedy.DomainSupervisor.start_link(%Tragedy.DomainConfig{
    name: MyApp.Domain,
    event_store: {Tragedy.EventStore.InMemory, []}
  })

:ok = Tragedy.DomainSupervisor.dispatch(pid, %MyApp.CreateAccount{account_id: "1"})

When this CreateAccount command is handed to the domain supervisor, Tragedy will:

  1. create a new GenServer process for the BankAccount aggregate with account_id: "1"
  2. execute the command in the new aggregate, which produces an AccountCreated event
  3. record the event in the domain's event store
  4. update the aggregate using the event

Since we don't have any sagas or listeners, that's the end of this story. See the HexDocs for information on how to create those.

Architecture

Aggregates, sagas, and listeners all run in separate processes, and handle events concurrently, each category under its own supervisor.

flowchart TD
    DomainSupervisor
    DomainSupervisor --> AggregatePoolSupervisor
    DomainSupervisor --> SagaSupervisor
    DomainSupervisor --> ListenerSupervisor
    DomainSupervisor --> CommandRouter
    DomainSupervisor --> TaskSupervisor
    DomainSupervisor --> EventStore
    CommandRouter -->|1 per dispatch| TaskSupervisor
    AggregateServer --> EventStore
    AggregatePoolSupervisor -->|1..n| AggregateServer
    SagaSupervisor -->|1..j| SagaServer
    ListenerSupervisor -->|1..k| Listener

Dispatches do not wait on each other. Each one runs in a process of its own, so a slow listener holds up only the command it is handling. An aggregate still runs one command at a time, but nothing orders one dispatch against another: two commands sent at once can reach an aggregate in either order, and a listener can see the second one's events before the first one's.

An aggregate records an event in the domain's event store before it applies the event, and before any saga or listener is given it. So an aggregate never holds a change the store rejected. Pick the store with the event_store field of Tragedy.DomainConfig; Tragedy.EventStore.InMemory keeps every event in one process, and loses them all when the domain stops.

Nothing reads those events back yet. An aggregate or saga that crashes is gone, and the next command builds it again the way its new/1 function does, with every event it had applied forgotten. An account with a balance of 100 comes back with a balance of 0, while the store still holds the events that would rebuild it. This is the main reason not to use Tragedy for anything you care about.

Installation

If available in Hex, the package can be installed by adding tragedy to your list of dependencies in mix.exs:

def deps do
  [
    {:tragedy, "~> 0.1.0"}
  ]
end

Documentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/tragedy.

License

Copyright © 2024 Rosa Richter

This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License along with this program. If not, see https://www.gnu.org/licenses/.