DDD.Specification behaviour (vicander v0.1.1)

Copy Markdown View Source

Specification pattern: a named, reusable, composable business rule.

A specification is a pure function of an entity (no database access).

defmodule MyApp.Specs.MaxOpenItems do
  use DDD.Specification, field: :items

  def check(%{items: items}, opts) do
    max = Keyword.fetch!(opts, :max)

    if Enum.count(items, &(&1.status == :open)) <= max,
      do: :ok,
      else: {:error, :too_many_open_items, "max #{max} open items", %{max: max}}
  end
end

check/2 returns :ok, {:error, code, message} or {:error, code, message, details}. use DDD.Specification also derives satisfied_by?/2. :field (default :base) is the field a failure is reported under.

Value specs

A spec that judges one field's value is declared with scope: :value and bound to the field where it is attached, so one spec serves any field and nothing relies on a naming convention:

defmodule MyApp.Specs.ValidEmail do
  use DDD.Specification, scope: :value

  def check(email, _opts), do: ...      # the value, never nil
end

invariant MyApp.Specs.ValidEmail, on: :email

Failures are reported under the on: field. A missing on:, or a field the entity doesn't have, is a compile error in invariant (and an ArgumentError when evaluated directly).

Entities attach specs two ways:

  • invariant MyApp.Specs.MaxOpenItems, max: 10 - must hold after every change.
  • defop name(entity, ...), requires: SomeSpec - must hold before the operation may run (a precondition). The operation's other arguments arrive in opts[:args], so a rule can depend on them: def check(list, opts) with [item_id] = Keyword.fetch!(opts, :args).

Composition

Specs combine with all_of/1, any_of/1 and not_spec/1, which return composite spec values accepted everywhere a spec module is:

spec = DDD.Specification.all_of([NotBlank, {MaxLength, max: 100}])
DDD.Specification.evaluate(spec, entity)

all_of returns the first failure, any_of returns the last failure when every branch fails.

Searching with a specification

A specification is a function of an entity, so the database can't run it. A spec that also defines to_filter/1 can be used to find the entities that satisfy it, by translating it to a DDD.Filter:

defmodule MyApp.Specs.Overdue do
  use DDD.Specification

  def check(%{due: due}, _opts), do: ...      # the in-memory rule

  @impl DDD.Specification
  def to_filter(_opts), do: {:due, {:lt, Date.utc_today()}}   # the same rule, as SQL
end

MyApp.TodoRepository.list(spec: DDD.Specification.all_of([Overdue, {Owned, owner: id}]))

all_of, any_of and not_spec translate to {:and, _}, {:or, _} and {:not, _}. Keep check/2 and to_filter/1 in step; a test that runs both over the same rows is cheap.

Summary

Callbacks

The same rule as a DDD.Filter, so the database can search with it. Optional.

Functions

Satisfied only when every spec is satisfied.

Satisfied when at least one spec is satisfied.

Evaluate a spec against an entity.

Satisfied when the given spec is not.

Translate a spec to a DDD.Filter expression (see "Searching with a specification").

Types

result()

@type result() ::
  :ok | {:error, atom(), String.t()} | {:error, atom(), String.t(), map()}

spec()

@type spec() ::
  module()
  | {module(), keyword()}
  | {:all_of, [spec()]}
  | {:any_of, [spec()]}
  | {:not, spec()}

Callbacks

check(entity_or_value, opts)

@callback check(entity_or_value :: term(), opts :: keyword()) :: result()

to_filter(opts)

(optional)
@callback to_filter(opts :: keyword()) :: term()

The same rule as a DDD.Filter, so the database can search with it. Optional.

Functions

all_of(specs)

@spec all_of([spec()]) :: spec()

Satisfied only when every spec is satisfied.

any_of(specs)

@spec any_of([spec()]) :: spec()

Satisfied when at least one spec is satisfied.

evaluate(spec, entity, opts \\ [])

@spec evaluate(spec(), struct(), keyword()) :: :ok | {:error, DDD.Error.violation()}

Evaluate a spec against an entity.

Returns :ok or {:error, violation} where violation is %{field, code, message, details}.

not_spec(spec)

@spec not_spec(spec()) :: spec()

Satisfied when the given spec is not.

to_filter(spec, opts \\ [])

@spec to_filter(spec(), keyword()) :: term()

Translate a spec to a DDD.Filter expression (see "Searching with a specification").

Raises ArgumentError for a spec module without to_filter/1: it can be checked in memory but not searched for.