Index

A Design Is Good for a Change, Not in Isolation

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
    └── ExpressDelivery

The 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:

ChangeRegistry filesTable files
locker21
same-day58

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:

RequestCapacity callsResult
local, before noon, capacity1standard, same-day, express
local, before noon, no capacity1standard, express
local, before noon, provider down1capacity-unavailable
local, at noon0standard, express
remote, before noon0standard

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:

  1. What exact behavior must remain stable?
  2. Which concrete change is likely enough to optimize for?
  3. Which decisions, contracts, and composition points move in each design?
  4. Which plausible defects should the evidence reject?
  5. 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.