Specification Pattern: Encapsulate Complex Business Rules
When business rules get complex, they scatter across your codebase. The Specification pattern turns rules into composable objects you can combine, reuse, and test in isolation.
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, andnotcombinators 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