Introduction
The Specification Pattern encapsulates business rules into reusable, composable objects. It provides a clean way to express complex query criteria and validation rules without polluting repositories or domain objects with conditional logic.
Key Concepts
- Specification - a single business rule encapsulated as an object
- Composite Specification - combining specifications with AND, OR, and NOT operators
- Candidate - the object being evaluated against a specification
- Repository Integration - using specifications to build database queries dynamically
Real World Context
E-commerce platforms use specifications to filter products by complex criteria such as price ranges, availability, customer eligibility, and promotional rules. Each rule is a separate specification that can be composed at runtime based on the user's request.
Deep Dive
Basic Specification Interface
php<?php declare(strict_types=1); namespace Domain\Shared\Specification; /** * @template T */ interface Specification { /** * @param T $candidate */ public function isSatisfiedBy(mixed $candidate): bool; }
Concrete Specifications
php<?php namespace Domain\Order\Specification; final readonly class OrderIsOverdue implements Specification { public function __construct( private \DateTimeImmutable $now ) {} public function isSatisfiedBy(mixed $candidate): bool { assert($candidate instanceof Order); return $candidate->status() === OrderStatus::Pending && $candidate->createdAt()->diff($this->now)->days > 7; } } final readonly class OrderExceedsAmount implements Specification { public function __construct( private Money $threshold ) {} public function isSatisfiedBy(mixed $candidate): bool { assert($candidate instanceof Order); return $candidate->total()->isGreaterThan($this->threshold); } } final readonly class CustomerIsEligibleForDiscount implements Specification { public function __construct( private int $minimumOrders ) {} public function isSatisfiedBy(mixed $candidate): bool { assert($candidate instanceof Customer); return $candidate->completedOrderCount() >= $this->minimumOrders; } }
Composite Specifications
php<?php namespace Domain\Shared\Specification; final readonly class AndSpecification implements Specification { /** @var Specification[] */ private array $specifications; public function __construct(Specification ...$specifications) { $this->specifications = $specifications; } public function isSatisfiedBy(mixed $candidate): bool { foreach ($this->specifications as $spec) { if (!$spec->isSatisfiedBy($candidate)) { return false; } } return true; } } final readonly class OrSpecification implements Specification { /** @var Specification[] */ private array $specifications; public function __construct(Specification ...$specifications) { $this->specifications = $specifications; } public function isSatisfiedBy(mixed $candidate): bool { foreach ($this->specifications as $spec) { if ($spec->isSatisfiedBy($candidate)) { return true; } } return false; } } final readonly class NotSpecification implements Specification { public function __construct( private Specification $specification ) {} public function isSatisfiedBy(mixed $candidate): bool { return !$this->specification->isSatisfiedBy($candidate); } }
Fluent Composition
php<?php namespace Domain\Shared\Specification; abstract class ComposableSpecification implements Specification { public function and(Specification $other): Specification { return new AndSpecification($this, $other); } public function or(Specification $other): Specification { return new OrSpecification($this, $other); } public function not(): Specification { return new NotSpecification($this); } } // Usage $spec = (new OrderIsOverdue($now)) ->and(new OrderExceedsAmount(Money::fromCents(10000))) ->and((new OrderIsCancelled())->not()); $overdueHighValueOrders = array_filter( $allOrders, fn(Order $order) => $spec->isSatisfiedBy($order) );
Repository Integration
Specifications can be translated to database queries so filtering happens at the database level rather than in memory.
php<?php namespace Infrastructure\Persistence\Doctrine; final class DoctrineOrderRepository implements OrderRepository { public function findMatching(Specification $spec): array { $qb = $this->em->createQueryBuilder() ->select('o') ->from(Order::class, 'o'); $visitor = new DoctrineSpecificationVisitor($qb); $visitor->visit($spec); return $qb->getQuery()->getResult(); } } final class DoctrineSpecificationVisitor { public function __construct(private QueryBuilder $qb) {} public function visit(Specification $spec): void { match (true) { $spec instanceof OrderIsOverdue => $this->visitOverdue($spec), $spec instanceof OrderExceedsAmount => $this->visitExceedsAmount($spec), $spec instanceof AndSpecification => $this->visitAnd($spec), $spec instanceof OrSpecification => $this->visitOr($spec), default => throw new \RuntimeException('Unsupported specification'), }; } private function visitOverdue(OrderIsOverdue $spec): void { $this->qb ->andWhere('o.status = :status') ->andWhere('o.createdAt < :overdueDate') ->setParameter('status', OrderStatus::Pending->value) ->setParameter('overdueDate', $spec->thresholdDate()); } private function visitExceedsAmount(OrderExceedsAmount $spec): void { $this->qb ->andWhere('o.totalCents > :threshold') ->setParameter('threshold', $spec->threshold()->cents()); } }
Common Pitfalls
- Over-specifying simple queries - If a query is used in only one place, a specification may add unnecessary abstraction. Reserve the pattern for rules that are reused or composed.
- Forgetting database translation - In-memory evaluation works for small datasets but becomes a performance bottleneck when filtering large tables. Always provide a query-level implementation for database-backed repositories.
- Leaking infrastructure into specifications - Specifications should express business rules using domain language, not SQL fragments or ORM-specific constructs.
Best Practices
- Use readonly classes - PHP 8.2+ readonly classes are ideal for specifications since they are inherently immutable and have clear constructor-based configuration.
- Compose at the application layer - Build composite specifications in application services based on the use case, keeping individual specifications focused on a single rule.
- Test specifications independently - Each specification should have its own unit test verifying the business rule it encapsulates.
Summary
- The Specification Pattern encapsulates business rules as composable, reusable objects
- Specifications can be combined with AND, OR, and NOT operators for complex criteria
- Repository integration translates specifications into efficient database queries
- PHP readonly classes provide an ideal implementation for immutable specifications
- Keep specifications focused on domain rules, not infrastructure details