Skip to main content
ArticlesProjects

The Second Pattern: Specification

Nine optional parameters is the symptom. The problem is a business rule that has to run in SQL and in memory, written twice and quietly drifting apart.

Here is a method that nobody designed. It accumulated.

final readonly class ConsignmentRepository
{
public function findConsignments(
?string $carrier = null,
?string $serviceLevel = null,
?string $status = null,
?string $destinationCountry = null,
?DateTimeInterface $bookedAfter = null,
?DateTimeInterface $bookedBefore = null,
?int $minimumValue = null,
?int $maximumValue = null,
?bool $requiresCustoms = null,
): array {
$query = query(Consignment::class)->select();
if ($carrier !== null) {
$query->whereField('carrier', $carrier);
}
if ($serviceLevel !== null) {
$query->whereField('service_level', $serviceLevel);
}
// … seven more of these
return $query->all();
}
}

It started as findConsignments(?string $carrier = null). Then the support team needed to filter by status. Then someone in finance wanted a value band. Each addition was two lines and entirely reasonable at the time.

Look at what you have now, though. Nine parameters, six of which are null on any given call. The signature tells you nothing about which combinations are legal, or which ones anybody actually uses. If I hand you this method cold and ask whether it is safe to pass $requiresCustoms: true alongside a $destinationCountry of GB, you cannot answer without reading the body and knowing something about how customs works. The type signature has stopped carrying information.

The call sites are worse.

$consignments = $this->consignments->findConsignments(
null,
null,
'awaiting_clearance',
null,
null,
null,
null,
null,
true,
);

Named arguments help with the readability, and they should absolutely be used here, but they do not fix the underlying problem. That call is asking a question with a name. Somebody in the business says it out loud several times a week: which consignments are stuck in customs? The code expresses it as two filters that happen to co-occur, and every place that asks the same question has to remember to pass both.

The obvious move

Most of us reach for a query object next. Stop passing nulls, start chaining.

final class ConsignmentQuery
{
private SelectQueryBuilder $query;
public function __construct()
{
$this->query = query(Consignment::class)->select();
}
public function forCarrier(string $carrier): self
{
$this->query->whereField('carrier', $carrier);
return $this;
}
public function withStatus(string $status): self
{
$this->query->whereField('status', $status);
return $this;
}
public function requiringCustoms(): self
{
$this->query->whereField('requires_customs', true);
return $this;
}
public function all(): array
{
return $this->query->all();
}
}

This is better, and for a lot of applications it is where you should stop. The call site now reads properly, the combinations are explicit, and adding a filter does not change any existing signature.

I am not setting this up to knock it down. If your filters are few and fixed, this is the right amount of structure and the rest of this article is not for you.

So where does it stop working? Not where you would expect. It is not the method count that gets you.

The first crack is that the interesting questions are not filters, they are concepts. “Awaiting customs clearance” is not a status, it is a status and a customs flag and, once you look closely, a condition about whether the carrier has acknowledged the booking. So you add awaitingCustomsClearance() to the query object, which is fine, and then atRiskOfLateDelivery(), which needs a date comparison against the service level’s transit time, and now your query object has business logic in it and twenty methods.

The second crack is the one that actually hurts. Somewhere else in the application, you need to know whether this particular consignment, the one you are holding in memory, is at risk of late delivery. You want to show a badge on it. And you cannot ask the query object, because the query object only knows how to build SQL. So you write the rule a second time.

public function isAtRisk(Consignment $consignment): bool
{
return $consignment->status !== 'delivered'
&& $consignment->expectedDelivery < $this->clock->now();
}

Two implementations of one business rule, in two languages, with nothing connecting them. They will drift. Someone fixes the SQL version when “at risk” starts accounting for customs holds, and the badge keeps showing the old answer until a customer asks why.

That duplication is the signal. It means the rule wants to be a thing rather than a method.

The pattern

What you want is a Specification.

Eric Evans introduced it in Domain-Driven Design in 2003, and he and Martin Fowler later wrote it up properly in a paper simply called Specifications. The idea is small: a specification is an object that encapsulates a single predicate. It can tell you whether a candidate satisfies it, and specifications can be combined with and, or and not to build bigger predicates out of smaller ones.

