Specification Pattern in DDD

How I use specifications to keep business rules out of fat services and out of raw SQL, with TypeScript and Prisma examples drawn from real production code.

A teammate at the London product agency I led engineering at once opened a PR with a method called customerCanCheckout. 180 lines. Three layers of nested if. A SQL string built by string concatenation halfway down. He pinged me on Slack: “I know it’s bad. I just don’t know how to break it apart.” Yeah. I’d seen that exact shape a dozen times by then. Business rules melt into a service method, then get duplicated into a query, then drift apart.

That’s the problem the specification pattern actually solves. Not “object-oriented elegance”. Drift between rules used to validate one thing and rules used to find many.

What a specification actually is

A specification is one business rule, named, with one method that answers isSatisfiedBy(thing): boolean. That’s the whole interface. The interesting part is composition. AND, OR, NOT. You compose small rules into bigger ones without writing a new branch every time the product team changes their mind about who counts as an “active customer”.

// domain/shared/specification.ts
export interface Specification<T> {
  isSatisfiedBy(candidate: T): boolean;
  and(other: Specification<T>): Specification<T>;
  or(other: Specification<T>): Specification<T>;
  not(): Specification<T>;
}

export abstract class CompositeSpec<T> implements Specification<T> {
  abstract isSatisfiedBy(candidate: T): boolean;
  and(other: Specification<T>) { return new AndSpec(this, other); }
  or(other: Specification<T>) { return new OrSpec(this, other); }
  not() { return new NotSpec(this); }
}

class AndSpec<T> extends CompositeSpec<T> {
  constructor(private readonly a: Specification<T>, private readonly b: Specification<T>) { super(); }
  isSatisfiedBy(c: T) { return this.a.isSatisfiedBy(c) && this.b.isSatisfiedBy(c); }
}
class OrSpec<T> extends CompositeSpec<T> {
  constructor(private readonly a: Specification<T>, private readonly b: Specification<T>) { super(); }
  isSatisfiedBy(c: T) { return this.a.isSatisfiedBy(c) || this.b.isSatisfiedBy(c); }
}
class NotSpec<T> extends CompositeSpec<T> {
  constructor(private readonly s: Specification<T>) { super(); }
  isSatisfiedBy(c: T) { return !this.s.isSatisfiedBy(c); }
}

Nothing clever. The point isn’t the base class. The point is that once you’ve got this shape, every business rule becomes a class with a name a product manager would recognize.

Real rules look like this

Don’t write EntitySpec and ValueObjectSpec. Write the rule the business actually cares about. Three from a real e-commerce-shaped codebase, in language the product team would actually use:

// domain/customers/specs.ts
import { Customer } from "./customer";
import { CompositeSpec } from "../shared/specification";

export class CustomerHasVerifiedEmail extends CompositeSpec<Customer> {
  isSatisfiedBy(c: Customer) { return c.email.verifiedAt !== null; }
}

export class CustomerHasNoOpenDisputes extends CompositeSpec<Customer> {
  isSatisfiedBy(c: Customer) { return c.openDisputeCount === 0; }
}

export class CustomerIsInGoodStanding extends CompositeSpec<Customer> {
  constructor(private readonly minAgeDays: number = 7) { super(); }
  isSatisfiedBy(c: Customer) {
    const ageMs = Date.now() - c.createdAt.getTime();
    return ageMs >= this.minAgeDays * 86_400_000;
  }
}

export const customerCanCheckout = new CustomerHasVerifiedEmail()
  .and(new CustomerHasNoOpenDisputes())
  .and(new CustomerIsInGoodStanding(7));

That last line is the one that earns the pattern its keep. The 180-line method becomes a sentence. If the rules team adds “must not be in a fraud-review queue”, you write one new class, AND it in, and you’re done.

The query side of the same rule

OK so this is the part most blog posts skip. The same customerCanCheckout rule shows up in two places. In the use case where you validate a single customer, fine, isSatisfiedBy works. But you also need it on a dashboard query: “show me every customer who can check out right now.” Reimplement those rules in a WHERE clause and they will drift. Mine drifted by a Tuesday afternoon and a feature flag.

The fix I’ve shipped twice is to let specs also describe themselves as a query fragment. Not “convert generic spec to SQL” magic. Each spec, where it makes sense, exposes a second method that returns a Prisma-shaped filter. Repository code picks it up.

// domain/customers/specs.ts (continued)
import { Prisma } from "@prisma/client";

export interface PrismaCustomerSpec extends Specification<Customer> {
  toPrisma(): Prisma.CustomerWhereInput;
}

export class CustomerHasVerifiedEmailQ extends CustomerHasVerifiedEmail implements PrismaCustomerSpec {
  toPrisma(): Prisma.CustomerWhereInput {
    return { email: { is: { verifiedAt: { not: null } } } };
  }
}

export class CustomerHasNoOpenDisputesQ extends CustomerHasNoOpenDisputes implements PrismaCustomerSpec {
  toPrisma(): Prisma.CustomerWhereInput {
    return { openDisputeCount: 0 };
  }
}

