← Back to home

Criteria — reusable predicate specifications

A criterion is a named, parameterised, pure boolean predicate over a candidate type — the Specification Pattern (Eric Evans's isSatisfiedBy, Spring Data's Specification<T>) made first-class in the DSL. It names a cross-aggregate domain rule ("active customer", "order in region", "may force-close") once, so the rule lives in one place instead of being inlined and duplicated across where clauses and guards.

Status. This ships the core of docs/old/proposals/criterion.md: the criterion declaration, body validation, and inline use in every existing boolean-expression position. A criterion referenced by a retrieval's or a find's where additionally reifies — it emits a named, constructed predicate object on every backend rather than dissolving at compile time (see "Reification" below). when <predicate> operation guards with their auto-exposed side-effect-free GET /<plural>/{id}/can_<op> endpoints are shipped on all five backends — node / .NET / python / elixir / java (a false gate → 409 Disallowed; the companion returns { allowed }). Repo.findAll(<Criterion>, page?) from workflow bodies is shipped on every backend — it desugars to a synthetic findAllBy<Criterion> retrieval (the enrich pass materialises it from the context's criteria), so it rides the existing retrieval pipeline. Repo.run accepts a criterion too (read-path-architecture.md, "run takes a criterion") — Repo.run(<Criterion>) / Repo.run(<Criterion>(args), page?) is first-class alongside Repo.run(<Retrieval>), riding the identical findAllBy<Criterion> desugar as findAll. A criterion and a retrieval may not share a name in one context (loom.criterion-retrieval-name-clash, an error), so run(<Name>) always means exactly one of them. Ordering / fetch-shaping is expressed by an anonymous retrieval — Repo.run(retrieval { where: <Criterion> sort: […] loads: […] }, page?) — the call-site twin of a declared retrieval; findAll and bare-criterion run stay the un-shaped spellings, shaping lives on the (named or anonymous) retrieval. Its where: is a criterion reference in this release. A list find on a repository is removed (loom.list-find-removed, an error): a find byX(...): T[] / T paged is a bespoke list finder — pass a criterion to run (Repo.run(<Criterion>(args))) or name a retrieval instead of accreting a method per query, and expose it with scaffoldPaged(of: …) + scaffoldPagedApi(of: …) when a client must call it. node scripts/codemod-list-find-to-criterion.mjs <path> rewrites the old form (see D-LIST-FIND-REMOVED). A unique-key reconstitution find (returning a single T / T? by identity) is not a list query and stays legal, and so does find all(…), the override of the repository's list endpoint. Over a declared unique (…) key it needs no declaration at all — Repo.getBy(<col>: v) / Repo.findBy(…) read it (D-REPO-KEY-LOOKUP; node scripts/codemod-find-to-findby.mjs migrates the finds). A key that is not unique is a query: a criterion, read with Repo.find(<Criterion>) — the same codemod writes criterion <Plural><Find> for such a find and points its call sites at find(C). The single-result Repo.find(<Criterion>) is shipped on every backend as the source of an if let workflow statement — if let x = Repo.find(<Criterion>) { … } else { … } — which binds the first match (non-null) in the then-branch and runs else on no match, and bound by a plain let (let c = Customers.find(ByEmail(e)) binds Customer?). Both ride the same shared findAllBy<Criterion> retrieval as findAll, capped at one row, so no public endpoint leaks. A composed anonymous where: and the from <Criterion>(args) parameter binding are not yet shipped.

Declaration

context Sales {
  enum OrderStatus { Draft, Confirmed, Closed }
  aggregate Customer { active: bool, region: string }
  aggregate Order { status: OrderStatus }
  repository Customers for Customer { }
  repository Orders for Order { }

  // Single-line form. `of Customer` names the candidate; inside the
  // body, bare field names (and `this`) resolve against the candidate
  // aggregate — the same convention as `invariant` / `derived` bodies.
  criterion ActiveCustomer of Customer = active == true

  // Parameterised. (The parameter is named distinctly from the `region`
  // field it compares against — a parameter shadows a same-named field.)
  criterion InRegion(rgn: string) of Customer = region == rgn

  // Comparison against an enum value.
  criterion CanForceClose of Order = status != Closed

  // `of bool` — a pure ambient predicate with no candidate (reads only
  // parameters + ambient context such as `currentUser`).
  criterion HasManagerRole of bool = currentUser.role == "manager"
}

Grammar:

criterion <Name>(<Param>*) of <T> [as <alias>] = <bool expression>
criterion <Name>(<Param>*) of <T> = <bool expression>

<T> (the candidate) must be an aggregate or bool in this release.

of T as <alias> — an optional candidate binder

The candidate is this / bare fields by default. of T as o additionally binds the author's chosen name o to that same candidate, so o.region reads exactly like this.region (same aggregate, SQL-queryable — byte- identical lowering). This is the one as-binding convention shared with from … as o / join … as c (read-path-architecture.md, "Aligned with criterion"); it is not a fixed self receiver — the alias is author-chosen and optional, and every existing criterion keeps parsing.

criterion InRegion(rgn: string) of Order as o = o.region == rgn   // ≡ region == rgn

The alias must not collide with a parameter name (loom.criterion-alias-collision).

Composition

Criteria compose with the ordinary boolean operators — the result is just another boolean expression:

criterion EligibleEuCustomer of Customer = ActiveCustomer && InRegion("EU")

There is no separate composition machinery: && / || / ! over criterion references are the composition.

Use sites

A criterion reference is inlined at compile time wherever a boolean expression is expected. Today that means:

// A criterion body (bare reference):
criterion CustomersActive of Customer = ActiveCustomer

// A criterion body (composed + parameterised):
criterion CustomersActiveInRegion(r: string) of Customer = ActiveCustomer && InRegion(r)

// A single-row find filter:
repository Customers for Customer {
  find activeByEmail(e: string): Customer? where ActiveCustomer && this.email == e
}

// Invariants and operation preconditions inline criteria the same way.

Because the reference is inlined — the predicate body is re-lowered with its parameters substituted and the candidate rebound to the host receiver — a criterion-driven where produces exactly the same lowered expression (and therefore the same generated SQL) as the equivalent hand-written inline filter. No backend query-engine change is involved; criteria ride the existing where→SQL path on every backend.

Reification (retrieval and find criteria)

There are positions where a criterion is not dissolved into its host: when a retrieval's where, or a repository find's where, is exactly a named criterion reference. There the criterion reifies — the backend emits a named, constructed predicate object (the Specification pattern made real in generated code) that the query method consumes, the functional analog of inlining lifted into a reusable object:

criterion NamedLike(needle: string) of Customer = this.name == needle
retrieval ByName(needle: string) of Customer { where: NamedLike(needle) sort: [name asc] }
repository Customers for Customer { }
// … Customers.run(NamedLike(needle)) and Customers.run(ByName(needle)) share it
Backend What the criterion reifies to
.NET / EF a Criterion<Customer> (with IsSatisfiedBy + a query-side ToExpression()), fed into the retrieval's Ardalis Specification<Customer> bundle (and into a find's .Where(crit.ToExpression()))
.NET / Dapper a parameterised SQL WHERE fragment inlined into the retrieval's Run<Name>Async / the find method (Dapper emits SQL, not a reified object)
Hono / Drizzle a module-level predicate function const namedLikeCriterion = (needle) => eq(schema.customers.name, needle), called by run<Name> and the matching find
Phoenix / Ecto a shared query fragment def named_like_criterion(needle), do: dynamic([c], c.name == ^needle) the read filters by
Java / JPA a Specification<T> factory on <Agg>Criteria, consumed via JpaSpecificationExecutor (the first backend to consume CriterionIR directly)
Python / SQLAlchemy inlines the predicate at the call site (non-reifying)