Almost every PHP write-up gets one thing backwards: which use comes first. They present Specification as a way to build query objects, with the predicate as a footnote. Evans and Fowler present it the other way round. The predicate is the pattern. Being able to translate that predicate into a query is a valuable second capability, and the whole point is that both come from one definition.

That is the property that solves the problem above. One class defines what “at risk” means. It can answer the question about an object in memory, and it can push the same rule into the database. When the definition changes, it changes in one place.

When this is worth it

I would reach for a specification when at least one of these is true.

The same rule is needed in more than one place, and one of those places is not a database query. This is the strongest signal by a distance. If you are filtering a list and validating and deciding whether to render a badge, the rule needs to exist independently of all three.

More than two optional filters that genuinely combine. Two filters give you four combinations. Five give you thirty two. Somewhere in between, the query object’s method list stops describing the legal combinations and starts hiding them.

The rule has a name someone says out loud. If people in the business say “the at-risk ones” and your code says ->whereStatus('in_transit')->whereExpectedDeliveryBefore($now), you have a translation layer living in everyone’s head. Specifications let the code use the business’s noun.

The rule needs to be assembled at runtime. API filtering is the obvious case. A client sends ?filter=at-risk,requires-customs and you have to combine those two into one query. You cannot compose method calls that have already happened, so you need objects.

When it is not

None of which applies if your filters are fixed and few. Keep the query object. A class per filter, for four filters that never combine in surprising ways, is ceremony you will resent within a month.

The same goes for a rule you use exactly once. One place, no drift risk, nothing to keep in sync. Write the where clause and move on. Promoting it later costs almost nothing, and the moment you need it somewhere else is the moment you will know.

Then there is the case where the rule only ever needs to run in SQL. That one is worth naming carefully, because Tempest has an answer for it that is smaller than a full specification, and I will come back to it in a minute.

And if your users build their own filter expressions, stop. You are writing an interpreter, not assembling specifications, and that is a different pattern with a different set of problems.

Building it in Tempest

Before writing a single class, go and read what the framework already gives you. In this case it gives you half the pattern, which I would have missed if I had gone straight to the keyboard.

There is a QueryScope interface in the database package:

namespace Tempest\Database\Builder\QueryBuilders;
interface QueryScope
{
public function apply(SupportsWhereStatements $builder): void;
}

And query builders have an applyScopes() method that takes an array of them. So a reusable, named, composable-by-array piece of query logic is already a first-class concept:

use Tempest\Database\Builder\QueryBuilders\QueryScope;
use Tempest\Database\Builder\QueryBuilders\SupportsWhereStatements;
final readonly class ForCarrier implements QueryScope
{
public function __construct(
private string $carrier,
) {}
public function apply(SupportsWhereStatements $builder): void
{
$builder->whereField('carrier', $this->carrier);
}
}
$consignments = query(Consignment::class)
->select()
->applyScopes([
new ForCarrier('carrier-a'),
new AwaitingCustomsClearance(),
])
->all();

If that is all you need, stop here. Genuinely. This is the framework handing you a well-sized tool for the “reusable named query fragment” problem, and reaching past it for something bigger would be the exact mistake this series is about.

What QueryScope cannot do is answer a question about an object. Look at the signature: it takes a builder and returns void. It exists to mutate a query. You cannot ask a QueryScope whether this consignment in your hand satisfies it, because it has no idea what a consignment is.

So the specification is QueryScope plus a predicate.

namespace App\Consignments\Specifications;
use Tempest\Database\Builder\QueryBuilders\QueryScope;
use Tempest\Database\Builder\QueryBuilders\SupportsWhereStatements;
use Tempest\Database\Builder\QueryBuilders\WhereGroupBuilder;
interface Specification extends QueryScope
{
public function isSatisfiedBy(Consignment $consignment): bool;
public function apply(SupportsWhereStatements|WhereGroupBuilder $builder): void;
}

Extending QueryScope rather than replacing it matters. Every specification you write drops straight into applyScopes() and works with the framework’s query builder, with no adapter and no bridge class. You are adding a capability, not building a parallel universe.

That union type needs explaining, because I did not put it there for fun. I wrote this interface without it first, and the composition code further down blew up.

