Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 037 / 100

Specification pattern: a practical Design Patterns guide

Business rules as composable objects.

Expona Admin8 min read

Complex business logic can scatter across your codebase, making it brittle and hard to test. The specification pattern centralizes these rules into reusable objects, letting you compose them like building blocks and apply them consistently to queries, validation, and business logic. In this guide, we'll show you how to implement specification pattern: a practical Design Patterns guide using TypeScript and demonstrate why senior teams rely on it for maintainability.

What is the Specification pattern?

The specification pattern encapsulates business rules into standalone objects that evaluate whether an entity satisfies a condition. Instead of scattering conditional logic throughout your codebase, you create reusable specifications that can be combined, tested in isolation, and even translated into database queries.

Think of it like building a filter: rather than writing if (user.isActive && user.balance > 0) everywhere, you define that rule once as a specification object and reuse it across validation logic, queries, and business workflows.

The core benefit is composition. You combine simple specifications with logical operators (and, or, not) to express complex requirements while keeping each piece readable and testable.

isEligible = isActive.and(hasBalance)

Let's start with a real scenario: determining whether a customer is eligible for a promotion. Instead of embedding that logic in multiple places, we create specifications and compose them.

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

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

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

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

class AndSpecification<T> extends Specification<T> {
  constructor(
    private left: Specification<T>,
    private right: Specification<T>
  ) {
    super();
  }

  isSatisfiedBy(candidate: T): boolean {
    return this.left.isSatisfiedBy(candidate) &&
           this.right.isSatisfiedBy(candidate);
  }
}

class OrSpecification<T> extends Specification<T> {
  constructor(
    private left: Specification<T>,
    private right: Specification<T>
  ) {
    super();
  }

  isSatisfiedBy(candidate: T): boolean {
    return this.left.isSatisfiedBy(candidate) ||
           this.right.isSatisfiedBy(candidate);
  }
}

class NotSpecification<T> extends Specification<T> {
  constructor(private spec: Specification<T>) {
    super();
  }

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

Now define customer-specific specifications:

typescript
interface Customer {
  id: string;
  isActive: boolean;
  balance: number;
  accountAgeMonths: number;
  hasOutstandingDispute: boolean;
}

class IsActiveSpecification extends Specification<Customer> {
  isSatisfiedBy(customer: Customer): boolean {
    return customer.isActive;
  }
}

class HasSufficientBalanceSpecification extends Specification<Customer> {
  constructor(private minBalance: number) {
    super();
  }

  isSatisfiedBy(customer: Customer): boolean {
    return customer.balance >= this.minBalance;
  }
}

class IsAccountMatureSpecification extends Specification<Customer> {
  constructor(private minMonths: number) {
    super();
  }

  isSatisfiedBy(customer: Customer): boolean {
    return customer.accountAgeMonths >= this.minMonths;
  }
}

class HasNoDisputeSpecification extends Specification<Customer> {
  isSatisfiedBy(customer: Customer): boolean {
    return !customer.hasOutstandingDispute;
  }
}

Compose them into a meaningful eligibility rule:

typescript
const isEligible = new IsActiveSpecification()
  .and(new HasSufficientBalanceSpecification(100))
  .and(new IsAccountMatureSpecification(6))
  .and(new HasNoDisputeSpecification());

const customer = {
  id: "cust_123",
  isActive: true,
  balance: 250,
  accountAgeMonths: 12,
  hasOutstandingDispute: false
};

if (isEligible.isSatisfiedBy(customer)) {
  console.log("Customer qualifies for promotion");
}

This approach makes the business rule self-documenting and testable. When requirements change—for example, lowering the minimum balance to 50—you update one line in one place.

Reuse rules in queries and validation

The real power of specifications emerges when you apply the same rule across multiple contexts: database queries, in-memory validation, and business workflows.

For in-memory validation during API requests:

typescript
class PromotionService {
  async offerPromotion(customerId: string): Promise<void> {
    const customer = await this.customerRepository.findById(customerId);
    
    if (!isEligible.isSatisfiedBy(customer)) {
      throw new Error("Customer does not qualify");
    }
    
    // Apply promotion logic
  }
}

For database queries, we translate specifications into SQL conditions using an expression tree pattern. Many ORMs support this directly:

typescript
class SpecificationVisitor {
  visitIsActive(spec: IsActiveSpecification): string {
    return "active = true";
  }

  visitHasSufficientBalance(spec: HasSufficientBalanceSpecification): string {
    return `balance >= ${spec.minBalance}`;
  }

  visitIsAccountMature(spec: IsAccountMatureSpecification): string {
    return `account_age_months >= ${spec.minMonths}`;
  }

