Architecting Business Logic: Mastering the Specification Pattern in Modern PHP

Architecting Business Logic: Mastering the Specification Pattern in Modern PHP

Mastering the Specification Pattern for Clean Business Logic in PHP

The Problem: Logic Leakage and Fat Repositories

As software engineers at SiberFX, we frequently encounter codebases where business logic is scattered across controllers, services, and repositories. You might see a repository method like getActiveHighValueUsersWithRecentOrders(). While this works initially, it leads to several problems: the logic is not reusable, the repository becomes bloated with specific query methods, and testing individual business rules requires mocking the entire database layer.

The Specification Pattern offers a robust solution by encapsulating a business rule into its own object. This object can then be used to validate an entity in memory or to modify a database query. In this article, we will explore how to implement a modern, type-safe Specification Pattern using PHP 8.2+ features.

Defining the Specification Interface

At its core, a specification is a simple contract. It answers one question: 'Does this object satisfy the criteria?'. Let's start by defining a generic interface.

interface SpecificationInterface
{
    public function isSatisfiedBy(object $candidate): bool;
}

However, simple boolean checks are often not enough. We often need to combine these rules (AND, OR, NOT). This is where Composite Specifications come into play.

Building the Composite Specification

To make our specifications composable, we can create an abstract class that implements our logic for combining rules. This follows the Open-Closed Principle: we can add new business rules without changing existing code.

abstract class CompositeSpecification implements SpecificationInterface
{
    public function and(SpecificationInterface $other): SpecificationInterface
    {
        return new AndSpecification($this, $other);
    }

    public function or(SpecificationInterface $other): SpecificationInterface
    {
        return new OrSpecification($this, $other);
    }

    public function not(): SpecificationInterface
    {
        return new NotSpecification($this);
    }
}

Implementing Logical Operators

The AndSpecification would look like this:

class AndSpecification extends CompositeSpecification
{
    public function __construct(
        private readonly SpecificationInterface $left,
        private readonly SpecificationInterface $right
    ) {}

    public function isSatisfiedBy(object $candidate): bool
    {
        return $this->left->isSatisfiedBy($candidate) && 
               $this->right->isSatisfiedBy($candidate);
    }
}

Real-World Example: E-commerce Loyalty Program

Imagine we are building a system for a client at SiberFX that manages a loyalty program. A customer is eligible for a 'Gold Discount' if they have been a member for over 2 years AND they have spent more than $5,000.

First, we define our domain specifications:

class IsLongTermMember extends CompositeSpecification
{
    public function __construct(private int $years) {}

    public function isSatisfiedBy(object $user): bool
    {
        return $user->getSubscriptionDate() <= new DateTime("-{$this->years} years");
    }
}

class IsHighSpender extends CompositeSpecification
{
    public function __construct(private float $threshold) {}

    public function isSatisfiedBy(object $user): bool
    {
        return $user->getTotalSpent() >= $this->threshold;
    }
}

Using the Specifications

Now, we can combine these rules fluently in our service layer:

$isGoldEligible = (new IsLongTermMember(2))->and(new IsHighSpender(5000));

if ($isGoldEligible->isSatisfiedBy($currentUser)) {
    $currentUser->applyDiscount(0.15);
}

Bridging the Gap: Specifications and the Database

One of the biggest challenges with the Specification Pattern is avoiding the 'Select All' performance trap. If we use isSatisfiedBy, we have to pull all records from the database into memory to check them. This is not feasible for large datasets.

To solve this, we can introduce a toQuery(QueryBuilder $qb) method to our specifications, or use a specialized Specification Evaluator. However, a cleaner modern approach is to have the specification return a set of criteria that the Repository understands.

The Specification-to-Query Strategy

We can add a method to our interface that modifies a query builder object (like Doctrine or Laravel's Eloquent):

interface QueryableSpecification extends SpecificationInterface
{
    public function applyTo(object $queryBuilder): void;
}

This allows us to maintain a single source of truth for the business rule. If the 'Gold Discount' logic changes, we update the Specification class, and both the in-memory validation and the database queries are updated simultaneously.

The Benefits for Mid-Level Developers

Why should you adopt this at your company? Here are the primary advantages:

  • Testability: You can unit test IsHighSpender in isolation without touching a database or a complex service.
  • Readability: $repo->findAll($isGoldEligible) is far more descriptive than a long list of parameters.
  • Reusability: Use the same specification for validation in a Command, for filtering in a Repository, and for displaying badges in a View.
  • DRY (Don't Repeat Yourself): No more duplicating SQL logic in different parts of the application.

Performance and Best Practices

While powerful, keep these tips in mind:

1. Keep Specifications Stateless

Avoid injecting services or repositories into a Specification. It should contain only the logic and the data required to evaluate the rule (like the threshold amount). If a rule requires external data, fetch that data first and pass it to the candidate object.

2. Narrow Your Scope

Don't try to build one 'MegaSpecification'. Smaller, atomic specifications like IsActive, IsEmailVerified, and HasInventory are much easier to maintain and combine.

3. Handle Type Safety

In modern PHP, use property hooks or specific types in your candidate checks. In our example, we used object $candidate, but it is better to use User $user if the specification is specific to the User entity to take advantage of static analysis tools like PHPStan or Psalm.

Conclusion

The Specification Pattern is a vital tool in the belt of any senior PHP developer. By moving business rules out of the infrastructure layer and into dedicated domain objects, you create a codebase that is resilient to change and easy to reason about. At SiberFX, we find that this pattern significantly reduces technical debt in long-term projects. Next time you find yourself writing a complex where clause, ask yourself: 'Is this a reusable business rule?'. If the answer is yes, it's time for a Specification.

Selim Görmüş
Written by
Selim Görmüş

0 Comments

Share your thoughts

Your email address will not be published. Required fields are marked *

To leave a comment, please sign in to your account.