DDD.Projection (vicander v0.1.1)

Copy Markdown View Source

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 starts

Options

  • :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):

returneffect
{: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
:deletedelete the row
:skipdo 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.Health and DDD.Telemetry report 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 in domain_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.rebuild runs 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

description(text)

(macro)

Describe the projection in a sentence.

field(name, type, opts \\ [])

(macro)

A column of the projection table. Options: :default, :description.

on(pattern, list)

(macro)

What to do to a row when an event matching pattern arrives (see the module docs).

projection(list)

(macro)

Declares the projection's fields. description and field go inside.