A Domain-Driven Design framework for Elixir, built on behaviours and macros: entities,
aggregates and value objects that can never exist in an invalid state, specifications,
commands and queries, a transactional event outbox, projections, and an MCP server so an AI
client can use the application. Its modules are DDD.*. The example application that uses it is
todo_app (a sibling project, ../todo_app).
Field rules and specification invariants run on new, update and every business operation.
Installation
# mix.exs
def deps do
[
{:vicander, "~> 0.1"},
# the database driver is yours to choose; PostgreSQL is the one the framework supports
{:postgrex, ">= 0.0.0"}
]
end# .formatter.exs: the DSL (`field :name, :string`) is written without parentheses
[import_deps: [:ecto, :ecto_sql, :vicander], inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]]Database
The framework depends on ecto_sql but not on a driver: your application adds the one it needs
(postgrex, ...), and DDD.Dialect follows from the repo's adapter. Almost everything is an
Ecto query, so it runs on whatever the repo runs on; the few things that differ between databases
are behind the DDD.Dialect behaviour:
| Ecto adapter | Dialect | Status |
|---|---|---|
Ecto.Adapters.Postgres | DDD.Dialect.Postgres | supported (14 and later) |
For another adapter, implement DDD.Dialect (lock_for_claim/1, now/1, read_only/2,
columns/2) and set config :vicander, dialect: MyApp.Dialect. A repo with no dialect raises an
ArgumentError naming its adapter.
What is not portable yet:
- lists of plain values (
{:array, :string}) are native Postgres arrays, and the:ilikefilter is Postgres'sILIKE; - the generated outbox migration has partial indexes (
where:), which MySQL does not have.
Leases and retry times are compared with the database's clock (DDD.Dialect.now/1), so nodes
whose clocks differ still agree on when a lease is over. Nested entities, value objects and lists
of them are one jsonb column each.
Setup
Tell it your repo. Commands, queries, handlers, projections and the
mix vic.*tasks use the repo you configure, unless one names another withrepo::# config/config.exs config :vicander, repo: MyApp.RepoCreate the outbox tables, once, and one migration per aggregate:
mix vic.gen.outbox_migration mix vic.gen.migration MyApp.Domain.Order mix ecto.migrateStart the framework in your supervision tree, after the repo. It starts the event bus, subscribes the handlers and projections you list, and runs the outbox poller and the dead-letter handler (
poller: falsefor a node that should not deliver events, and in tests):children = [ MyApp.Repo, {DDD.Supervisor, handlers: [MyApp.Handlers.SendWelcome, MyApp.Projections.OrderSummary]} ]
The framework starts nothing by itself, and reads nothing from your application's own
configuration. See DDD.Config and DDD.Supervisor.
Developing the framework
mix deps.get
mix test # creates and migrates the test database; needs Postgres on localhost
mix credo --strict # style and complexity (config in .credo.exs); dev only
mix dialyzer # types; the first run builds a cache in priv/plts (a few minutes)
Postgres defaults for the tests live in config/test.exs (postgres/postgres on
localhost). The tests use DDD.TestRepo and the fixtures in test/support; the outbox tables
come from test/support/repo/migrations. Test output is quiet by default (warnings and errors
only). LOG=1 mix test shows the whole flow, and LOG=1 mix test path/to/file_test.exs:LINE
follows a single test:
[command] MyApp.Commands.CompleteItem started %{item_id: ..., list_id: ...}
[op] MyApp.Domain.TodoItem.complete ok
[op] MyApp.Domain.TodoList.complete_item ok
[repo] saved MyApp.Domain.TodoList <id> (1 event(s) written to the outbox)
[command] MyApp.Commands.CompleteItem ok in 7.8ms
[outbox] published MyApp.Events.ItemCompleted (MyApp.Domain.TodoList <id>)
[event_bus] MyApp.Events.ItemCompleted -> 1 handler(s): MyApp.Handlers.AchievementTracker[op] and [event_bus] lines are debug, the rest info. Failed commands log the error codes
([command] ... failed in 3.1ms: title_blank (MyApp.Domain.TodoItem.new) ...). SQL queries are
not shown unless you also set SQL=1 (LOG=1 SQL=1 mix test ...). Tests that assert on a log
line use capture_log, so those stay captured.
Project standards
No compile warnings (
warnings_as_errorsis on).At least 90% test coverage (
mix test --coverfails below the threshold).One module per file. In
lib/dddthe file is named after its module and sits in a folder for its concern; the module names stay short (DDD.Entity), so the folders do not appear in them (test/ddd/layout_test.exschecks this):lib/ddd/ model/ entity, aggregate, value_object, schema, identity, type, money, specification, error, bounded_context, domain_service use_cases/ command, query, params, filter, listing, page, slice events/ domain_event, event_bus, event_handler, outbox/ (poller, dead letters) persistence/ repository, in_memory_repository, repository/ecto, migration, projection, serializer mcp.ex, mcp/ the MCP server: protocol core, registry, schema, args, json, stdio operations/ health, telemetry
Concepts
Entity, aggregate, value object
defmodule MyApp.Title do
use DDD.ValueObject
value_object do
field :value, :string, required: true
invariant MyApp.Specs.TitleWithinLimit, on: :value, max: 100
end
end
defmodule MyApp.TodoItem do
use DDD.Entity
entity do
identity :id, :uuid
field :title, {:one, MyApp.Title}, required: true
field :status, {:enum, [:open, :done]}, default: :open
invariant MyApp.Specs.TitleNotBlank
end
defop complete(item), requires: MyApp.Specs.ItemNotCompleted do
%{item | status: :done}
end
end
{:ok, item} = MyApp.TodoItem.new(title: title)
{:ok, item} = MyApp.TodoItem.complete(item)
{:error, %DDD.Error{code: :precondition_failed} = error} = MyApp.TodoItem.complete(item)
DDD.Error.has_code?(error, :already_completed) # => truefield name, type, default: ..., required: true. Presence isrequired: true; every other rule is a specification attached withinvariant. The example app has novalidateat all. (validate field, fun, message: ...still exists as a shortcut for a throwaway one-liner, but it has no name, code or reuse, and it is skipped fornil.)new/1,update/2(attrs or a function of the current entity) andvalidate/1run all rules, and report every broken rule at once, each with its own code. Specs run after the required fields are present, so they only ever see a well-formed entity.DDD.Aggregateaddsemit_event/2andpop_events/1. Nested entities are declared as{:list, Entity}or{:one, Entity}and live inside the aggregate.DDD.ValueObjecthasnew/1andequals?/2and noupdate.- Entities and aggregates get
created_atandupdated_at(:datetime_usec) automatically.new/1sets both,update/2and everydefopbumpupdated_at,validate/1does not, andupdate/2ignores attempts to set them. Value objects are immutable and have none.
Identity
Every entity and aggregate has an identity, made of one or more fields, one identity each:
identity :id, :uuid # the usual case
identity :tenant_id, :uuid # or several: the same SKU in every tenant
identity :sku, :stringOrder.identity(order)returns it as a string: the value itself for one field, the values joined with:for several ("6f1f...:A-100"). A:or\inside a part is escaped with a\, so two different entities can never share a string.DDD.Identity.decode/1splits it back.Order.build_identity(tenant_id: t, sku: "A-100")makes the same string without an entity, andOrder.identity_fields()lists the part names.- Every part is required, and none can change:
update/2ignores them and adefopthat changes one returns:identity_changed. A part of a composite identity must be a string, uuid, integer, atom, date or enum field. - Ids. Only a lone
identity :id, :uuidis generated bynew/1, as a UUIDv7 (time-ordered, so indexes stay compact), and only when you leave it out. Every other identity is passed in.DDD.Identitymakes ids by hand:generate/0(v7),uuid7/0,uuid4/0,uuid1/0,uuid3/2,uuid5/2. - Storage. One field is the table's primary key in its own type (
uuid). A composite identity is stored whole in avarcharprimary key column namedidentity, next to normal not-null columns for each part.find_by_id/1, the outboxaggregate_idof an event, and a{:ref, Aggregate}to a composite aggregate all use the identity string. - Changing an existing aggregate's identity is destructive:
mix vic.gen.migrationrefuses without--allow-destructive, and then writes a migration with aTODOto backfill the new key for existing rows. It never drops the old key.
Field types
Every field has a type, and a value of the wrong type is a violation with the code
:invalid_type (nil is accepted; use required: true to forbid it). A misspelled type fails at
compile time.
| type | value | column |
|---|---|---|
:string, :text | binary | varchar, text |
:integer, :float, :boolean | number, boolean | bigint, float, boolean |
:decimal | Decimal | numeric |
:atom | atom | varchar |
:uuid | UUID string | uuid |
:date, :time, :naive_datetime, :datetime, :datetime_usec | Date, Time, NaiveDateTime, DateTime | date, time(6), timestamp(6), timestamp(0), timestamp(6) |
:binary | binary | bytea |
:map, :json | map, or map or list | jsonb |
{:enum, [:open, :done]} | one of the atoms | varchar |
{:array, type} | list of type | Postgres array, e.g. varchar[] |
{:map, type} | map with string keys, values of type | jsonb |
{:ref, Aggregate} | the id of another aggregate | the type of its identity |
{:one, Module}, {:list, Module} | nested entities or value objects (a list is one JSON array) | jsonb |
:money | DDD.Money ({:one, DDD.Money}) | jsonb |
field :status, {:enum, [:open, :done]}, default: :open
field :tags, {:array, :string}, default: []
field :price, :money
field :ship_to, {:one, Address}
field :customer_id, {:ref, Customer}DDD.Money is a value object with a Decimal amount and a 3-letter currency. Money.add/2,
subtract/2 and compare/2 return a business error (:currency_mismatch) for two currencies;
there is no silent conversion. A reference is a plain id, so aggregates stay separate; it is
checked against the identity type of the other aggregate. Arrays of arrays are not supported.
DDD.Type is the one place that knows what a type means: checking, Postgres, and JSON.
Domain events
defmodule MyApp.Events.ItemAdded do
use DDD.DomainEvent
domain_event do
field :item_id, :string
field :title, :string
end
endEvery event has event_id, occurred_at, created_at and a link to the aggregate it came
from: aggregate_type (the module name as a string) and aggregate_id. emit_event/2 fills
the link in, so events never carry the aggregate's id by hand. The outbox stores the same
link in its aggregate_type / aggregate_id columns, and the poller restores it.
Specifications
A specification is a named, reusable, composable business rule. It is a pure function of the entity.
defmodule MyApp.Specs.MaxOpenItems do
use DDD.Specification, field: :items
def check(%{items: items}, opts) do
max = Keyword.fetch!(opts, :max)
if Enum.count(items, &(&1.status == :open)) <= max,
do: :ok,
else: {:error, :too_many_open_items, "at most #{max} open items", %{max: max}}
end
end
# attach it to an entity or aggregate:
invariant MyApp.Specs.MaxOpenItems, max: 10
# use it on its own, or compose it:
MyApp.Specs.MaxOpenItems.satisfied_by?(list, max: 10)
DDD.Specification.evaluate(DDD.Specification.all_of([A, {B, max: 3}]), entity)Value specs check one field and are bound to it when attached, so the link is explicit and one spec serves any field:
defmodule MyApp.Specs.ValidEmail do
use DDD.Specification, scope: :value
def check(email, _opts) when is_binary(email) do
if String.contains?(email, "@"), do: :ok, else: {:error, :invalid_email, "must be an email"}
end
end
invariant MyApp.Specs.ValidEmail, on: :email
invariant MyApp.Specs.ValidEmail, on: :billing_emailcheck/2 receives the value (never nil: absence is required: true's job) and a failure is
reported under the on: field. Forgetting on: or naming a field the entity doesn't have is a
compile error, so a rename can't leave a spec silently checking nothing. A spec that needs
several fields (use DDD.Specification, field: :items, check(entity, opts)) is an entity spec
and takes no on:.
all_of/1, any_of/1 and not_spec/1 build composite specs. Specs run only after field rules
pass, so they can assume required fields are present. Rules that need the database (for
example "the owner exists") belong in the command handler.
Business operations: defop
defop marks a function as a business operation. The body returns the new struct, or
{:error, reason}. The framework then validates and seals the result.
State the business rules explicitly, as named preconditions, not by pattern-matching the entity in the function head:
defop complete(item), requires: MyApp.Specs.ItemNotCompleted do
%{item | status: :done}
end
defop rename(item, %MyApp.Title{} = title), requires: MyApp.Specs.ItemEditable do
%{item | title: title}
end
# Several rules, some with options; all of them are checked and all failures reported:
defop ship(order), requires: [MyApp.Specs.OrderIsPaid, {MyApp.Specs.InStock, warehouse: :main}] do
%{order | status: :shipped}
endrequires: takes specifications (see above), checked against the entity (the first argument)
before the body runs. A broken rule returns a business error with code: :precondition_failed,
the rule's own message, and one violation per broken rule carrying its code:
DDD.Error.has_code?(error, :already_completed). Every broken rule is reported at once.
Invariants are the mirror image: they check the state after an operation.
The operation's other arguments reach each spec as opts[:args], so a rule can depend on
them. That is how the example's TodoList.complete_item(list, item_id) states its prerequisites
where a reader looks:
defop complete_item(list, item_id),
requires: [Specs.ItemExists, {Specs.ItemSatisfies, spec: Specs.ItemNotCompleted}] do
...
end
# ItemExists reads the id from opts[:args]:
def check(%{items: items}, opts) do
[item_id | _] = Keyword.fetch!(opts, :args)
if Enum.any?(items, &(&1.id == item_id)), do: :ok, else: {:error, :item_not_found, "..."}
endItemSatisfies lifts a rule about one item (written once, on the item) to the list that holds
it. Specs are evaluated against the first argument only, so a rule about an item can't be
attached to the list directly: the list has no status, and the spec would pass every call.
Patterns in the head are for the shape of the arguments (%Date{}, %Title{}), not for
business rules.
- Arguments that match no clause:
{:error, %DDD.Error{code: :invalid_operation}}. - Events emitted with
emit_event/2are dropped when the operation is rejected or the result is invalid. - An operation with several clauses is guarded by the preconditions of all of them.
Errors
Every failure is {:error, %DDD.Error{}} with kind, entity, entity_id, operation, code,
message, details, violations and context. entity is the kind of entity (the module) and
entity_id the identity of the instance it happened to (Entity.identity/1, so
"tenant:sku" for a composite identity), or nil when there is none: a new call missing its
identity, or an error that isn't about an instance. Validation, preconditions, unsealed structs,
identity changes, a lost update and a failed save all carry it. An MCP client sees entity (the
short name) and entity_id in a business error. The kind is :business (the domain said no;
retrying gives the same answer) or :infrastructure (the system failed; retrying may succeed).
Running a command adds command to context. DDD.Error.has_code?/2 and
DDD.Error.to_map/1 (a %{field => [messages]} map) help callers.
Sealing
Every struct has a required __sealed__ key, so %Todo{...} literals do not compile by
accident. The seal is a fingerprint of the data, set only after validation. Repositories and
defop reject structs whose seal does not match (code: :unsealed). defop checks every framework
struct among its arguments, not just the entity, so add_item(list, item) refuses a forged item.
An aggregate's pending events are sealed too. emit_event/2 and pop_events/1 are the only
ways to change them and they keep the seal, so replacing the list by hand makes the aggregate
unsealed and defop, update/2 and save/1 refuse it. validate/1 also refuses an unsealed
aggregate that carries events, rather than sealing events it cannot vouch for.
Repositories
defmodule MyApp.TodoListRepository do # domain layer: the port
use DDD.Repository
repo MyApp.TodoList do
find_by :owner_id
end
end
defmodule MyApp.Infrastructure.TodoListRepository do # infrastructure: generated adapter
use DDD.Repository.Ecto, port: MyApp.TodoListRepository, repo: MyApp.Repo, table: "todo_lists"
endThe whole aggregate is one row. Nested entities are stored as jsonb and rebuilt into sealed
structs on load; keys missing from old rows take the field default. save/1 writes the
aggregate and its domain events (the outbox) in one transaction.
DDD.InMemoryRepository is available for tests.
Optimistic locking
Every aggregate has a version, which starts at 0 and is advanced by the repository on every
save, never by the domain (new/1 and update/2 ignore it; a version field you declare
yourself is left alone). save/1 writes UPDATE ... WHERE id = ? AND version = ?, so two
requests that loaded the same aggregate cannot both win: the second one gets
{:error, %DDD.Error{kind: :infrastructure, code: :stale_aggregate,
details: %{id: ..., expected_version: 3, actual_version: 4}}}and nothing is written, events included. It is an infrastructure error: nothing about the request is wrong, and running the command again on fresh data may well succeed. The outbox retries a handler that fails this way by itself; for a command say how often to re-run it:
use DDD.Command, retry_on_conflict: 3 # re-runs execute/1, up to 3 more timesA save is also a conflict when the row was deleted since you loaded it, or when a new
aggregate (version 0) uses an id that already exists. A command called inside an open
transaction is not retried: that transaction is already doomed. mix vic.gen.migration adds the
column (null: false, default: 0, so existing rows start at 0). Each conflict is a
[:ddd, :repository, :conflict] telemetry event.
Listing: filters, pages and cursors
An Ecto adapter (and a projection) also has list/1, count/1, page/1 and slice/1:
TodoListRepository.list(filter: [owner_id: id, status: {:in, [:open, :late]}], order: :title)
{:ok, %DDD.Page{entries: lists, page: 2, total_entries: 57, total_pages: 3}} =
TodoListRepository.page(filter: [owner_id: id], order: [desc: :created_at], page: 2, page_size: 20)
# keyset pagination: no OFFSET, no totals, stable while rows come and go
{:ok, %DDD.Slice{entries: lists, next_cursor: cursor}} = Repo.slice(order: :title, limit: 20)
{:ok, next} = Repo.slice(order: :title, limit: 20, after: cursor)The filter language (DDD.Filter) has equality, :gt :gte :lt :lte :neq :in :not_in :like :ilike, nil for NULL, and {:and, _}, {:or, _}, {:not, _}. Values are bound parameters.
The identity is always the last sort key, so every order is total and pages never overlap. A bad
field, operator, order, page number or cursor is a business error (:invalid_filter,
:invalid_order, :invalid_page, :invalid_cursor), because these usually arrive straight from
a request.
Specifications can search. A spec is a pure function of an entity, so the database can't run
it; a spec that also defines to_filter/1 (the same rule, written as a filter) can be given to
the repository, alone or composed with all_of, any_of and not_spec:
def to_filter(_opts), do: [status: :open, due: {:lt, Date.utc_today()}]
TodoListRepository.list(spec: DDD.Specification.all_of([Overdue, {OwnedBy, owner: id}]))A spec without to_filter/1 raises an ArgumentError there: it can only be checked in memory.
Keep the two in step; a test that runs both over the same rows is cheap.
Commands
A command is the input of one use case and carries its own handler, execute/1. The handler
loads aggregates through repositories, calls domain operations and saves. It holds no business
rules.
defmodule MyApp.Commands.AddItem do
use DDD.Command
command do
field :list_id, :string, required: true
field :title, :string, required: true
end
@impl DDD.Command
def execute(%__MODULE__{list_id: list_id, title: title}) do
with {:ok, list} <- load_list(list_id),
{:ok, item} <- MyApp.TodoItem.new(title: title),
{:ok, list} <- MyApp.TodoList.add_item(list, item) do
MyApp.Infrastructure.TodoListRepository.save(list)
end
end
end
MyApp.Commands.AddItem.call(%{list_id: id, title: "Buy milk"}) # builds, validates, executesnew/1builds and validates the command (required: truefields).call/1takes a struct or plain attrs. Every error comes back as aDDD.Errorwithcontext: %{command: MyApp.Commands.AddItem}.call!/1raises instead.
Transactions. execute/1 runs in its own database transaction by default. {:ok, _}
commits; {:error, _} rolls back everything the handler did, including every aggregate it
saved and their outbox events; an exception rolls back and propagates. Repository
transactions inside the handler join it, so a command that saves two aggregates is atomic.
Validation in new/1 runs before the transaction starts.
use DDD.Command # default: transaction on the configured repo
use DDD.Command, repo: MyApp.Repo # another repo
use DDD.Command, transaction: false # no transaction (e.g. in-memory repositories)Queries
A DDD.Query is the read counterpart of a command: fields, new/1, call/1, execute/1, but it
never saves an aggregate or emits an event. Its execute/1 runs in a read-only Postgres
transaction, so "this cannot change data" is enforced by the database, and it sees one
consistent snapshot. use DDD.Query, transaction: false opts out; a query called inside an open
transaction joins it.
defmodule MyApp.Queries.OpenItems do
use DDD.Query
query do
description "The items of a list that are not done yet"
field :list_id, :uuid, required: true, description: "The list to read"
end
@impl DDD.Query
def execute(%__MODULE__{list_id: id}), do: ... # {:ok, data} | {:error, reason}
endCommand and query fields are DDD.Types, checked on new/1 (:invalid_type). Every module
that can be described takes description "...": commands, queries, entities, aggregates and
value objects; field ..., description: "..." describes a field and
defop name(...), description: "..." an operation. Descriptions are plain metadata
(__description__/0, __ddd_description__/0); the MCP server reads them.
MCP server
The framework can serve the application over the Model Context Protocol,
so an AI client such as Claude can call its commands and queries and read its aggregates. The
protocol core is DDD.MCP (JSON-RPC 2.0, no extra dependency); the first transport is stdio.
defmodule MyApp.Interface.MCP do
use DDD.MCP.Server,
instructions: "Manage todo lists.",
commands: [MyApp.Commands.CreateList, {MyApp.Commands.RenameItem, name: "rename_todo"}],
queries: [MyApp.Queries.OpenItems],
repos: [MyApp.TodoList.Repository],
read_only: false,
authorize: &MyApp.MCPPolicy.authorize/2
endmix compile && mix vic.mcp MyApp.Interface.MCP # serve on stdin/stdout
mix vic.mcp MyApp.Interface.MCP --read-only # queries and resources only
claude mcp add todo -- mix vic.mcp MyApp.Interface.MCP- Nothing is exposed unless it is listed, and the commands and entities carry no MCP code.
What you list is the level of access: only
queries:andrepos:is a read-only server. - Tools. Each command or query is a tool named after the module (
AddItemisadd_item, overridable per entry; two with one name stop the server at startup). Its input schema is built from the fields: theDDD.Type(enums becomeenum,:uuida UUID string, nested value objects objects),required,defaultand the descriptions. Queries carryreadOnlyHint: true. A module with nodescriptioncannot be exposed. Arguments are converted from JSON throughDDD.Type; nested entities and value objects are built with their ownnew/1, so their rules run, and atoms are never created from input. - Resources. An exposed aggregate (list its repository port, which must define
impl/0) is read-only:todo_list://<identity>reads one andtodo_list://by/owner_id/<value>runs afind_bythe port declares, at most 100 results (truncatedsays when it was cut).resources/listis empty on purpose: nothing hands a client a whole table. Every field of the aggregate is shown; to keep data back, don't expose the aggregate and return a curated map from a query. - Errors. A business error (validation, not found, a precondition) is returned with its code, message and violations so the client can correct itself. An infrastructure error is a generic "the operation failed" and its details only go to the log. A malformed call gets a JSON-RPC error saying what was wrong.
- Access.
read_only: true(or--read-only) refuses every command.authorize:is called with(tool_name, arguments)before each tool call, and with("resource:<name>", %{"uri" => uri})before each resource read; return:okor{:error, reason}. - Stdio hygiene. Standard output carries only protocol messages. The task moves the logger to
standard error and turns the SQL log and the outbox poller off. Run
mix compilefirst, because Mix prints its compile output on standard output when sources changed.
Migrations
mix vic.gen.outbox_migration # the domain_events outbox table
mix vic.gen.migration MyApp.TodoList # first run: create table
mix vic.gen.migration MyApp.TodoList # later runs: only the difference
The generator keeps a snapshot of each aggregate in priv/ddd_snapshots/ and writes the
difference as a new migration. It never overwrites earlier migrations.
- Removing a column or changing its type loses data, so it is refused unless you pass
--allow-destructive. Destructive operations are marked in the generated file. - Renames must be explicit, otherwise they are a remove plus an add:
--rename old:new, orfield :name, :string, renamed_from: :old_name. (The example app moved frominserted_attocreated_atthis way, with no data loss.) - Edits inside a nested entity do not change columns. Old rows keep the old shape, and defaults fill the missing keys on load. Reshaping existing data needs a hand-written migration.
- Nested lists are stored as one
jsonbarray.
Check for drift between aggregates and the real tables in a test:
DDD.Migration.drift(MyApp.TodoList, "todo_lists", MyApp.Repo).
Outbox
Events are written to the domain_events table in the same transaction as the aggregate (and
inside the command's transaction), so a state change and its events commit together or not at
all. DDD.Outbox.Poller then delivers them to their handlers:
- Claim a batch of due events with a lease and
FOR UPDATE SKIP LOCKED, so several pollers or nodes never take the same event. - Deliver to every subscribed handler, synchronously.
- Mark published only when every handler succeeded or rejected. A system failure is
retried after an exponential backoff (1s, 2s, 4s ... capped at 5 minutes). After
max_retriesretries (default 10, so 11 attempts) the event is dead-lettered:failed_atis set,last_errorsays why, and it is no longer claimed.
The attempt is counted when the event is claimed, so even an attempt that kills the whole VM uses one up. If the server stops mid-delivery, the lease expires and the event is delivered again; handlers that had already succeeded are skipped.
Handlers
defmodule MyApp.OnItemCompleted do
use DDD.EventHandler, events: [MyApp.Events.ItemCompleted]
@impl DDD.EventHandler
def handle(%MyApp.Events.ItemCompleted{aggregate_id: list_id}) do
case MyApp.Commands.RecordProgress.call(%{list_id: list_id}) do
{:ok, _} -> :ok
{:error, error} -> {:error, error} # business: rejected. infrastructure: retried
end
end
end
MyApp.OnItemCompleted.subscribe()What handle/1 returns decides what happens, using the kind of the error (DDD.Error.kind):
handle/1 returns | Meaning | Outcome |
|---|---|---|
:ok | handled | delivered |
{:error, %DDD.Error{kind: :business}} or a plain reason like :not_found | the handler looked at the event and said no. Deterministic. | rejected: recorded in event_deliveries (outcome: "rejected"), never retried, not a dead letter |
{:error, %DDD.Error{kind: :infrastructure}}, an exception, a throw | the system failed | retried with backoff; dead-lettered when retries run out |
In both failure cases a transactional handler is rolled back first. A rejection is remembered, so the handler is not run again for that event.
Errors the framework creates carry the right kind: validation, invalid operations and
unsealed structs are :business; a rolled-back command transaction, a failed save and
exceptions are :infrastructure. Build your own with DDD.Error.business/1 and
DDD.Error.infrastructure/1. A plain reason is the domain's answer, so it counts as business.
Each delivery runs in its own transaction by default, together with a row in
event_deliveries that records "this handler processed this event". If the handler fails,
everything rolls back, including that row, and the event is retried. If it succeeds, the handler
is never run again for that event, even if another handler of the same event failed. For
handlers that only write to our database this is effectively exactly-once. Events a handler
produces (by saving aggregates) commit with its work, so chained events are safe too: they only
exist if the handler succeeded.
For an effect a transaction can't undo (sending an email, calling an API), use
transaction: false. That handler is delivered at least once and must tolerate a repeat:
use DDD.EventHandler, events: [...], transaction: false
use DDD.EventHandler, events: [...], repo: MyApp.RepoPlain modules with handle/1 work as well and are delivered like transaction: false.
Pruning
domain_events and event_deliveries grow with every event. Delete the old ones when you
don't need them any more:
mix vic.outbox.prune --older-than-days 30
or on a timer, with {DDD.Supervisor, retention: [older_than_days: 30]}, or from your own
scheduler with DDD.Outbox.Retention.prune(MyApp.Repo, older_than_days: 30). Only published
events and resolved dead letters are deleted; waiting events, events being retried and unresolved
dead letters are kept. Nothing is pruned unless you ask: there is no default age. A projection
is rebuilt by replaying the events that are left (see Projections), so prune after the
history you want to be able to rebuild from, or archive the table first.
Retry configuration
config :vicander, :outbox,
max_retries: 10, # retries after the first attempt (default 10)
backoff_base_ms: 1_000, # the first retry waits this long, then it doubles (default 1s)
backoff_max_ms: 300_000 # never wait longer than this (default 5 minutes)A poller option wins over the application config, which wins over the default:
{DDD.Outbox.Poller, repo: MyApp.Repo, max_retries: 3, backoff_ms: fn retry -> ms end}.
Dead letters
An event is dead-lettered only when the system kept failing: infrastructure errors,
exceptions, or an event type that can't be resolved. Business rejections are never dead
letters. When the poller gives up it logs an error, emits a [:ddd, :outbox, :dead_letter]
telemetry event (attach a handler to alert), and notifies DDD.Outbox.DeadLetterHandler, a
supervised process that applies a policy:
| Policy returns | Effect |
|---|---|
:keep | acknowledge it; it stays dead-lettered for a person to deal with |
:replay | clear the failure state; the poller retries it (handlers that succeeded are skipped) |
:discard | resolve it as discarded |
:later | decide on a later sweep |
The default policy keeps every dead letter: the failure is logged and emitted as telemetry, and a person replays it. Replaying automatically before anyone has looked mostly repeats the failure, so it is opt-in, for transient causes such as an outage longer than the retries:
config :vicander, :dead_letters,
max_replays: 3, # default 0: never replay automatically
replay_cooldown_ms: 60_000, # wait this long after it died
policy: MyApp.DeadLetterPolicy # or your own `DDD.Outbox.DeadLetterPolicy`The database is the source of truth. The handler sweeps for unresolved dead letters when it
starts and every minute, so a crash and restart (or a lost notification) loses nothing. A
policy that raises is logged and treated as :later; it cannot crash the handler.
By hand:
mix vic.outbox.dead_letters # unresolved ones (--all includes resolved)
mix vic.outbox.replay EVENT_ID # or --all; fix the cause first
mix vic.outbox.discard EVENT_ID
Setup
{DDD.Supervisor, handlers: [...]} (see Setup at the top) starts the poller and the
dead-letter handler. Handlers are subscribed first, so an event is never delivered to an empty
list. mix vic.gen.outbox_migration creates the domain_events and event_deliveries tables.
Limits: handler cycles (A causes B causes A) are not detected, and events are ordered per poll
batch, not across a whole chain. Two handlers saving the same aggregate at the same moment no
longer overwrite each other (see Optimistic locking): the loser fails with :stale_aggregate
and is retried.
Projections
A read model is a denormalized table kept up to date by events, so a screen that needs "my lists
with their counts" reads one table instead of loading and folding every aggregate.
DDD.Projection is an event handler (see Handlers), so it is delivered through the outbox and
inherits its guarantees: every event is applied in its own transaction together with its delivery
record, so a retry or a duplicate delivery never counts an event twice.
defmodule MyApp.Projections.ListSummary do
alias MyApp.Events.{ListCreated, ItemAdded, ItemCompleted}
use DDD.Projection, table: "list_summaries", key: :list_id,
events: [ListCreated, ItemAdded, ItemCompleted]
projection do
description "One row per list: title and counts"
field :list_id, :uuid
field :title, :string
field :open_count, :integer, default: 0
field :done_count, :integer, default: 0
end
on %ListCreated{} = e, do: {:upsert, %{title: e.title}}
on %ItemAdded{}, do: {:inc, :open_count, 1}
on %ItemCompleted{}, do: {:inc, open_count: -1, done_count: 1}
end
MyApp.Projections.ListSummary.subscribe() # before the poller startsThe row is keyed by the event's aggregate_id (key_from: picks another event field). An on
clause returns what to do to that row: {:upsert, map} (create with the field defaults, or set
these fields), {:update, map} (only if the row exists), {:inc, field, n} (the database adds,
so concurrent events never overwrite each other), :delete, :skip, a list of these, or
{:error, reason} (a rejection, like in any handler). Events can arrive before the row exists:
an increment creates it.
- Table.
mix vic.gen.migration MyApp.Projections.ListSummarywrites the migration. - Reading.
get/1, andlist/1,count/1,page/1,slice/1with the filters, orders and cursors above. Rows are plain maps. - Consistency. Eventually consistent: it trails by the time the outbox takes. Decisions read the aggregate, never a projection.
- Lag.
lag/0returns%{pending: n, oldest_pending_age_ms: ms}: the events of its types that no delivery record covers yet (discarded dead letters excluded). - Rebuild.
mix vic.projection.rebuild MyApp.Projections.ListSummary(orrebuild/0) empties the table and replays every stored event of its types, in order. Use it after fixing anonclause, or to build a new projection from history. The events must still be indomain_events. Readers see the table fill up again while it runs, so do it in a quiet period; if it fails part-way the error says the projection is partial, and running it again is safe.
The example app has one: TodoApp.Application.Projections.TodoList.ListStats (items added and completed per
list). It lives in the application layer, beside the query that reads it (list_activity): a
projection is an event handler that maintains a read model, and the framework hides the SQL, so
nothing in it is specific to an adapter. Only the repositories, which the domain declares as
ports, sit behind impl/0.
Testing your application
DDD.Test has the assertions an application's tests keep writing:
import DDD.Test
{:ok, list} = TodoList.complete_item(list, item.id)
assert_event_emitted(list, ItemCompleted, item_id: item.id) # returns the event
refute_event_emitted(list, ListReopened)
assert_error_code(TodoList.complete_item(list, item.id), :already_completed) # returns the error
{:ok, _} = Commands.AddItem.call(...)
drain_outbox() # delivers the saved events to the subscribed handlers, nowIn tests, start the framework with a poller that doesn't run by itself
({DDD.Supervisor, handlers: [...], poller: [interval_ms: 600_000]}) and call drain_outbox/0
when a test needs the handlers to have run, or pass poller: false and test the handlers
directly. DDD.InMemoryRepository replaces a Postgres adapter in tests that don't need a
database. The example app's TodoApp.DataCase shows the Ecto sandbox setup.
Observability
Every framework event is a :telemetry event; DDD.Telemetry has the full catalogue.
| what | event |
|---|---|
| a command or query | [:ddd, :command] / [:ddd, :query] spans, with :result, :error_codes, :error_kind |
| a command re-run after a version conflict | [:ddd, :command, :retry] |
| a lost optimistic-lock race | [:ddd, :repository, :conflict] |
| a handler delivery | [:ddd, :handler, :deliver] span, with :outcome (:delivered, :skipped, :rejected, :error) |
| the outbox | [:ddd, :outbox, :poll | :published | :retry | :dead_letter] |
| a projection rebuild | [:ddd, :projection, :rebuild] span |
| outbox and projection gauges | [:ddd, :outbox, :stats], [:ddd, :projection, :lag] |
The gauges are read from the database, so emit them on a timer (for example with
:telemetry_poller): DDD.Telemetry.report(MyApp.Repo, [MyApp.Projections.ListSummary]). A
stuck outbox shows up as a growing oldest_pending_age_ms, and dead_letters above zero means
someone has to act (mix vic.outbox.dead_letters).
Health. DDD.Health.check/1 answers :ok, :degraded (unresolved dead letters, an event or a
projection waiting longer than its threshold) or :unhealthy (the database does not answer):
config :vicander, :health,
projections: [MyApp.Projections.ListSummary],
max_outbox_age_ms: 60_000, max_dead_letters: 0, max_projection_lag_ms: 60_000The result is JSON-encodable: serve it from a Plug route or a Phoenix controller, or list
DDD.Health.Query in an MCP server's queries:. mix vic.health [--json] prints it and exits
non-zero unless :ok, for a deploy gate or a container probe.
Pending work
Known gaps, in the order they matter. None is hidden: each was found and decided on.
- Concurrent first saves. Optimistic locking guards every save of an existing aggregate, and
a new aggregate whose id already exists is a
:stale_aggregateconflict. Two simultaneous first saves of the same new id can still both pass that check, and the second insert then fails with a raw constraint error instead of aDDD.Error. Ids are UUIDv7, so this only happens when the same new struct is saved twice at once. - Projection ordering. A projection applies events in delivery order, which follows
occurred_atwithin a poll batch. Itsonclauses should be commutative (increments, upserts of independent fields) rather than depend on strict order; events carry no aggregate version yet that a projection could use to detect an out-of-order one. - Outbox lease per batch. A claimed batch of up to 100 events shares one 30-second lease. With slow handlers the unprocessed tail can be claimed again by another node. Renew the lease per event, or claim fewer events.
- Logged parameters.
DDD.CommandandDDD.Querylog every parameter value at info level. There is no way to keep a password or token out of the log yet.
Example application
todo_app (a sibling project, ../todo_app) is a complete application on top of the framework: four layers
(domain, application, infrastructure, interface) with the layering enforced by
boundary, aggregates with specifications, commands and
queries (paged and cursor listings), a read model, event handlers, and an MCP server. It is the
first thing to read to see how the pieces fit, and its test suite is the framework's integration
test.
alias TodoApp.Application.{Commands, Queries}
{:ok, user} = Commands.User.RegisterUser.call(%{name: "Ann", email: "ann@example.com"})
{:ok, list} = Commands.TodoList.CreateList.call(%{owner_id: user.id, name: "Home"})
{:ok, list} = Commands.TodoList.AddItem.call(%{list_id: list.id, title: "Buy milk"})
{:ok, open} = Queries.TodoList.OpenItems.call(%{list_id: list.id})