Design Patterns - Decorator
Continuing the design patterns in PHP posts, we are going to focus today on the Decorator pattern and how we are using it in Phalcon.
The pattern in one paragraph
A decorator is an object that implements an interface and holds another object of that same interface. Callers see the interface. The decorator adds behavior before or after the call, then gives the work to the object it wraps. Because the outside shape does not change, you can stack decorators, and the code that uses the service does not know how many layers are there.
Why we use it
- The class stays closed. You add logging, caching, retries, or metrics without an edit to the class that does the real work.
- No subclass explosion. Three cross-cutting concerns give you three small decorators, not eight subclasses for each combination.
- Composition at the edges. Each layer is small, has one job, and is easy to test on its own with a fake inner object.
- The wiring is the only place that knows. Callers keep asking for the interface. Only the container knows which layers are active, so a layer can be added in production and left out in the test suite.
Where do we see this in Phacon
There are a few spots in Phalcon where decorators are used, although not named. The glaring one is of course the DataMapper\Pdo\Connection\Decorated, and there are plenty of components in our new Phalcon\ADR namespace. The one that we will be focusing on today is the Phalcon\Container\Container which has a post-build extender hook:
public function extend(string $name, callable $callableObject): voidAn extender is a callable that receives the built service and the container, and returns the object that the container will hand out. Return the same object and you get a mutation. Return a wrapper and you get a decorator.
The Phalcon\ADR\Application class exposes the same for ADR applications:
public function extend(string $name, Closure $extender): staticWhat happens inside
The extenders are stored on the service definition (Phalcon\Container\Definition\ServiceDefinition), and the build loop is short:
foreach ($this->extenders as $extender) {
$instance = $extender($instance, $container);
}
return $instance;Three points follow from those three lines:
- The extenders run after the constructor, so the object is complete before the first layer sees it.
- They run in registration order. The first extender is the innermost wrapper, the last extender is the outermost.
- The return value replaces the instance. An extender that forgets its
returnstatement destroys the service.
A worked example
Our domain is Invoices. The application asks for one from a repository, and the repository talks to the database. We want a cache layer and an audit log, but we do not want either concern inside the PDO class. Putting them all in one spot is certainly convenient and easy but we will pay for it (dearly) later during maintenance. As such, we want to keep these concerns separate.
The contract
namespace App\Domain\Invoice;
interface InvoiceRepository
{
public function findById(int $invoiceId): ?Invoice;
}The real work
namespace App\Domain\Invoice;
use Phalcon\DataMapper\Pdo\Connection;
class PdoInvoiceRepository implements InvoiceRepository
{
public function __construct(
protected Connection $connection
) {
}
public function findById(int $invoiceId): ?Invoice
{
$row = $this->connection->fetchOne(
'SELECT * FROM co_invoices WHERE inv_id = :id',
['id' => $invoiceId]
);
return empty($row) ? null : Invoice::fromArray($row);
}
}The cache layer
namespace App\Domain\Invoice;
use Phalcon\Cache\Cache;
class CachedInvoiceRepository implements InvoiceRepository
{
public function __construct(
protected InvoiceRepository $repository,
protected Cache $cache
) {
}
public function findById(int $invoiceId): ?Invoice
{
$key = 'invoice-' . $invoiceId;
if (true === $this->cache->has($key)) {
return $this->cache->get($key);
}
$invoice = $this->repository->findById($invoiceId);
if (null !== $invoice) {
$this->cache->set($key, $invoice, 300);
}
return $invoice;
}
}The audit layer
namespace App\Domain\Invoice;
use Phalcon\Logger\LoggerInterface;
class LoggedInvoiceRepository implements InvoiceRepository
{
public function __construct(
protected InvoiceRepository $repository,
protected LoggerInterface $logger
) {
}
public function findById(int $invoiceId): ?Invoice
{
$this->logger->info('Invoice read: ' . $invoiceId);
return $this->repository->findById($invoiceId);
}
}With this approach, we separate concerns cleanly and each class is a handful of lines, has one reason to change if needed but most importantly knows nothing about the other two.
The wiring
namespace App\Providers;
use App\Domain\Invoice\CachedInvoiceRepository;
use App\Domain\Invoice\InvoiceRepository;
use App\Domain\Invoice\LoggedInvoiceRepository;
use App\Domain\Invoice\PdoInvoiceRepository;
use Phalcon\Cache\Cache;
use Phalcon\Contracts\Container\Service\Collection;
use Phalcon\Contracts\Container\Service\Provider;
use Phalcon\Logger\LoggerInterface;
class InvoiceProvider implements Provider
{
public function provide(Collection $services): void
{
/**
* This is where we tell our container, whenever I ask you for
* `InvoiceRepository::class` (interface), give me back the
* `PdoInvoiceRepository` (concrete)
*
* Order will be LIFO (Last one In, First one Out)
*/
$services->bind(
InvoiceRepository::class,
PdoInvoiceRepository::class
);
/**
* CachedInvoiceRepository is "extended" on top (so to speak) of the
* PdoInvoiceRepository
*/
$services->extend(
InvoiceRepository::class,
static function (object $inner, object $container): object {
return new CachedInvoiceRepository(
$inner,
$container->get(Cache::class)
);
}
);
/**
* LoggedInvoiceRepository is "extended" on top (so to speak) of the
* CachedInvoiceRepository
*/
$services->extend(
InvoiceRepository::class,
static function (object $inner, object $container): object {
return new LoggedInvoiceRepository(
$inner,
$container->get(LoggerInterface::class)
);
}
);
}
}The same two layers can be attached on the definition itself, which reads well when the service is registered and decorated in one place:
$services
->bind(InvoiceRepository::class, PdoInvoiceRepository::class)
->addExtender($cacheLayer)
->addExtender($auditLayer)
;How do we use this in our code?
$repository = $container->get(InvoiceRepository::class);
$invoice = $repository->findById(42);The call goes through:
- the log layer, where data is logged (according to our class)
- then the cache layer where we search if there is a cached resultset and return it
- finally through the database only on a cache miss.
The controller, the action, and the tests keep the same one line. We can comment out or remove any of the two extend() calls in the provider above, and there is no change in our code as far as data retrieval is concerned.
Rules and gotchas
- Decorate before the first resolution.
extend()throwsCannotExtendResolvedwhen the container already holds an instance of that service, andaddExtender()throwsFrozenDefinitionwhen the definition was already frozen by a build. Providers run before the firstget(), so this is a non-issue in normal wiring and a clear error when something resolves a service too early. - The service must exist.
extend()throwsServiceNotFoundwhen there is no definition for the name. Register first, decorate second. - Order is the stack order. The last extender you add is the outermost layer, so it is the first to see the call.
- Always return an object. The return value becomes the service.
- Aliases are resolved first.
extend()runs the name through the alias table, so the interface name, the concrete name, and the short alias all reach the same definition. - Lifetimes still apply. A
SINGLETONservice is wrapped once and cached. ATRANSIENTservice is rebuilt per request, and the extenders run again for each new instance. - Keep the interface. A decorator that adds public methods is no longer a decorator - the callers now need to know which layer they hold.
What it costs
The example has three layers, so findById() exists in three classes and the query runs two hops away from the caller. That is not free:
- Delegation boilerplate. One method is cheap. A repository with twelve methods makes each decorator forward twelve methods, and a thirteenth method means an edit in every layer.
- Traceability.
$container->get(InvoiceRepositoryInterface::class)no longer tells you what you hold. A stack trace reads log layer, then cache layer, then PDO, and the provider is the only place that explains that order. - Silent ordering bugs. Swap the two
extend()calls and the audit entry is written only on a cache miss. Nothing fails. The behavior just changes.
Rule of thumb: decorate narrow interfaces, one to three methods. For a wide interface, Phalcon\Events\Manager gives you the same hook without the forwarding code.
When not to reach for it
- The behavior belongs to the object itself. A rule that is part of the domain belongs in the domain class, not in a wrapper.
- One layer needs the internals of another. That is a sign the split is in the wrong place.
- Events are enough. For a hook around a well known point in the framework,
Phalcon\Events\Manageris the lighter tool.
Elsewhere in the framework
The pattern is not only an application-level trick:
Phalcon\DataMapper\Pdo\Connection\Decoratedtakes an existingPDOinstance and decorates it with the extended connection methods.- The ADR middleware chain is a decorator chain over a handler:
Phalcon\ADR\PipelineandPhalcon\ADR\EventfulHandlerboth implement theHandlercontract and wrap the next step.
Takeaway
The container extender is a two-line hook with a large effect. Write small classes that do one thing, keep them behind the interface, and let the container decide which layers your application runs with. The business code never changes and never knows.