Design Patterns And Data Architecture
Decorator Pattern
The decorator pattern wraps an object with another object that implements the same interface. The wrapper adds behavior before, after, or around the original call while preserving the caller's contract. In PHP applications, decorators are common for caching, logging, metrics, retries, tracing, authorization checks, feature flags, rate limiting, fallbacks, and small policy additions around infrastructure clients.
A decorator is useful when the core behavior is already correct, but the application needs extra behavior without editing that core class. For example, an exchange-rate provider may already know how to call a remote API. A caching decorator can store results around it. A logging decorator can record failures around it. The controller still depends on ExchangeRateProvider; it does not need to know whether caching or logging is present.
The key requirement is that the decorator and the wrapped object share the same interface. If the wrapper exposes a different interface, it may be an adapter, facade, or separate service instead. If the wrapper changes the meaning of the operation, it may be a dangerous decoration that surprises callers.
The Shape Of A Decorator
A decorator usually has:
- an interface used by callers
- a concrete implementation that performs the original behavior
- a decorator implementation that also implements the interface
- an
innerobject passed into the decorator
The decorator calls the inner object at the appropriate time. It may return the inner result unchanged, transform safe metadata, cache the result, retry failures, record metrics, or reject calls before they reach the inner object.
The interface should describe the stable behavior. The decorator should preserve that behavior. If InvoiceRenderer::render() returns PDF bytes, a metrics decorator should still return the same PDF bytes. If a caching decorator returns stale data, the interface or documentation should make that freshness policy explicit.
A Caching Decorator
This example wraps an exchange-rate provider. The fixed provider is the original implementation. The caching provider stores results by currency.
<?php
declare(strict_types=1);
interface ExchangeRateProvider
{
public function rateFor(string $currency): float;
}
final class FixedExchangeRateProvider implements ExchangeRateProvider
{
public function rateFor(string $currency): float
{
return match ($currency) {
'EUR' => 1.17,
'USD' => 1.25,
default => throw new InvalidArgumentException('Unsupported currency.'),
};
}
}
final class CachingExchangeRateProvider implements ExchangeRateProvider
{
/** @var array<string, float> */
private array $cache = [];
public function __construct(
private ExchangeRateProvider $inner,
) {
}
public function rateFor(string $currency): float
{
if (!array_key_exists($currency, $this->cache)) {
$this->cache[$currency] = $this->inner->rateFor($currency);
}
return $this->cache[$currency];
}
}
$rates = new CachingExchangeRateProvider(new FixedExchangeRateProvider());
echo $rates->rateFor('EUR') . PHP_EOL;
echo $rates->rateFor('EUR') . PHP_EOL;
// Prints:
// 1.17
// 1.17
The caller sees only the ExchangeRateProvider interface. It does not know whether the result came from the fixed provider or the decorator's cache.
This in-memory cache is only a teaching example. Production cache decorators need expiry, invalidation, size limits, error handling, and process-lifetime awareness.
Decoration Versus Inheritance
Decorators use composition. A decorator receives an object and wraps it. Inheritance uses a subclass to change or extend behavior. Composition is often safer in PHP application code because it avoids deep class hierarchies and lets behavior be combined at configuration time.
A logging decorator and caching decorator can wrap the same provider in either order. With inheritance, combining two optional behaviors can lead to many subclasses or awkward base classes. Decorators keep optional behavior separate.
Inheritance can still be appropriate when a framework gives a documented base class with extension points. Do not fight the framework. But when the variation is optional behavior around an interface, a decorator is usually clearer.
Order Matters
Decorator order can change behavior. Consider a provider wrapped with caching and logging. If logging wraps caching, the log sees every caller request, including cache hits. If caching wraps logging, the log sees only cache misses that reach the inner provider.
Neither order is universally correct. Choose the order that matches the operational question. For request metrics, you may want to count every call. For remote provider metrics, you may want to count only calls that actually leave the process.
Retry order also matters. Retrying around a cache may retry cache failures and provider failures. Caching around a retrying provider may store only the final successful result. Write down the intended order when stacking decorators, and test it.
Decorators And Dependency Injection
Decorators are often wired in the dependency injection container. The application asks for ExchangeRateProvider, and configuration decides whether the concrete service is wrapped by cache, metrics, tracing, or retry decorators.
This keeps controllers and use cases clean. They depend on the interface and do not manually construct the decorator stack. It also makes behavior easier to enable or disable by environment.
Avoid hiding important product behavior in container configuration with no tests. If a decorator enforces authorization, idempotency, or billing limits, make sure the test suite proves the decorated service is the one used at runtime. Container wiring is part of the behavior.
Caching Decorators Need Policy
A caching decorator is one of the most common forms, but it is easy to get wrong. The decorator must define cache key, TTL, invalidation, serialization, stampede behavior, and failure policy.
For exchange rates, a stale value might be acceptable for a short period. For account permissions, stale data can become a security problem. For inventory, stale data can oversell stock. Do not apply a generic cache decorator without understanding the domain's freshness requirements.
The decorator should also avoid unbounded memory growth. Long-running PHP workers and application servers can keep decorator instances alive. An array cache that is harmless in a short CLI example can become a leak in a worker process.
Retry Decorators Need Idempotency
A retry decorator wraps an operation and tries again after temporary failure. This is useful around HTTP clients, queue publishers, and provider APIs. It is dangerous around side effects that are not idempotent.
Retrying a read request after a timeout is usually safer than retrying a payment capture. If the first payment request succeeded but the response timed out, a retry can duplicate the charge unless the operation uses an idempotency key or provider guarantee.
A retry decorator should classify failures, limit attempts, use backoff, and preserve cancellation or timeout budgets. It should not retry validation errors, authentication failures, permission denials, or business rejections.
Logging, Metrics, And Tracing Decorators
Operational decorators should add evidence without changing business behavior. A logging decorator can record operation name, duration, success or failure category, and correlation ID. A metrics decorator can increment counters and observe durations. A tracing decorator can start spans around provider calls.
Be careful with labels and payloads. Do not log secrets, tokens, full request bodies, payment details, or unbounded user input. Metrics labels should use stable names, not raw URLs, user IDs, or exception messages.
Operational decorators are valuable because they keep instrumentation out of domain code. The domain service asks for a profile or sends a notification; the decorator records how long the provider call took.
Authorization Decorators
Authorization can be implemented as a decorator when the permission check belongs around a specific application port. For example, a DocumentDownloader decorator can check whether the current actor may read the document before it calls the inner downloader. The caller still uses DocumentDownloader, while the decorated implementation enforces access at the boundary.
Use this carefully. Authorization decorators should receive explicit actor and resource context through the method contract or a well-defined request context. They should not pull user state from hidden globals, and they should not replace route-level authorization where the framework already provides a clearer mechanism. If a denied call must be logged, the decorator can record the denial with a stable reason code before throwing a permission exception.
Common Failure Modes
One failure mode is changing semantics silently. A decorator that catches all exceptions and returns a default value may make callers think the inner operation succeeded. If fallback is intended, the interface should communicate fallback behavior or the result should carry a source and freshness indicator.
Another failure mode is order confusion. A cache, retry, timeout, and metrics stack can behave very differently depending on order. Review the stack as one design, not as independent wrappers.
A third failure mode is using decorators to hide bad dependencies. Wrapping a slow provider with a cache may reduce calls, but it does not remove the need for timeouts, failure handling, and capacity planning.
A fourth failure mode is decorating the wrong abstraction. If the interface is too broad, the decorator may have to special-case unrelated methods. Split the interface or decorate a narrower boundary.
Review Criteria
When reviewing a decorator, ask what behavior it adds and whether the interface meaning remains true. Check that the decorator has one focused reason to exist. A CachingLoggingRetryingProvider class mixes several policies and is harder to reason about than separate decorators with an explicit order.
Check failure behavior. Does the decorator rethrow, wrap, retry, cache, fall back, or suppress? Is that visible to the caller where it needs to be? Check state lifetime. Does the decorator store per-request data in a long-lived object? Does it clear buffers and avoid unbounded arrays?
Check tests. The inner implementation should have its own tests. The decorator should have tests proving the added behavior. The configured stack should have at least one test or integration check proving the application receives the decorated service.
What To Check
Before moving on, make sure you can:
- explain decorator as same-interface wrapping
- distinguish decorators from adapters and inheritance
- identify useful decorators for caching, logging, metrics, retries, tracing, and authorization
- explain why decorator order matters
- define cache freshness, retry, and failure policies explicitly
- avoid unbounded state in long-running PHP processes
- test both the decorator and the configured decorator stack
After this lesson, you should be able to take a cross-cutting behavior in a PHP application and decide whether it belongs in the original class, a middleware layer, framework configuration, or a focused decorator around a stable interface.
Practice
Task: Add A Caching Decorator
Design a caching decorator.
Requirements
Name the decorator, describe how it wraps the inner provider, define cache key and freshness policy, and explain what the caller should know.
Check your work
The decorator should implement the same interface as the provider and should not change the caller's method call.
Show solution
The freshness policy should be explicit. For example, product prices may use a five-minute TTL if the product team accepts that delay. If prices must change immediately during checkout, the decorator should not be used there or must include invalidation tied to price updates.
The caller still depends on ProductPriceProvider and calls priceFor(). It should not need to know whether a cache exists, but the application's architecture documentation should record the freshness policy because it affects product behavior.
Task: Stack Decorators
A team wants caching, provider-call metrics, and retries around an exchange-rate provider.
Choose an order for the decorators and explain what that order measures and retries.
Requirements
Mention cache hits, cache misses, remote provider calls, retry safety, and one test that proves the order.
Check your work
The answer should not claim decorator order is irrelevant.
Show solution
A practical order is CachingExchangeRateProvider wrapping MetricsExchangeRateProvider wrapping RetryingExchangeRateProvider wrapping the remote provider. With that order, cache hits return before metrics and retries around the remote provider run. The provider-call metric measures only calls that actually miss the cache and reach the remote provider.
Retries happen only for remote provider failures, not cache reads. That is reasonable if the cache itself is local or has separate failure policy. If the team wants request-level metrics including cache hits, add a separate outer metrics decorator or measure at the caller boundary.
One test should call the same currency twice and assert that the remote provider was called once, the provider-call metric was recorded once, and the second call returned from cache. Another test should make the remote provider fail temporarily and assert that retry attempts happen only on the cache miss path.
Task: Review Decorator Fit
A developer proposes SafePaymentDecorator that catches every payment exception and returns false so checkout can continue without crashing.
Review whether this is a good decorator.
Requirements
Discuss interface semantics, payment side effects, retry/idempotency, user-visible behavior, and better failure handling.
Check your work
The answer should not hide payment uncertainty behind a boolean.
Show solution
This is not a good decorator. Catching every payment exception and returning false changes the meaning of the payment interface. The caller cannot distinguish card decline, provider timeout, duplicate request risk, invalid configuration, authentication failure, or internal defect.
Payment operations have side effects. If a timeout happens after the provider captured money, returning false may encourage checkout to try again without idempotency and charge the customer twice. The decorator must not hide ambiguous outcomes.
A better design maps failures into typed application outcomes such as declined, temporary failure, configuration failure, and unknown outcome. Retrying should happen only when the operation is idempotent or protected by a provider idempotency key. Checkout should show the user an appropriate message and stop fulfillment until payment state is known.
Operational logging can be added with a decorator, but failure semantics must remain visible to the application.