A criterion shared by a retrieval and a find reifies to a single predicate object (one module-level fn / one Ecto query fragment), consumed by both.

The emitted predicate is byte-identical to what inlining would produce — reification is a code-organisation choice, not a behavioural one, so cross-backend conformance/wire parity is unchanged. The rule for when a criterion reifies is simply "if it has a name." A where that is exactly one named criterion reifies; a composed one (Active && InRegion(r)) or an anonymous boolean expression (most invariants, preconditions, capability filters) has nothing to reify and stays inline. See docs/old/proposals/reified-criteria.md for the full design and the remaining-work register (the anonymous filter capability predicates and the principal/tenancy factory are the parts that still inline).

Validation

Diagnostic When
loom.criterion-unsupported-target Candidate type is neither an aggregate nor bool (e.g. of decimal — reserved for the future from-binding surface).
loom.criterion-impure The body calls a mutating operation. Criteria are pure — call a pure function instead.
loom.criterion-cycle A criterion (transitively) references itself.
loom.criterion-arity A criterion call supplies the wrong number of arguments.
loom.criterion-retrieval-name-clash A retrieval has the same name as a criterion in its context — run(<Name>), scaffoldPaged(of: <Name>) and where: <Name> would each have to pick one silently. Rename one.
loom.call-arg-type A criterion call supplies an argument whose type isn't assignable to the declared parameter (the shared call-argument type check; arity stays loom.criterion-arity). Checked at the env-bearing call sites (body precondition / requires, derived, …).

The body is an ordinary expression, so mutation (:=, +=), emit, and workflow calls are already excluded by the grammar.

Advisories — the compiler steers you toward criteria

One error and one warning steer you toward the criterion / retrieval / index model:

Code Fires when Steers you to
loom.list-find-removed (error) A repository declares a find returning a collection (: T[] / T paged) other than find all — a bespoke list finder accreting on the repo. Pass a criterion to run (Repo.run(<Criterion>(args))) or name a retrieval instead; put it on the wire with scaffoldPaged(of: <it>) on the context and scaffoldPagedApi(of: <it>) on the api. node scripts/codemod-list-find-to-criterion.mjs <path> rewrites a source. A unique-key reconstitution find returning a single T / T? stays fine.
loom.index-suggestion (warning) A field is read on a query filter — a find … where, a reified filter, a criterion body or a retrieval's where — but has no index. Add index: <Agg>.<field> on the aggregate's kind: state resource — the column then shows up in the migration.

The index suggestion follows the migration: a find … where rewritten as a criterion + retrieval keeps earning the same loom.index-suggestion.

The intent is "pit of success": the compiler notices a performance smell in a DDD model — an unusual and welcome nudge — and points at the idiomatic fix before it becomes a slow query in production.

See also