Overview

When you look around modern software systems, you quickly notice that filtering logic is everywhere. Search queries, notification targeting, access control, and display conditions all rely on some form of filtering. The demand is broad, but implementing these mechanisms from scratch often leads to sprawling conditionals, fragile combinations, and a constant fear of hidden bugs or edge cases.

This is where the Specification Pattern proves its value. Although it is a classic design pattern, it remains highly effective today. At its core, it provides a structured way to express and combine rules, giving clarity and consistency to otherwise messy filtering logic. By encapsulating conditions into reusable specifications, developers can focus on business rules instead of wrestling with branching code or scattered logic. The result is cleaner code, easier testing, and greater confidence in the system's behavior.

We applied it when implementing the notification feature in our product, solving the problem with a concrete example.

Target Issue

We are developing a system that analyzes call data uploaded by our customers. Initially, our product included a notification feature — for example, sending an email once the analysis was complete. At that time, the conditions for sending notifications were extremely simple, based only on the teams or tags attached to the analysis results.

None
Old filtering: Just check if the analysis result includes any teams or tags on the filtering conditions:

However, when we began pitching our product to enterprise customers, we faced a new requirement: providing notifications based on powerful filtering rules that could meet the complex information control needs of large organizations. That challenge led us to adopt the Specification Pattern as the foundation for managing notification conditions.

None
New filtering: Complex conditions which includes multiple logical operator

Basics of Specification pattern

The Specification Pattern is a design pattern for representing business rules as composable objects. Each specification defines its own condition through an isSatisfiedBy method, which evaluates whether a candidate meets that rule.

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

What makes this pattern powerful is that specifications can be combined recursively (e.g., via AND, OR, NOT) to form a tree structure of conditions. Each leaf represents an atomic rule, while branches represent logical combinations. By traversing and aggregating this tree, the system can evaluate even highly complex filtering logic in a clean and maintainable way.

This makes the Specification Pattern particularly suitable when you need multi-layered, tree-shaped decision logic without scattering conditional branches throughout your code.

None
Example: Decision logic with tree structure in our new notification feature, suitable for Specification pattern

Notification filtering by Specification pattern

In our system, we had the following requirements, which led us to design a class implementation accordingly:

  • Use Tag and Team as leaf conditions.
  • Since a branch condition can strictly contain two or more child conditions, we use the operators ALL and ANY instead of AND / OR.
  • Support up to three levels of nested ALL / ANY branch conditions.
  • Because we persist data in DynamoDB, the implementation must be able to output conditions as JSON for storage.

Branch: ALL / ANY

export type SpecJSON =
  | { type: "Any"; specs: SpecJSON[] }
  | { type: "All"; specs: SpecJSON[] }

export interface Spec<C> {
  isSatisfiedBy(candidate: C): boolean
  toJSON(): SpecJSON
}

export abstract class Composite<C> implements Spec<C> {
  abstract isSatisfiedBy(candidate: C): boolean
  abstract toJSON(): SpecJSON // To store dynamoDB
}

// ---- Any / All ----
export class Any<C> extends Composite<C> {
  private specs: Spec<C>[]
  constructor(...specs: Spec<C>[]) {}
  isSatisfiedBy(candidate: C): boolean {}
  toJSON(): SpecJSON {} 
}
export class All<C> extends Composite<C> {
  private specs: Spec<C>[]
  constructor(...specs: Spec<C>[]) {}
  isSatisfiedBy(candidate: C): boolean {}
  toJSON(): SpecJSON {}
  }
}

Leaf : Team / Tag

// Better to extend the Branch condition for each Leaf class.
// You can reuse it when we need other type of filterling
// e.g.) Search functionality
export type NotificationSpecJSON =
  | SpecJSON
  | { type: "TeamSpec"; teamId: string }
  | { type: "TagSpec"; tagId: string }

// Example of candidate.
// In this case, it passes the array of team ID and tag ID 
// which are attached the analysis result
export type NotificationCandidate = { teamIds: TeamId[]; tagIds: TagId[] }

// Leaf condition 1: Team
export class TeamSpec extends Composite<NotificationCandidate> {
  constructor(private teamId: TeamId) {
    super()
  }

  isSatisfiedBy(candidate: NotificationCandidate) {
    return candidate.teamIds.some((teamId) => teamId === this.teamId)
  }

  toJSON(): NotificationSpecJSON {
    return {
      type: "TeamSpec",
      teamId: this.teamId,
    }
  }
}

// Leaf condition 2: Tag
export class TagSpec extends Composite<NotificationCandidate> {
  constructor(private tagId: TagId) {
    super()
  }

  isSatisfiedBy(candidate: NotificationCandidate) {
    return candidate.tagIds.some((tagId) => tagId.eq(this.tagId))
  }

  toJSON(): NotificationSpecJSON {
    return {
      type: "TagSpec",
      tagId: this.tagId.value,
    }
  }
}

You can use it like the following code snippet:

Usage:

// Create the complex specification using All and Any classes
const team1OrTeam2 = new Any(new TeamSpec(team1), new TeamSpec(team2))
const tag1AndTag2 = new All(new TagSpec(tag1), new TagSpec(tag2))
const leftSide = new All(team1OrTeam2, tag1AndTag2)

const team3OrTeam4 = new Any(new TeamSpec(team3), new TeamSpec(team4))
const tag3AndTag4 = new All(new TagSpec(tag3), new TagSpec(tag4))
const rightSide = new All(team3OrTeam4, tag3AndTag4)

const complexSpec = new Any(leftSide, rightSide)

// Target condition to judge
// In our case, the Team/Tag IDs that were attached on the Analysis result.
const candidate = { teams: [team1], tags: [tag1, tag2] },

// Execute judgement.
// Gonna be true if it passed as an entire condition.
const shouldSendMessage = complexSpec.isSatisfiedBy(candidate)

Outcome

In general, the Specification Pattern is said to offer the following benefits:

  • Improved testability
  • Better reusability of conditions
  • A unified interface
  • The ability to compose rules dynamically

From our own experience, applying this pattern allowed us to enhance the functionality smoothly, with virtually no bugs related to condition evaluation. We also made use of Cursor during the implementation, and it turned out to be a great fit — since we could issue instructions to the AI based on well-established concepts.

And when in doubt, there's no shortage of prior implementations available on GitHub to reference. Honestly, once you determine that your use case fits the pattern, there's little reason to hesitate — just use it.