QueryScope::apply() receives a SupportsWhereStatements. When you nest conditions with whereGroup(), the closure is handed a WhereGroupBuilder instead, and although that class has whereField(), orWhere() and whereRaw() with the right signatures, it does not implement the interface. PHP does not do structural typing, so passing one where the other is expected is a TypeError. Widening the parameter is legal because PHP allows contravariant parameter types, and it is the smallest honest fix.

Worth knowing about the interface generally: it declares three methods. None of the convenient ones, and not whereGroup(). Your specifications can call more than the type promises, because the concrete builders have far more on them, but your IDE will not help you and a future refactor could take it away.

Now the rule that was duplicated earlier has one home:

final readonly class AtRiskOfLateDelivery implements Specification
{
public function __construct(
private Clock $clock,
) {}
public function isSatisfiedBy(Consignment $consignment): bool
{
return $consignment->status !== ConsignmentStatus::DELIVERED
&& $consignment->expectedDelivery < $this->clock->now();
}
public function apply(SupportsWhereStatements|WhereGroupBuilder $builder): void
{
$builder
->whereNot('status', ConsignmentStatus::DELIVERED)
->whereField('expected_delivery', $this->clock->now(), WhereOperator::LESS_THAN);
}
}

Both halves, side by side, in one file. They can still drift, and I will come back to that, but now they drift eight lines apart instead of across two packages.

Composition

The combinators are what make specifications more than named scopes. Tempest’s builder has whereGroup() and orWhereGroup(), which take a closure, and that is enough to express the grouping correctly.

final readonly class AndSpecification implements Specification
{
/** @var Specification[] */
private array $specifications;
public function __construct(Specification ...$specifications)
{
$this->specifications = $specifications;
}
public function isSatisfiedBy(Consignment $consignment): bool
{
foreach ($this->specifications as $specification) {
if (! $specification->isSatisfiedBy($consignment)) {
return false;
}
}
return true;
}
public function apply(SupportsWhereStatements|WhereGroupBuilder $builder): void
{
foreach ($this->specifications as $specification) {
$builder->whereGroup(
fn (WhereGroupBuilder $group) => $specification->apply($group),
);
}
}
}

The grouping is not decoration. Ask yourself what happens without it: a specification that internally uses an orWhere leaks that or out into the surrounding query, and your result set quietly gets wider. No error, no failing test unless you wrote one for exactly this, just more rows than you asked for. That is why the combinators build groups rather than appending conditions.

An OrSpecification is the same shape with orWhereGroup(), and a Not wraps a single specification and inverts both halves.

With those three, the runtime assembly case works:

$specification = new AndSpecification(
new AwaitingCustomsClearance(),
new OrSpecification(
new ForCarrier('carrier-a'),
new ForCarrier('carrier-b'),
),
);
$consignments = query(Consignment::class)
->select()
->applyScopes([$specification])
->all();
// And the same object, on a single record, with no database involved
$badge = $specification->isSatisfiedBy($consignment);

Registering them with discovery

The API filtering case needs one more thing. If a client sends ?filter=at-risk,requires-customs, something has to turn those strings into objects, and I do not want a match statement that I have to remember to update.

This is where Tempest does something I have not seen another PHP framework invite so directly. Discovery is not just how the framework finds your controllers. It is an extension point you are expected to use for your own concepts.

use Tempest\Discovery\Discovery;
use Tempest\Discovery\DiscoveryLocation;
use Tempest\Discovery\IsDiscovery;
use Tempest\Reflection\ClassReflector;
final class SpecificationDiscovery implements Discovery
{
use IsDiscovery;
public function __construct(
private readonly SpecificationRegistry $registry,
) {}
public function discover(DiscoveryLocation $location, ClassReflector $class): void
{
$attribute = $class->getAttribute(NamedSpecification::class);
if ($attribute === null) {
return;
}
$this->discoveryItems->add($location, [$class, $attribute]);
}
public function apply(): void
{
foreach ($this->discoveryItems as [$class, $attribute]) {
$this->registry->register($attribute->name, $class->getName());
}
}
}

You do not register that discovery class anywhere. Tempest discovers its own discovery classes, so implementing the interface is the whole of the setup.

The attribute is as small as you would hope:

use Attribute;
#[Attribute]
final readonly class NamedSpecification
{
public function __construct(
public string $name,
) {}
}

