Design Patterns And Data Architecture
Strategy Pattern
The strategy pattern lets several interchangeable classes implement the same behavior in different ways. A caller depends on a shared interface, while another part of the application chooses which implementation to use. In PHP applications, strategy is useful for pricing rules, shipping methods, tax calculation, password policies, export formats, notification channels, validation rules, feature-specific scoring, and provider-specific behavior.
The pattern is most useful when a conditional is growing because each branch is a real algorithm or policy. A two-line match expression is not automatically a problem. A controller or service with ten branches, each requiring different dependencies and tests, is a stronger signal. Strategy gives each branch a name, a contract, and a focused place for tests.
A strategy is different from a factory. The strategy performs behavior. A factory may choose or create the strategy. A strategy is also different from inheritance for reuse. Strategies are normally composed into the caller. The caller can use one implementation today and another tomorrow without becoming a subclass of either.
The Shape Of A Strategy
A strategy design usually has three parts:
- an interface that describes the behavior the caller needs
- one or more concrete implementations
- selection code that chooses the implementation for a situation
The interface should be named after the behavior, not after the pattern. ShippingCostCalculator, PasswordPolicy, ExportWriter, and TaxRule are useful names. StrategyInterface is not.
A strategy interface should be narrow enough that every implementation can honor it honestly. If one implementation needs a customer, another needs a file path, and another needs a database transaction, the abstraction may be mixing several different jobs.
Replacing A Conditional
This example starts with the decision already separated into strategy classes. The checkout code does not know the discount rule it received.
<?php
declare(strict_types=1);
interface DiscountStrategy
{
public function discountFor(int $subtotalPence): int;
}
final class StudentDiscount implements DiscountStrategy
{
public function discountFor(int $subtotalPence): int
{
return (int) round($subtotalPence * 0.15);
}
}
final class VipDiscount implements DiscountStrategy
{
public function discountFor(int $subtotalPence): int
{
return (int) round($subtotalPence * 0.25);
}
}
final class CheckoutTotal
{
public function __construct(
private DiscountStrategy $discount,
) {
}
public function totalAfterDiscount(int $subtotalPence): int
{
return $subtotalPence - $this->discount->discountFor($subtotalPence);
}
}
$checkout = new CheckoutTotal(new StudentDiscount());
echo $checkout->totalAfterDiscount(10000) . PHP_EOL;
// Prints:
// 8500
The checkout code is now stable while discount rules can change independently. The student discount can be tested without VIP cases. The VIP discount can be tested without constructing a whole checkout controller.
This is useful when discount rules are product policies. If the rules are trivial and never vary, the extra classes may be unnecessary.
Strategy Selection
Someone still has to choose the strategy. That selection can happen in configuration, a factory, dependency-injection wiring, a controller, or a use case. Keep selection close to the information that justifies it.
If the strategy is fixed for the whole application, dependency injection configuration is enough. For example, all password checks might use NistPasswordPolicy until the team deliberately changes the binding.
If the strategy depends on runtime data, use a factory or resolver. A shipping method may depend on country, cart contents, delivery speed, and carrier availability. A report export strategy may depend on the requested format.
Avoid making every strategy implementation ask whether it applies. That can scatter selection rules. A supports() method is useful in plugin-style systems, but it still needs deterministic ordering and clear failure behavior when nothing matches or several match.
Default strategies need the same care. NoDiscount, NullNotifier, or LocalFilesystemStorage can be valid explicit choices, but they should not hide missing configuration in production. If the product requires a real provider, failing fast is safer than quietly using a no-op implementation. Reserve defaults for cases where the default is part of the documented behavior.
Dependencies And State
Strategies can have dependencies. A shipping strategy may need a carrier client. A tax strategy may need a rate table. A password policy may need a breach-password checker. Those dependencies should be explicit in the strategy constructor.
The caller should not pass the entire service container into every strategy. That hides dependencies and makes each implementation harder to test. It also encourages strategies to do extra work beyond their contract.
Be careful with mutable state inside strategies. Many strategies should be stateless services. If a strategy caches results, tracks attempts, or stores request-specific data, decide whether that state is safe for the runtime. Long-running PHP processes, workers, and application servers can reuse the same object between requests if the framework container is configured that way.
Strategies Should Share Semantics
The interface is not only a method signature. It is a promise about meaning. If ShippingCostCalculator::costFor($cart) returns pence, every implementation must return pence. If one implementation returns tax-inclusive values and another returns tax-exclusive values, the shared type is misleading.
Document units, rounding, timeouts, failure behavior, and side effects. Can the strategy call a remote service? Can it throw? Does it return zero when unavailable, or fail the checkout? Does it mutate the cart? These questions matter more than the pattern name.
Use value objects where they make the contract clearer. Returning Money can be safer than returning int if the application handles multiple currencies. Returning a result object can be clearer when a strategy may reject the operation with a reason.
Strategy Versus Match
Modern PHP has match, and a clear match expression is often better than a premature strategy hierarchy. Keep the conditional when:
- there are only a few simple branches
- branches do not need separate dependencies
- branch behavior is obvious and unlikely to grow
- tests are still readable
- selection and behavior are tightly coupled and local
Move toward strategy when branches grow, require different dependencies, need separate tests, are owned by different teams, change at different rates, or represent plugin-like behavior.
Refactor because the design pressure exists, not because a pattern catalogue says conditionals are bad.
Strategy Versus Template Method
Template method uses inheritance: a base class defines a workflow and subclasses override specific steps. Strategy uses composition: a caller receives an object that implements a behavior interface.
In PHP application code, strategy is often easier to test and change because it avoids deep inheritance chains. A class can receive different strategies without becoming part of a class hierarchy. It can also combine several strategies when the use case needs separate choices, such as pricing, tax, and shipping.
Template method can still be useful when a framework base class owns a lifecycle and exposes protected extension points. Do not force strategy where the framework already has a clear supported extension mechanism. The important design choice is explicit variation, not one pattern winning every time.
Testing Strategies
Each strategy should have focused tests for its own rule. If StudentDiscount rounds percentages, test the rounding boundaries there. If VipDiscount has a maximum discount, test that maximum there. The caller should have a smaller test proving that it uses the strategy result correctly.
Selection code needs separate tests. A factory or resolver should be tested for known inputs, unknown inputs, ordering conflicts, and missing dependencies. Without selection tests, the individual strategy classes can all pass while production still chooses the wrong one.
Contract tests can be useful when many strategies must obey the same semantics. A shared test can assert that every shipping strategy returns non-negative pence, does not mutate the cart, and throws a documented exception for unsupported destinations.
Common Failure Modes
One failure mode is over-abstraction. Turning every if into an interface and three classes makes simple code harder to read. The strategy pattern should reduce complexity by isolating meaningful variation.
Another failure mode is a vague interface. Processor::process(array $data): array can hide many unrelated behaviors. The caller and implementers no longer share a precise contract, and tests become shallow.
A third failure mode is leaking selection into callers. If every controller has to know which strategy class to instantiate, the conditional has only moved. Use configuration, factories, or resolvers where selection is repeated.
A fourth failure mode is inconsistent side effects. One notification strategy only formats a message, another sends it immediately, and another queues it. Those are different semantics. Split the interface or rename the behavior so the contract is honest.
Review Criteria
When reviewing a strategy design, ask what varies and why. If the variation is real, check that the interface captures the stable behavior and that each implementation can honor it without special cases.
Check where selection happens. The selection rule should be testable and should have a defined failure mode. Unknown format, unsupported country, disabled provider, or missing configuration should not produce a random default unless the product explicitly wants that.
Check that strategies do not know too much about the caller. A shipping strategy should not receive the entire HTTP request if it only needs destination, weight, and basket value. Narrow inputs make strategies more reusable and easier to test.
What To Check
Before moving on, make sure you can:
- explain strategy as interchangeable behavior behind a shared interface
- decide when a
matchis clearer than a strategy hierarchy - separate behavior implementations from selection logic
- use factories or configuration to choose runtime strategies
- define strategy contracts with clear units, errors, and side effects
- test individual strategies and the resolver that selects them
- spot vague interfaces, over-abstraction, and inconsistent semantics
After this lesson, you should be able to take a growing conditional in a PHP application and decide whether it should remain local, become a clearer match, or be refactored into strategy classes with a precise contract and tested selection rule.
Practice
Task: Replace A Conditional With Strategy
A checkout service has a match that calculates discounts for student, vip, and none. The student rule will soon require a student-status API, and the vip rule will require account history.
Design a strategy-based replacement.
Requirements
Name the interface, the concrete strategies, the method signature, and where dependencies belong. Explain why this is now a reasonable strategy candidate.
Check your work
The answer should separate discount behavior from the code that chooses which discount applies.
Show solution
Concrete strategies could be NoDiscount, StudentDiscount, and VipDiscount. StudentDiscount receives a student-status client through its constructor. VipDiscount receives an account-history repository or service through its constructor. Those dependencies belong in the strategies because they are required by the specific rules.
A separate factory or resolver chooses the strategy from the customer's selected discount type, account state, or validated discount code. The checkout service receives the chosen strategy or asks the resolver for it, then applies the result consistently.
This is now a reasonable strategy candidate because branches have different dependencies, will need separate tests, and represent product policies that can change independently.
Task: Design Runtime Strategy Selection
A reporting feature can export csv, json, and xlsx. The selected format comes from a validated request parameter.
Design the strategy selection flow.
Requirements
Include the export strategy interface, concrete implementations, resolver or factory behavior, unknown-format handling, and tests.
Check your work
The controller should not instantiate every exporter directly.
Show solution
Create a ReportExporterResolver or ReportExporterFactory with a method forFormat(string $format): ReportExporter. It maps validated format names to implementations. Unknown formats should throw a specific exception or return a validation error before export begins; they should not silently fall back to CSV.
The controller validates the request parameter, asks the resolver for the exporter, calls the use case or exporter, and returns the correct response headers and body. The controller does not know constructor details for each exporter.
Tests should cover each known format, unknown format rejection, response headers for each output type, and at least one focused test per exporter for format-specific behavior.
Task: Review Strategy Overuse
A project has NameFormattingStrategy, EmailLowercaseStrategy, and BooleanInversionStrategy. Each has one implementation and only wraps a one-line expression.
Review whether these strategies are useful.
Requirements
Explain when to keep direct code, when to use a strategy, and what evidence would justify adding strategy classes later.
Check your work
The answer should not treat every conditional or expression as a pattern problem.
Show solution
These strategies are probably over-abstraction. If each has one implementation and only wraps a one-line expression, direct code or a small named method is clearer. The interface and class files add indirection without isolating meaningful variation.
Keep direct code when the behavior is simple, local, and has no separate dependencies or ownership. A helper method can still improve readability if the expression needs a name.
Introduce strategy later if the behavior gains real alternatives, separate dependencies, different release cadence, plugin requirements, or enough branching logic that focused tests would become clearer. For example, name formatting might become a strategy if the product supports locale-specific formatting rules with different dependencies and tests.
The review recommendation is to remove or avoid the strategy classes until variation exists. Patterns should respond to design pressure, not create it.