A read model: a denormalized table kept up to date by domain events.
Queries read the table directly instead of loading and folding whole aggregates. A projection
is an event handler (DDD.EventHandler), so it is fed by the outbox and inherits its
guarantees: each event is applied in its own transaction together with the delivery record,
so a retry or a duplicate delivery never counts an event twice.
defmodule MyApp.Projections.ListSummary do
use DDD.Projection,
table: "todo_list_summaries",
key: :list_id,
events: [ListCreated, ItemAdded, ItemCompleted, ItemRemoved]
projection do
description "One row per list: title, owner, open and done counts"
field :list_id, :uuid
field :owner_id, :uuid
field :title, :string
field :open_count, :integer, default: 0
field :done_count, :integer, default: 0
end
on %ListCreated{} = e, do: {:upsert, %{owner_id: e.owner_id, title: e.title}}
on %ItemAdded{}, do: {:inc, :open_count, 1}
on %ItemCompleted{}, do: {:inc, open_count: -1, done_count: 1}
on %ItemRemoved{}, do: {:inc, :open_count, -1}
end
MyApp.Projections.ListSummary.subscribe() # before the outbox poller startsOptions
:table- the table (required):key- the field that is the primary key, one row per key (required):events- the event modules the projection reacts to (required):repo- default: the configured repo (config :vicander, repo: MyApp.Repo):key_from- the event field holding the key, default:aggregate_id, which is right when the projection is one row per aggregate
What an on clause returns
on matches an event like a function head, and returns what to do to the row of that event's
key (a list of these is applied in order):
| return | effect |
|---|---|
{:upsert, %{field: value}} | create the row (with the field defaults), or set these fields |
{:update, %{field: value}} | set these fields; nothing if there is no row |
{:inc, :field, n} / {:inc, field: n, other: m} | add n to numeric fields, creating the row if needed |
:delete | delete the row |
:skip | do nothing |
{:error, reason} | like any handler: a business error is a rejection, an infrastructure error is retried |
An event the projection lists but has no on clause for is skipped.
The table
mix vic.gen.migration MyApp.Projections.ListSummary writes the migration, like for an
aggregate (the key is the primary key). Apply new fields the same way.
Reading
The module gets get/1 ({:ok, map} or {:error, :not_found}), and the listing functions
list/1, page/1, slice/1, count/1 with filters, ordering and cursors (see
DDD.Listing). Rows come back as plain maps with the declared fields.
{:ok, page} = ListSummary.page(filter: [owner_id: id, open_count: {:gt, 0}], order: :title)A projection is eventually consistent. Decisions belong to the aggregate; never base a business rule on a projection.
Operating it
lag/0- how many events are waiting for this projection and how long the oldest has waited (DDD.HealthandDDD.Telemetryreport it).rebuild/0- empty the table and replay every stored event into it, in order. Use it after fixing a projection, or after adding one for events that already happened. It needs the events to still be indomain_events. Readers see the table fill up again while it runs, so run it in a quiet period; run it again if it is interrupted.mix vic.projection.rebuildruns it from the command line.
Summary
Functions
Describe the projection in a sentence.
A column of the projection table. Options: :default, :description.
What to do to a row when an event matching pattern arrives (see the module docs).
Declares the projection's fields. description and field go inside.
Functions
Describe the projection in a sentence.
A column of the projection table. Options: :default, :description.
What to do to a row when an event matching pattern arrives (see the module docs).
Declares the projection's fields. description and field go inside.