Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Reified Predicates with AND/OR/NOT Composition

A predicate scattered as an inline if clause is a predicate the rest of the codebase cannot reuse, cannot test in isolation, cannot inspect for tracing. @Specification from @frenchexdev/ddd-specification reifies predicates as named objects with Boolean-algebra composition. Is this customer eligible for the loyalty discount? becomes a named specification; combining three eligibility rules becomes rule1.and(rule2).and(rule3.not()).


What @Specification Reifies

Evans (Domain-Driven Design, chapter 9) introduces specifications as the answer to predicates that need to compose. The pattern is older than DDD — Fowler's Patterns of Enterprise Application Architecture covers it independently — but the DDD application is distinctive: specifications carry domain meaning and are named in the ubiquitous language. IsActiveSubscription, HasUnpaidInvoices, IsEligibleForLoyaltyDiscount — names a domain expert recognises.

Four properties define the pattern. Named: the specification has a predicate string for logging and tracing. Single-method: isSatisfiedBy(candidate: T): boolean is the only required method. Composable: and(other), or(other), not() produce new specifications without mutating the originals. Testable in isolation: a specification has no dependencies beyond its inputs, so unit tests can exercise every branch with synthetic candidates.

The composition shape is Boolean-algebra-complete — AND/OR/NOT — which means any predicate expressible as a finite combination of base specifications is composable. The internal combinators (AndSpec, OrSpec, NotSpec) are the implementation; consumers never instantiate them directly, they just call .and(), .or(), .not() on existing specifications.


The Runtime: ddd-specification

spec.ts exports two surfaces. The Specification decorator stamps __specification + __specificationMetadata (with the predicate name) onto a class. The Spec<T> abstract base class provides the composition methods.

The pattern this triplet uses is the cleanest in the corpus — the decorator and the base class together name the contract, and TypeScript's nominal typing through the abstract base catches any class that tries to satisfy the shape without inheriting it.

In our invented Subscription context:

import { Specification, Spec } from '@frenchexdev/ddd-specification';
import type { Customer } from '../entities/customer.js';

@Specification({ predicate: 'isActiveSubscriber' })
export class IsActiveSubscriber extends Spec<Customer> {
  isSatisfiedBy(customer: Customer): boolean {
    return customer.plan !== null;
  }
}

@Specification({ predicate: 'hasUnpaidInvoices' })
export class HasUnpaidInvoices extends Spec<Customer> {
  isSatisfiedBy(customer: Customer): boolean {
    return customer.unpaidInvoiceCount > 0;
  }
}

@Specification({ predicate: 'isEligibleForLoyaltyDiscount' })
export class IsEligibleForLoyaltyDiscount extends Spec<Customer> {
  constructor(
    private readonly isActive: IsActiveSubscriber = new IsActiveSubscriber(),
    private readonly hasDebt:  HasUnpaidInvoices  = new HasUnpaidInvoices(),
  ) { super(); }

  // The composite predicate, expressed as Boolean algebra over the primitives.
  private readonly composite = this.isActive.and(this.hasDebt.not());

  isSatisfiedBy(customer: Customer): boolean {
    return this.composite.isSatisfiedBy(customer);
  }
}

The composite specification reads as the predicate would read in English: is an active subscriber AND does not have unpaid invoices. The two primitive specifications can be tested independently; the composite test simply verifies the composition. The same primitives compose into other predicates elsewhere in the codebase without re-implementation.


The Analyzer: ddd-specification-analyzer

The runtime above is real and frozen — the Spec<T> base class with and() / or() / not() composition is shipped. The analyzer, however, is hand-written, like Factory and Domain Event. codes.ts exports two diagnostic factories in the DDD0NNN namespace.

DDD0140_SPECIFICATION_MISSING_PREDICATE ships at warning severity. A @Specification without the predicate option still works at runtime — the AND/OR/NOT machinery is independent of the metadata — but the missing predicate text degrades the explainability tooling that renders predicates in audit logs and traces:

export const DDD0140_SPECIFICATION_MISSING_PREDICATE = 'DDD0140';

export function specificationMissingPredicate(className: string, file: string): Diagnostic {
  return {
    code: DDD0140_SPECIFICATION_MISSING_PREDICATE,
    severity: 'warning',
    message: `@Specification "${className}" omitted the \`predicate\` option. Predicate text is needed for explain tooling.`,
    file,
  };
}

DDD0141_SPECIFICATION_NO_IS_SATISFIED_BY is the structural check at error severity. A specification class that does not implement isSatisfiedBy(candidate): boolean cannot evaluate its predicate — the contract is broken at the type level. The diagnostic blocks the build:

specificationNoIsSatisfiedBy('IsActiveSubscriber', 'specs/is-active-subscriber.ts');
// DDD0141 [error] @Specification "IsActiveSubscriber" does not implement
// `isSatisfiedBy(candidate): boolean`. The predicate cannot be evaluated.

The hand-written shape places this analyzer on the same migration list as the other DDD0NNN cohorts. Future work tracked via PROP-SPECIFICATION-001 (code namespace migration to DDD-SPECIFICATION-NNN) and PROP-SPECIFICATION-002 (spec-first codegen pass with banner + idempotence invariants) will rewrite both on top of defineAnalyzerSpec / defineCodegenSpec.


The Codegen: ddd-specification-codegen

The codegen is also hand-written. It emits a specification registry; future work will add property-test scaffolds and substrate-native query translation (repository.find(spec) rewritten into the persistence layer's query DSL). The deeper cross-pattern role — the same Spec<T> object travelling through different consumer contexts, each interpreting it according to its needs — remains the design horizon for the spec-first migration.


  • Used by @Repository query methods — find(spec: Spec<T>): Promise<T[]> translates the specification to a substrate-native query.
  • Used by @Factory for invariant checks during construction.
  • Used by @CommandHandler for precondition verification.
  • Composes with @DesignByContract — a @Pre decorator may invoke spec.isSatisfiedBy(...) for its predicate.
  • Operates on @ValueObject and @Entity instances; the type parameter T is the candidate type.

Back to the series index.

⬇ Download