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
endcheck/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: :emailFailures 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 inopts[: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
Callbacks
Functions
Satisfied only when every spec is satisfied.
Satisfied when at least one spec is satisfied.
@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}.
Satisfied when the given spec is not.
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.