export class CustomerIsInGoodStandingQ extends CustomerIsInGoodStanding implements PrismaCustomerSpec {
  toPrisma(): Prisma.CustomerWhereInput {
    const cutoff = new Date(Date.now() - 7 * 86_400_000);
    return { createdAt: { lte: cutoff } };
  }
}

Then composition has its own query-aware version that knows how to merge filters:

// domain/shared/specification-query.ts
import { Prisma } from "@prisma/client";
import { PrismaCustomerSpec } from "../customers/specs";

export const andQ = (...specs: PrismaCustomerSpec[]): Prisma.CustomerWhereInput =>
  ({ AND: specs.map(s => s.toPrisma()) });

export const orQ = (...specs: PrismaCustomerSpec[]): Prisma.CustomerWhereInput =>
  ({ OR: specs.map(s => s.toPrisma()) });

export const notQ = (spec: PrismaCustomerSpec): Prisma.CustomerWhereInput =>
  ({ NOT: spec.toPrisma() });

I went back and forth on whether to make one object implement both isSatisfiedBy and toPrisma. Eventually settled on yes, with a marker interface so the type system tells me when a spec is query-capable. Some rules genuinely can’t be expressed as SQL. Those stay validation-only.

Wiring specs into a repository

Repository takes a query-capable spec, asks for its Prisma fragment, runs the query. No business logic in the repository. No raw SQL. The repo stays a thin adapter.

// infrastructure/customers/prisma-customer.repository.ts
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma.service";
import { CustomerRepository } from "../../domain/customers/customer.repository";
import { Customer } from "../../domain/customers/customer";
import { PrismaCustomerSpec } from "../../domain/customers/specs";
import { toDomain } from "./customer.mapper";

@Injectable()
export class PrismaCustomerRepository implements CustomerRepository {
  constructor(private readonly prisma: PrismaService) {}

  async findMatching(spec: PrismaCustomerSpec, take = 50): Promise<Customer[]> {
    const rows = await this.prisma.customer.findMany({
      where: spec.toPrisma(),
      take,
      orderBy: { createdAt: "desc" },
    });
    return rows.map(toDomain);
  }
}

Same customerCanCheckout composition, used for validation and querying, depending on whether you’ve got a single hydrated customer or a database to scan.

War story: the drifted rule

This is the one I bring up whenever someone tells me the pattern feels like overkill. Same agency, mid-DDD migration on a healthcare professional portal we shipped. The rule was “a physician can be assigned to a clinic if they’re verified, not under suspension, and their license region matches the clinic’s region.” Three conditions. Lived in a service method. Also lived in a WHERE clause on the assignment search screen. Also lived in a third place I didn’t know about until later, a Sidekiq job that nightly auto-suggested matches. Three copies.

A teammate added a fourth condition, “must have completed onboarding,” to the service method. Forgot the search. Forgot the third copy. The search screen happily showed clinic admins a list of physicians who looked assignable but weren’t, and the assignment use case rejected them with a validation error after the click. Clinics started filing tickets. First wrong fix was “let’s just add the new condition to the other two places.” Bandage. Did it. Felt dirty.

The real fix was the spec pattern, exactly as above. One spec per condition. One composition for the rule. The use case ran isSatisfiedBy. The search ran toPrisma. The Sidekiq job ran toPrisma. Same rule, three call sites, one source of truth. Took two days to migrate. The fourth condition went in as a single new class and a one-line .and(). The runbook now has one sentence at the top of the assignment domain: if you’re about to write the same if twice, write a spec instead.

War story: the silent N+1

A different one, on the agency’s flagship SaaS I built end to end. We’d shipped specs for the user-eligibility rules and were feeling good about it. A few weeks in, someone noticed the eligibility dashboard had quietly gotten slow. Page load went from around 140 ms to over 3 s. Datadog showed a database query count exploding on that route. The dashboard was using isSatisfiedBy in a for loop instead of toPrisma in a single query. The reviewer (me) had missed it. The interface looks the same when you call it.

First wrong fix: I added Promise.all. Made it worse, because now we were hammering Postgres with hundreds of parallel single-row queries instead of sequential ones. Connection pool started rejecting checkouts. The real fix was a findMatching(spec) repository method that takes a query-capable spec, plus a lint rule that flags any for loop over a repository fetch followed by isSatisfiedBy. Heavy-handed, but it caught two more cases that quarter.

Specifications fix rules-drift, but they introduce a new shape of N+1 if you reach for the wrong method. Decide which side of the spec you’re on, validation or query, before you write the loop.

When I don’t bother

I don’t reach for this on simple CRUD. A single nullable check doesn’t need a class. The pattern earns its keep when a rule has two or more clauses, appears in two or more places, or is likely to grow. If it lives in one method that gets called once, leave it as an if. DDD purity is not the goal. Killing duplication is.

Takeaways

  • A specification is one named business rule with isSatisfiedBy. Composition is the feature, not the elegance.
  • The rule that lives only in code drifts. The same spec used for validation and querying does not.
  • Make the query side explicit. A toPrisma method beats reinventing a WHERE in three files.
  • Repositories stay thin. They take a spec, ask for its query fragment, return aggregates.
  • Don’t loop isSatisfiedBy over a list you fetched in a loop. Reach for findMatching instead.
  • Skip the pattern on rules that have one clause and one caller. Save it for rules that are growing.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.