And tagging a specification makes it available by name:

#[NamedSpecification('at-risk')]
final readonly class AtRiskOfLateDelivery implements Specification
{
// …
}

The registry itself is where one detail matters. Notice that apply() stored a class name rather than an instance, and it had to. AtRiskOfLateDelivery takes a Clock in its constructor, and discovery runs at boot, long before you know which specifications a given request will ask for. So the registry hands resolution to the container:

use Tempest\Container\Container;
use Tempest\Container\Singleton;
#[Singleton]
final class SpecificationRegistry
{
/** @var array<string, class-string<Specification>> */
private array $specifications = [];
public function __construct(
private readonly Container $container,
) {}
public function register(string $name, string $className): void
{
$this->specifications[$name] = $className;
}
public function resolve(string $name): Specification
{
$className = $this->specifications[$name]
?? throw new UnknownSpecification($name);
return $this->container->get($className);
}
}

Do not skip the #[Singleton]. Without it you get one registry for the discovery class and a different, empty one for anything that tries to read it, and the bug presents as your specifications having vanished.

Which leaves the controller almost boring, and that is the point:

final readonly class ListConsignmentsController
{
public function __construct(
private SpecificationRegistry $registry,
) {}
#[Get('/consignments')]
public function __invoke(ListConsignmentsRequest $request): Response
{
$specification = new AndSpecification(
...array_map(
$this->registry->resolve(...),
$request->filters,
),
);
$consignments = query(Consignment::class)
->select()
->applyScopes([$specification])
->all();
return new Ok($consignments);
}
}

Adding a new filter to the API is now one class with one attribute. Nothing central to update, no match statement to remember, and discovery is cached in production so you do not pay the reflection cost per request.

I am introducing discovery here deliberately, because it comes back later in this series. Two of the patterns still to come are, underneath, the same problem: find every implementation of an interface and wire it up. In most frameworks that is a service provider chore. Here it is a class you write once.

What it costs

The class count is the obvious one. Nine filters becomes nine classes plus three combinators, and if your team finds that oppressive, that is a real cost rather than a failure of taste. It is why I gave the previous section as much room as I did.

Debugging gets a layer harder too. When a query returns the wrong rows you are now reading a composed object graph rather than a method body, so logging the generated SQL stops being a nicety and becomes something you wire up on day one.

There is also that union parameter, which is not elegant and exists only because the framework’s grouping builder does not implement the interface its own scopes are typed against. That may well get tidied up, and if it does this article ages in exactly one paragraph. Building on a young framework means occasionally holding something the maintainers have not settled yet.

The cost that should actually worry you is drift between the two halves. Putting isSatisfiedBy() and apply() in the same class makes drift much less likely, but nothing enforces that they agree. A specification whose predicate says one thing and whose query says another is worse than the duplication you started with, because now you believe they match.

The mitigation is a test, and it is the test I would write first:

it('agrees with itself', function () {
$specification = new AtRiskOfLateDelivery(new MockClock('2026-01-15'));
$matched = query(Consignment::class)
->select()
->applyScopes([$specification])
->all();
$all = query(Consignment::class)->select()->all();
$expected = array_filter(
$all,
fn (Consignment $c) => $specification->isSatisfiedBy($c),
);
expect($matched)->toEqualCanonicalizing($expected);
});

Run the specification both ways against the same fixtures and assert the answers match. It is four lines of setup and it catches the only failure mode that makes this pattern worse than what it replaced. If you take one thing from this article, take this test rather than the class hierarchy.

Where this leaves us

Specification earns its keep when a business rule has to exist in more than one place and one of those places is not a database query. Four filters that never surprise anyone do not need it, and for the middle ground, where you want named query fragments and nothing else, QueryScope is already the right size.

If you take one thing, though, make it the instinct I would push back on hardest. Do not reach for this because the parameter list got long. The parameter list is a symptom. What you have is a business rule with no home.

Next in the series: your carrier integration works fine, and the vendor’s vocabulary has quietly become your domain’s vocabulary. The Anti-Corruption Layer, and why GraphQL makes the coupling harder to see than XML ever did.

Part of a Series

The Second Pattern

You are reading Part 1 of 10 in this learning series.

View Full Series

Share

XLinkedIn

Related

Keep Reading

All posts →