Sourced.Projection (sourced v0.1.0)

Copy Markdown View Source

A reducer over stored events to build the current state.

A projection is built from an :initial_state, a map of :handlers keyed by event type, and the :tags for filtering on events:

  is_cancelled_projection =
    Sourced.Projection.new(
      initial_state: false,
      tags: ["order:1"],
      handlers: %{
        OrderCancelled => fn _state, _event -> true end,
        OrderReopened => fn _state, _event -> false end
      }
    )

The handlers declare the event types, so the query falls out of the definition: the projection above carries [%{types: [OrderCancelled, OrderReopened], tags: ["order:1"]}]. Handlers take the state and the Sourced.StoredEvent, and must be pure.

Sourced.DecisionModel pairs a projection with the store, folding it into a state and the append condition that keeps it valid. See dcb.events for the model itself.

Named handlers

Anything varying per decision, such as the id in a tag, belongs in a function that returns a projection. Handlers can be captures of named functions, which keeps the transitions individually testable:

  defmodule MyApp.Orders do
    alias Sourced.Projection

    def shipped?(order_id) do
      Projection.new(
        initial_state: false,
        tags: ["order:#{order_id}"],
        handlers: %{
          OrderShipped => &shipped/2,
          OrderCancelled => &cancelled/2
        }
      )
    end

    defp shipped(_state, _event), do: true
    defp cancelled(_state, _event), do: false
  end

A projection outliving a code reload — one driving a read model from a long-lived process — wants &__MODULE__.shipped/2 instead: a local capture is pinned to the module version that defined it and raises BadFunctionError once that version is purged.

Composing

Rather than growing one projection until it answers every question a decision asks, write a projection per question and compose/1 them. The composed projection folds to a map keyed the same way, and queries the union of its parts:

Sourced.Projection.compose(%{
  placed: MyApp.Orders.placed?(1),
  total: MyApp.Orders.total(1)
})

Each part is only fed the events its own query matches, so a part tagged "order:1" never sees an event that arrived because a sibling asked for "customer:42". The result is a projection, so composites nest.

Event types

Handler keys are matched against event.type as the store returns it: the event module under a Sourced.Middleware.Domain, the stored string without one, and the stored string either way for an event type the domain does not register.

A key matching none of those never fires, and nothing raises — worth checking first when a projection's state never moves.

Summary

Types

Folds one event into the state.

Handlers keyed by the event type they fold.

The key a composed projection files a part's state under.

Whatever a projection folds to. Opaque to the library.

t()

Functions

Folds one event into state.

Combines projections into one that folds to a map keyed the same way.

Builds a projection.

Folds events into the projection's :initial_state.

Folds events into state, which a read model carries across the batches a subscription delivers.

Types

handler()

@type handler() :: (state(), Sourced.StoredEvent.t() -> state())

Folds one event into the state.

handlers()

@type handlers() :: %{required(Sourced.EventStore.Query.type()) => handler()}

Handlers keyed by the event type they fold.

key()

@type key() :: term()

The key a composed projection files a part's state under.

opts()

@type opts() :: [
  handlers: handlers(),
  initial_state: state(),
  tags: [Sourced.StoredEvent.tag()]
]

state()

@type state() :: term()

Whatever a projection folds to. Opaque to the library.

t()

@type t() :: %Sourced.Projection{
  handlers: handlers(),
  initial_state: state(),
  query: Sourced.EventStore.Query.t()
}

Functions

apply(projection, state, event)

@spec apply(t(), state(), Sourced.StoredEvent.t()) :: state()

Folds one event into state.

An event the projection has no handler for leaves the state untouched.

compose(projections)

@spec compose(%{optional(key()) => t()}) :: t()

Combines projections into one that folds to a map keyed the same way.

The composed query is the union of the parts', and each part is only fed the events its own query matches.

new(opts)

@spec new(opts()) :: t()

Builds a projection.

Options

  • :handlersrequired the state transitions, keyed by event type. Their keys become the query's types.
  • :initial_state — the state before any event has been folded in. Defaults to nil.
  • :tags — restricts the projection to events carrying all of these tags. Defaults to [], matching every event of its types.

project(projection, events)

@spec project(t(), [Sourced.StoredEvent.t()]) :: state()

Folds events into the projection's :initial_state.

project(projection, state, events)

@spec project(t(), state(), [Sourced.StoredEvent.t()]) :: state()

Folds events into state, which a read model carries across the batches a subscription delivers.