Business rules have a way of metastasizing. What starts as if (user.age >= 18) becomes a complex web of conditions scattered across services, repositories, and UI components. The same rule appears in six places, slightly differently each time. When the rule changes, you hunt through the entire codebase.

The Specification pattern gives business rules a home. Each rule becomes a named, composable object. Rules can be combined with and, or, and not. And they can be tested in isolation.

The Core Abstraction

A specification answers one question: does this object satisfy this rule?

interface Specification<T> {
  isSatisfiedBy(candidate: T): boolean;
}

That’s it. One method, one boolean. Everything else is composition.

A Basic Example

class User {
  constructor(
    public readonly id: string,
    public readonly age: number,
    public readonly country: string,
    public readonly isPremium: boolean,
    public readonly hasVerifiedEmail: boolean
  ) {}
}

// Each rule is a named class
class IsAdult implements Specification<User> {
  isSatisfiedBy(user: User): boolean {
    return user.age >= 18;
  }
}

class IsFromEU implements Specification<User> {
  private euCountries = new Set(['FR', 'DE', 'ES', 'IT', 'PT', 'NL', 'BE']);

  isSatisfiedBy(user: User): boolean {
    return this.euCountries.has(user.country);
  }
}

class IsPremiumUser implements Specification<User> {
  isSatisfiedBy(user: User): boolean {
    return user.isPremium;
  }
}

class HasVerifiedEmail implements Specification<User> {
  isSatisfiedBy(user: User): boolean {
    return user.hasVerifiedEmail;
  }
}

Composing Specifications

The magic is in composition. Build combinators once, use them everywhere:

class AndSpecification<T> implements Specification<T> {
  constructor(private readonly specs: Specification<T>[]) {}

  isSatisfiedBy(candidate: T): boolean {
    return this.specs.every((spec) => spec.isSatisfiedBy(candidate));
  }
}

class OrSpecification<T> implements Specification<T> {
  constructor(private readonly specs: Specification<T>[]) {}

  isSatisfiedBy(candidate: T): boolean {
    return this.specs.some((spec) => spec.isSatisfiedBy(candidate));
  }
}

class NotSpecification<T> implements Specification<T> {
  constructor(private readonly spec: Specification<T>) {}

  isSatisfiedBy(candidate: T): boolean {
    return !this.spec.isSatisfiedBy(candidate);
  }
}

Now you can express complex rules clearly:

const canAccessAdultContent = new AndSpecification<User>([
  new IsAdult(),
  new HasVerifiedEmail(),
]);

const eligibleForEUDiscount = new AndSpecification<User>([
  new IsFromEU(),
  new IsPremiumUser(),
]);

const canUseAdvancedFeatures = new AndSpecification<User>([
  new HasVerifiedEmail(),
  new OrSpecification<User>([new IsPremiumUser(), new IsAdult()]),
]);

Fluent API

Add a base class with fluent methods to make composition readable:

abstract class CompositeSpecification<T> implements Specification<T> {
  abstract isSatisfiedBy(candidate: T): boolean;

  and(other: Specification<T>): CompositeSpecification<T> {
    return new AndSpecification([this, other]);
  }

  or(other: Specification<T>): CompositeSpecification<T> {
    return new OrSpecification([this, other]);
  }

  not(): CompositeSpecification<T> {
    return new NotSpecification(this);
  }
}

class IsAdult extends CompositeSpecification<User> {
  isSatisfiedBy(user: User): boolean {
    return user.age >= 18;
  }
}

// Now you can chain:
const eligibilitySpec = new IsAdult()
  .and(new HasVerifiedEmail())
  .and(new IsPremiumUser().or(new IsFromEU()));

const isEligible = eligibilitySpec.isSatisfiedBy(currentUser);

Using Specifications in Your Domain

class DiscountService {
  private premiumEuSpec = new AndSpecification<User>([
    new IsFromEU(),
    new IsPremiumUser(),
    new HasVerifiedEmail(),
  ]);

  private adultSpec = new IsAdult();

  calculateDiscount(user: User, product: Product): number {
    if (this.premiumEuSpec.isSatisfiedBy(user)) {
      return 0.25; // 25% for premium EU users
    }
    if (new IsPremiumUser().isSatisfiedBy(user)) {
      return 0.15; // 15% for other premium users
    }
    return 0;
  }

  canPurchase(user: User, product: Product): boolean {
    if (product.isAgeRestricted) {
      return this.adultSpec.isSatisfiedBy(user);
    }
    return true;
  }
}

Parameterized Specifications

Specifications can take parameters — they’re just objects:

class HasMinimumOrders implements Specification<User> {
  constructor(private readonly minimum: number) {}

  isSatisfiedBy(user: User): boolean {
    return user.orderCount >= this.minimum;
  }
}

class IsInCountry implements Specification<User> {
  constructor(private readonly country: string) {}

  isSatisfiedBy(user: User): boolean {
    return user.country === this.country;
  }
}

// Usage
const loyalFrenchUser = new HasMinimumOrders(10).and(new IsInCountry('FR'));

Testing Specifications

Because each specification is a single-responsibility class with one method, testing is trivial:

describe('IsAdult', () => {
  const spec = new IsAdult();

  it('is satisfied by users aged 18+', () => {
    expect(spec.isSatisfiedBy({ age: 18 } as User)).toBe(true);
    expect(spec.isSatisfiedBy({ age: 25 } as User)).toBe(true);
  });

  it('is not satisfied by users under 18', () => {
    expect(spec.isSatisfiedBy({ age: 17 } as User)).toBe(false);
    expect(spec.isSatisfiedBy({ age: 0 } as User)).toBe(false);
  });
});

describe('AndSpecification', () => {
  it('requires all specs to be satisfied', () => {
    const spec = new IsAdult().and(new IsPremiumUser());
    
    expect(spec.isSatisfiedBy({ age: 18, isPremium: true } as User)).toBe(true);
    expect(spec.isSatisfiedBy({ age: 18, isPremium: false } as User)).toBe(false);
    expect(spec.isSatisfiedBy({ age: 16, isPremium: true } as User)).toBe(false);
  });
});

When to Use the Specification Pattern

Use it when:

  • The same business rule appears in multiple places
  • Rules need to be combined in various ways
  • Rules change frequently and need to be discoverable
  • You’re applying DDD and want rules to live in the domain layer

Skip it when:

  • The rule is a single, simple condition used in one place
  • You’re adding abstraction for its own sake
  • Performance is critical and the overhead of object creation matters

Key Takeaways

  • The Specification pattern turns business rules into named, composable objects
  • and, or, and not combinators let you build complex rules from simple ones
  • Each specification is independently testable — one class, one test file
  • Parameterized specifications handle rules that vary by context
  • The pattern lives naturally in the domain layer alongside your entities