  visitHasNoDispute(spec: HasNoDisputeSpecification): string {
    return "has_outstanding_dispute = false";
  }
}

// In your repository
async findEligibleCustomers(): Promise<Customer[]> {
  const whereClause = this.visitor.buildWhereClause(isEligible);
  
  return this.db.query(`
    SELECT * FROM customers
    WHERE ${whereClause}
  `);
}

In production systems (e-commerce, SaaS platforms), this pattern lets you run the same validation logic in API handlers, background jobs, and database filters without duplication. We've seen this dramatically reduce bugs where validation differed between the application layer and the query layer.

Readable names for complex conditions

A major benefit of specifications is that they give names to otherwise opaque boolean expressions. This improves code readability and makes requirements explicit.

Compare these two approaches:

typescript
// Without specifications: what does this actually mean?
if (user.status === "active" && 
    user.creditScore > 650 && 
    user.monthlyIncome > 3000 && 
    !user.hasRecentDefault &&
    user.yearsEmployed >= 2) {
  issueCard();
}

With specifications:

typescript
const isCreditworthyForCard = new IsActiveUser()
  .and(new HasAcceptableCreditScore(650))
  .and(new HasSufficientIncome(3000))
  .and(new HasNoRecentDefaults())
  .and(new HasStableEmployment(2));

if (isCreditworthyForCard.isSatisfiedBy(user)) {
  issueCard();
}

The second version is self-documenting. A new team member can read it and understand the business logic without decoding conditionals. When the risk team says "we need to increase the credit score minimum to 700," you know exactly which specification to modify.

Specifications also make requirements traceable. You can link each specification class to a ticket, documentation, or compliance requirement—something nearly impossible with inline conditionals.

Translate to SQL with expression trees

For larger applications, you'll want specifications to generate efficient SQL directly rather than filtering in memory. Expression trees provide a mechanism for this.

Here's a practical approach using a visitor pattern to build SQL from composed specifications:

typescript
abstract class Specification<T> {
  abstract isSatisfiedBy(candidate: T): boolean;
  abstract toSql(): string;

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

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

class AndSpecification<T> extends Specification<T> {
  constructor(
    private left: Specification<T>,
    private right: Specification<T>
  ) {
    super();
  }

  isSatisfiedBy(candidate: T): boolean {
    return this.left.isSatisfiedBy(candidate) &&
           this.right.isSatisfiedBy(candidate);
  }

  toSql(): string {
    return `(${this.left.toSql()} AND ${this.right.toSql()})`;
  }
}

class IsActiveSpecification extends Specification<Customer> {
  isSatisfiedBy(customer: Customer): boolean {
    return customer.isActive;
  }

  toSql(): string {
    return "status = 'active'";
  }
}

class HasSufficientBalanceSpecification extends Specification<Customer> {
  constructor(private minBalance: number) {
    super();
  }

  isSatisfiedBy(customer: Customer): boolean {
    return customer.balance >= this.minBalance;
  }

  toSql(): string {
    return `balance >= ${this.minBalance}`;
  }
}

Now your repository can execute the specification as a query:

typescript
class CustomerRepository {
  async findSatisfying(spec: Specification<Customer>): Promise<Customer[]> {
    const sql = `SELECT * FROM customers WHERE ${spec.toSql()}`;
    return this.db.query(sql);
  }
}

// Usage
const results = await repo.findSatisfying(isEligible);

For TypeScript + PostgreSQL, you can even use type-safe query builders like Prisma or TypeORM that support similar patterns natively. The key is that your business rule remains a single source of truth, expressed as code but executable at the database level.

Common mistakes

  • Over-abstracting simple checks: If a rule is genuinely one-off and unlikely to change, wrapping it in a specification adds unnecessary indirection. Reserve specifications for rules you'll test, compose, or reuse.
  • Forgetting to handle nulls: Specifications often deal with optional fields. Make sure your isSatisfiedBy methods handle null or undefined gracefully.
  • Mixing specification logic with side effects: Specifications should be pure functions that evaluate conditions. Don't log, update state, or call external services inside isSatisfiedBy.
  • Not testing composed specifications: Test individual specifications and also test their compositions, since combining them can introduce subtle bugs.
  • SQL generation without parameterization: When building SQL from specifications, always use parameterized queries to prevent injection attacks. Don't concatenate user-controlled values directly into the SQL string.

Key takeaways

  • Specifications encapsulate business rules as reusable objects, improving clarity and reducing duplication across your codebase.
  • Compose simple specifications with and, or, and not operators to express complex requirements while keeping each piece testable.
  • Apply the same specification for validation in APIs, background jobs, and database queries—a single source of truth for business logic.
  • Use meaningful class names to make requirements self-documenting and traceable to business tickets or compliance rules.
  • Implement toSql() methods to translate specifications into efficient database queries, avoiding unnecessary in-memory filtering.
  • Keep specifications pure; avoid side effects and ensure they handle edge cases like null values properly.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles