Skip to content

How to enhance final classes from open source code with decorator pattern

Published: at 07:00 AMSuggest Changes

In many open source repositories, especially those with just a few maintainers, you’ll often find classes declared as final. This approach allows maintainers to make changes without worrying too much about backward compatibility. However, it can also make the code a bit challenging to modify. The good news is that it’s not as difficult as it seems! Since these classes usually implement interfaces and utilize composition, we can tap into the advantages of the Decorator pattern.

Table of contents

Open Table of contents

Utilizing an open source project

In my search for an open source project with a significant user base, I came across EasyAdminBundle, which boasts over 18,000 users and 4,000 stars on GitHub. For this example, I’ll be focusing on the AdminUrlGenerator.php file. We will implement a dispatch event that gets triggered when the generate method is called.

Building a decorator class

Since AdminUrlGenerator.php is a final class, we cannot extend it or override the generate method. In this case, a suitable alternative is to use a decorator.

<?php

declare(strict_types = 1);

namespace App\Decorator;

use App\Event\UrlGeneratedEvent;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

#[AsDecorator(decorates: AdminUrlGenerator::class)]
final class AdminUrlGeneratorDecorator implements AdminUrlGeneratorInterface
{
    public function __construct(
        #[AutowireDecorated]
        private AdminUrlGeneratorInterface $adminUrlGenerator,
        private EventDispatcherInterface $event,
    ) {
    }

    public function generateUrl(): string
    {
          $url = $this->adminUrlGenerator->generate();
          $this->event->dispatch(new UrlGeneratedEvent($url));

          return $url;
    }

    public function setRoute(string $routeName, array $routeParameters = []): self
    {
        $this->adminUrlGenerator->setRoute($routeName, $routeParameters);

        return $this;
    }

    public function get(string $paramName): mixed
    {
        return $this->adminUrlGenerator->get($paramName);
    }

    //....
}

Analyzing the AdminUrlGeneratorDecorator

#[AsDecorator(decorates: AdminUrlGenerator::class)]

//...

#[AutowireDecorated]

Since EasyAdminBundle is built on the Symfony Framework, it provides a way to decorate classes using Symfony’s built-in features. In the decorates parameter, you specify the class you want to decorate, which allows Symfony to inject AdminUrlGeneratorDecorator in place of the original AdminUrlGenerator.

By adding the #[AutowireDecorated] attribute above the $adminUrlGenerator property, Symfony automatically injects the original AdminUrlGenerator class into that parameter. This allows the decorator to use the functionality of the original class and extend it as needed.

<?php

final class AdminUrlGeneratorDecorator implements AdminContextProvider

Here, we are creating the AdminUrlGeneratorDecorator class and implementing the same interface as AdminUrlGenerator, which is the AdminContextProvider interface. This is crucial because, by doing so, Symfony will inject our AdminUrlGeneratorDecorator class everywhere the AdminContextProvider interface is used. This allows us to modify or extend the behavior of AdminUrlGenerator without changing its core functionality, ensuring compatibility across the entire system.

<?php

    public function __construct(
      #[AutowireDecorated]
      private AdminUrlGenerator $adminUrlGenerator,
      private EventDispatcherInterface $event,
    ) {
    }
<?php
    public function generateUrl(): string
    {
          $url = $this->adminUrlGenerator->generate();
          $this->event->dispatch(new UrlGeneratedEvent($url));

          return $url;
    }

This is the method we want to modify.

In the first line, we call the original method to generate the URL.

The second line dispatches our custom event, passing the generated URL as an argument

Finally, we return the URL generated by the original method, ensuring that the decorator retains the original behavior while adding our enhancements.

<?php
    public function setRoute(string $routeName, array $routeParameters = []): self
    {
        $this->adminUrlGenerator->setRoute($routeName, $routeParameters);

        return $this;
    }

    public function get(string $paramName): mixed
    {
        return $this->adminUrlGenerator->get($paramName);
    }

    //....

You might be wondering, “Why do I need to implement these methods if I only want to change the generate method?”

The reason is that the AdminContextProvider interface requires us to implement all its defined methods. In this case, we must implement these methods and call the original implementations, as demonstrated with the setRoute and get methods.

For simplicity, I haven’t included all methods in this example, but it’s important to note that you must implement all of them.

Complete Implementation

//src/Decorator/AdminUrlGeneratorDecorator.php
<?php

declare(strict_types = 1);

namespace App\Decorator;

use App\Event\UrlGeneratedEvent;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

#[AsDecorator(decorates: AdminUrlGenerator::class)]
class AdminUrlGeneratorDecorator implements AdminUrlGeneratorInterface
{
// ...
//src/Event/UrlGeneratedEvent.php
<?php

declare(strict_types=1);

namespace App\Event;

class UrlGeneratedEvent
{
    public function __construct(private string $url)
    {
    }

    public function getUrl(): string
    {
        return $this->url;
    }
}
//src/Listener/UrlGeneratedListener.php
<?php

declare(strict_types=1);

namespace App\Listener;

use App\Event\UrlGeneratedEvent;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener]
class UrlGeneratedListener
{
    public function __construct(private LoggerInterface $logger)
    {
    }

    public function __invoke(UrlGeneratedEvent $event)
    {
        $this->logger->info(
            'New url generated.',
            [
                'url' => $event->getUrl(),
                'eventClass' => get_class($event),
                'listenerClass' => get_class($this),
            ]
        );
    }
}

The AdminContextProvider interface is utilized in several classes, including MenuFactory.php, which is called when loading an admin page that contains a menu.

When you navigate to the /admin page, you will notice an info log created by our listener, indicating that our decorator and event system are functioning as intended.

You can check the screenshot below. Image with symfony profile page on log section

Conclusion

In conclusion, the Decorator Pattern provides a powerful way to extend and modify the functionality of existing classes without altering their core implementation. By leveraging the AdminContextProvider interface, we were able to create a flexible and reusable solution that enhances our application’s capabilities.

I hope this post has provided valuable insights into using decorators in open-source projects. If you have any questions or thoughts, feel free to leave a comment below.

Happy coding!


Previous Post
Get Frontend Theme Updates Using Git: Part 2
Next Post
A beginner's guide to setting up PHP on your computer