DDD.EventHandler behaviour (vicander v0.1.1)

Copy Markdown View Source

A handler for domain events delivered through the outbox.

defmodule MyApp.OnOrderPlaced do
  use DDD.EventHandler, events: [MyApp.Events.OrderPlaced]

  @impl DDD.EventHandler
  def handle(%MyApp.Events.OrderPlaced{} = event) do
    case MyApp.Commands.ReserveStock.call(%{order_id: event.aggregate_id}) do
      {:ok, _} -> :ok
      {:error, error} -> {:error, error}
    end
  end
end

MyApp.OnOrderPlaced.subscribe()

What handle/1 returns

  • :ok (or {:ok, _}, or any other non-error value): the event was handled.
  • {:error, reason}: it depends on the kind of the error.
    • A business error (%DDD.Error{kind: :business}, or any plain reason such as :not_found) means the handler looked at the event and said no. That outcome is deterministic, so it is recorded as rejected and never retried. It is not a dead letter.
    • An infrastructure error (%DDD.Error{kind: :infrastructure}, for example a conflict or a failed transaction) is a temporary failure: the event is retried with a backoff and dead-lettered if it keeps failing.
  • An exception, throw or crash is unexpected: retried like an infrastructure error.

In both failure cases a transactional handler is rolled back, so it never leaves half of its work behind.

Transactions and delivery guarantees

By default each delivery runs in its own database transaction together with a record in event_deliveries that this handler processed this event. If the handler fails, everything rolls back, including the record, and the event is retried later. If it succeeds, the handler is never run again for that event, even when another handler of the same event failed and the event is retried. For handlers that only write to our database that is effectively exactly-once. Events a handler produces by saving aggregates commit with its work.

use DDD.EventHandler, events: [...]                      # transaction on the configured repo
use DDD.EventHandler, events: [...], repo: MyApp.Repo     # another repo
use DDD.EventHandler, events: [...], transaction: false   # no transaction

A handler with an effect a transaction can't roll back (sending an email, calling an API) should use transaction: false. It is then delivered at least once: if the server stops after the effect but before the delivery is recorded, it runs again, so it must tolerate that.

Handlers that don't use DDD.EventHandler (any module with handle/1) still work and are delivered like transaction: false handlers.

Summary

Functions

Deliver event (stored under event_id, the 16-byte outbox id) to handler.

Types

result()

@type result() :: :delivered | :skipped | {:rejected, term()} | {:error, term()}
  • :delivered - the handler ran and succeeded
  • :skipped - it had already processed (or rejected) this event
  • {:rejected, reason} - a business error: recorded, never retried
  • {:error, reason} - a failure to retry (infrastructure error, exception)

Callbacks

handle(event)

@callback handle(event :: struct()) :: :ok | {:ok, any()} | {:error, any()}

Functions

deliver(handler, event, event_id, fallback_repo)

@spec deliver(module(), struct(), binary(), module()) :: result()

Deliver event (stored under event_id, the 16-byte outbox id) to handler.

fallback_repo is used for the delivery record of non-transactional handlers.