Vicander

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:

Leases and retry times are compared with the database's clock (c: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

  1. Tell it your repo. Commands, queries, handlers, projections and the mix vic.* tasks use the repo you configure, unless one names another with repo::

    # config/config.exs
    config :vicander, repo: MyApp.Repo
  2. Create the outbox tables, once, and one migration per aggregate:

    mix vic.gen.outbox_migration
    mix vic.gen.migration MyApp.Domain.Order
    mix ecto.migrate
  3. Start 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: false for 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

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)   # => true

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, :string

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
end

Every 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_email

check/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}
end

requires: 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, "..."}
end

ItemSatisfies 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.

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"
end

The 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 times

A 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, executes

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}
end

Command 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
end
mix 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

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.

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:

  1. Claim a batch of due events with a lease and FOR UPDATE SKIP LOCKED, so several pollers or nodes never take the same event.
  2. Deliver to every subscribed handler, synchronously.
  3. 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_retries retries (default 10, so 11 attempts) the event is dead-lettered: failed_at is set, last_error says 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.Repo

Plain 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 starts

The 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.

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, now

In 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_000

The 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.

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})