Two delivery-option implementations return the same quotes.
One has a DeliveryService interface, a class for each service, and a tagged
Laravel registry. The other has a validated rule table and one evaluator. The
registry looks extensible. The table looks compact.
Which design is better?
There is not enough information to answer. “Extensible” names a possibility, not a requirement. “Compact” counts structure, not the decisions a future change will disturb.
Apply a locker-delivery requirement and the table absorbs the change in one policy definition. Reset to the original baseline, add capacity-aware same-day delivery, and the table has to grow a behavior interface, a collaborator, and new failure semantics. Its old advantage belonged to one kind of change.
A design is not good in isolation. It is good for a named change under a set of behavioral constraints.
Equal results remove the easy escape hatch
The reproduction starts with a synthetic Laravel 13 service. A request contains a destination, weight, and requested hour. Both designs return the same ordered list of delivery codes and prices.
The registry delegates each decision to a service:
RegistryDeliveryOptions
└── iterable<DeliveryService>
├── StandardDelivery
└── ExpressDeliveryThe rule-table version keeps the corresponding policy together:
return [
new DeliveryRule('express', ['local' => 900], 1, 5_000, 30),
new DeliveryRule(
'standard',
['local' => 500, 'remote' => 800],
1,
20_000,
10,
),
];This is not an unvalidated array made deliberately fragile so polymorphism can
win. DeliveryRule rejects an empty code, missing destination prices, negative
prices, and invalid weight ranges. Both implementations also reject duplicate
service codes.
Nine inputs cover the weight edges, one unit beyond them, local and remote destinations, stable ordering, and requests with no available option. The contract suite runs those cases against both implementations, and a separate parity test compares all nine results directly.
That baseline matters. Without it, a comparison can quietly credit one design for doing less or excuse it for changing existing behavior.
Locker delivery is a definition-shaped change
The first requirement fits the dimensions already present:
Add service `locker`.
Price: 350 cents.
Destination: local only.
Weight: 1 through 10,000 grams, inclusive.
Order: after standard, before express.The registry adds LockerDelivery and tags it at the composition root. The
table adds one validated definition:
new DeliveryRule('locker', ['local' => 350], 1, 10_000, 20)Both changed designs pass eleven cases and produce the same public-result matrix. The exact 10,000-gram edge includes locker delivery; 10,001 grams does not. A remote request never includes it. When every service is eligible, the order is standard, locker, express.
For this change, the table keeps the whole decision visible in one policy file. The registry separates behavior from activation across two files. That is a real difference in review shape. It still is not a universal score.
A separate class would earn its keep if locker delivery acquired a carrier API, a distinct calculation, or a failure policy. In the requirement actually given, the class contains values which the shared evaluator already knows how to interpret.
File counts describe a diff, not its quality
It is tempting to stop here:
| Change | Registry files | Table files |
|---|---|---|
| locker | 2 | 1 |
| same-day | 5 | 8 |
The numbers are retained by the fixture, but they do not mean “the table wins 1–2” or “the registry wins 5–8.” Shared contracts, behavior, composition, and an evaluator carry different decisions. One added class can be safer than one new branch in a crowded function. One table row can be clearer than a class which only returns constants.
The useful inventory names roles:
- existing decision-owning files which changed;
- new behavior or policy definitions;
- external contracts and translated failures;
- composition entries;
- caller contracts which remained unchanged; and
- tests which prove old behavior and the new boundary.
Class count, line count, interface count, coverage percentage, and wall-clock
editing time cannot answer whether those decisions moved together for the
right reason. The fixture’s verifier leaves its quality_scores array empty on
purpose.
David Parnas’s modularization paper supplies the older and more useful premise: the criteria used to divide a system affect its flexibility and comprehensibility. It does not turn a module count into a quality measure, and it does not predict the next delivery requirement.
Same-day delivery changes the kind of variation
The second experiment resets both implementations to the same original baseline. Locker delivery is not carried forward.
The new requirement is different:
Add `same-day` for local requests before 12:00.
Ask CapacityGateway whether capacity is available.
No capacity omits the option.
Provider failure becomes `capacity-unavailable`.
Never call the provider for remote or late requests.This is not another set of scalar values. It introduces an external decision, a call guard, and a distinction between an ordinary negative answer and an operational failure.
The registry gives that behavior to one service:
public function optionFor(DeliveryRequest $request): ?DeliveryOption
{
if ($request->destination !== 'local'
|| $request->requestedHour >= 12) {
return null;
}
try {
$available = $this->capacity->available($request->destination);
} catch (CapacityProviderUnavailable) {
throw DeliveryAvailabilityFailed::capacityUnavailable();
}
return $available
? new DeliveryOption('same-day', 1_200, 15)
: null;
}The composition root supplies CapacityGateway and activates the new service.
Laravel’s official container documentation supports the
tagging and tagged-resolution mechanics used by the fixture. Those mechanics
make activation explicit; they do not prove that a registry is the right
domain model.
The scalar table cannot express the change honestly. Adding a special callback
field or a same-day branch to the evaluator would hide a second programming
model inside the definitions. The changed version instead introduces
OptionRule:
interface OptionRule
{
public function code(): string;
public function optionFor(DeliveryRequest $request): ?DeliveryOption;
}Existing DeliveryRule implements it, and a new SameDayRule owns the
capacity behavior. RuleDefinitions now composes behavior objects rather than
returning only scalar definitions.
That is a defensible design. It is also an admission: under this pressure, the rule table became a registry of rules. The original representation did not already contain the new axis.
Failure behavior reveals what the abstraction owns
Both same-day variants produce the same five-case matrix:
| Request | Capacity calls | Result |
|---|---|---|
| local, before noon, capacity | 1 | standard, same-day, express |
| local, before noon, no capacity | 1 | standard, express |
| local, before noon, provider down | 1 | capacity-unavailable |
| local, at noon | 0 | standard, express |
| remote, before noon | 0 | standard |
The interesting cells are not the happy path. Calling capacity before checking the destination turns an ineligible remote request into an unnecessary network dependency. Treating “no capacity” as provider failure changes an ordinary business result into an operational error. Swallowing provider failure makes an outage indistinguishable from a valid negative answer.
The reproduction applies those defects one at a time. Across both designs, ten same-day mutations cover the inclusive-noon edge, calling before the guard, swallowing provider failure, converting no capacity into failure, and omitting activation. Eight locker mutations cover its maximum edge, remote leakage, ordering, activation, and price. All eighteen fail by name.
That result does not prove one abstraction is more maintainable. It proves the comparison is sensitive to the ways each concrete change can be wrong. Without negative controls, a green matrix might only show that the examples are too weak to distinguish the implementations.
The expected change has to come from somewhere
The locker result would justify a table only if definition-shaped changes are credible. The same-day result would justify a behavior registry only if variants really own different collaborators or failure semantics.
Source code cannot supply those probabilities. Useful evidence comes from a committed roadmap, repeated change history, current integration constraints, product rules, or a small design spike against the next known requirement. Absent that evidence, a hypothetical future need is speculation wearing an architecture badge.
Martin Fowler’s Yagni explains why speculative capability has a cost now and why tests plus refactoring make delayed decisions viable. Delaying a seam is not a refusal to design. It is a decision to pay for an abstraction when a concrete pressure can shape it.
The choice reverses when delay is expensive. A public plugin contract, a committed multi-provider rollout, independent package ownership, or a protocol which cannot be migrated atomically may justify an extension point before its second implementation arrives.
Keep the least elaborate shape which fits the pressure
Several representations remain credible:
Use direct conditionals when there are few cases, one owner, and one visible decision. Move toward a table when variants differ only across validated data dimensions and seeing the complete policy together helps review or operation.
Use separate behavior objects when variants own different algorithms, dependencies, protocols, or failure semantics. Keep composition explicit and reject duplicate identities; a registry that silently forgets activation is not open for extension in any useful sense.
Configuration or generated definitions can help when operators own many repetitive variants. That moves the programming surface into a schema, validation, deployment, provenance, and compatibility policy. It does not make the change “no code.”
Sometimes the right answer is to defer the abstraction. The preceding article on characterization boundaries shows how a selected behavior contract can make that later refactor safer. The article on visible composition shows the wiring cost that a registry must still pay.
Judge the next change, then preserve the option to revise
A design comparison can be much smaller than this fixture. It needs enough evidence to answer five questions:
- What exact behavior must remain stable?
- Which concrete change is likely enough to optimize for?
- Which decisions, contracts, and composition points move in each design?
- Which plausible defects should the evidence reject?
- What changed condition would reverse the choice?
The fixture was exported from commit
5668a8b8958a3d58bc8ae4a4312353ca162b6616 and run from a clean archive. Its
verifier, role-based change inventory, normalized baseline parity, both result
matrices, and representative mutation diffs match the retained evidence by
SHA-256. That makes this comparison reproducible. It does not turn invented
delivery rules into production experience or predict another system’s next
requirement.
For definition-shaped variation, keep the validated table. For behavior-shaped variation, let behavior own its dependency and failure policy. If the expected change is still unnamed, keep the simpler current design and the tests needed to change it when the pressure becomes real.