DDD.Command behaviour (vicander v0.1.1)

Copy Markdown View Source

A command is the input of one use case, and it carries its own handler.

The command declares its fields (validated on construction) and implements execute/1: load aggregates through repositories, call domain operations, save. It holds no business logic itself; the rules live in entities, aggregates and specifications.

Usage

defmodule MyApp.Commands.PlaceOrder do
  use DDD.Command

  command do
    field :customer_id, :string, required: true
    field :items,       :list,   required: true
    field :notes,       :string, default: nil
  end

  @impl DDD.Command
  def execute(%__MODULE__{customer_id: customer_id, items: items}) do
    with {:ok, order} <- MyApp.Order.place(%{customer_id: customer_id}),
         {:ok, order} <- MyApp.Order.add_items(order, items) do
      MyApp.Infrastructure.OrderRepository.save(order)
    end
  end
end

MyApp.Commands.PlaceOrder.call(%{customer_id: "c1", items: [...]})

Running a command

  • new/1 builds and validates the command: {:ok, command} or {:error, %DDD.Error{}}.
  • call/1 takes a command struct or plain attrs (built with new/1 first), runs execute/1, and returns its result. Any error comes back as a DDD.Error whose context has :command set to the command module.
  • call!/1 is call/1 that raises on failure.

Transactions

execute/1 runs in its own database transaction by default: {:ok, _} commits, {:error, _} rolls back everything the handler did (every aggregate it saved, and their outbox events), and an exception rolls back and propagates. The repositories' own transactions join it.

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)

Validation in new/1 happens before the transaction starts.

Retrying version conflicts

Aggregates are saved with optimistic locking: a save that lost against a concurrent one fails with :stale_aggregate (an infrastructure error). retry_on_conflict: n re-runs the whole command, on freshly loaded data, up to n more times before giving up:

use DDD.Command, retry_on_conflict: 3

A command called from inside an open transaction is never retried (the outer transaction is already doomed), and neither is any other error.

Telemetry

Every run is a [:ddd, :command] span; see DDD.Telemetry.

Summary

Callbacks

execute(command)

@callback execute(command :: struct()) :: {:ok, any()} | {:error, any()}

Functions

command(list)

(macro)