Deserialization of data using an interface

Deserializing data to an implementation of an interface using JMS serializer because the implementation can be different from project to project.

As an agency, we strive to create solutions that can be easily reused while maintaining a high level of flexibility in case more custom work is needed. Our framework of choice is Symfony, so this means we have a set of bundles we use for most of our applications. A speed bump we came across was the fact that the JMS serializer could not handle interfaces, which is only logical.

What we need

$someModel = $serializer->deserialize($data, Acme\Model\SomeModelInterface::class, 'json');

This will not work because the serializer does not know what to do with an interface.

The exact use-case is an API controller, using the FOSRest bundle with a paramconverter that uses the request body converter from FOS. I won't go into further detail, it's not that important. But what is important is the fact we want the serializer to be able to handle deserialization of data knowing only the interface of the resulting object. This allows us to override these models on project level without copying entire controllers (or services). We did not immediately find a solution to our problem on the internet, so here we are.

What we did

First thing we need is some sort of mapping between the interface and the model that implements this. We looked into using the Symfony service container but we quickly shot this down because, let's face it, registering data models as services is not ok. So we implemented a simple mapper that takes data from configuration to register the mapping.

namespace Acme\AppBundle\Service\Mapping;

class InterfaceImplementationMapper
{
    private $interfaceImplementations = [];

    public function __construct(array $interfaceImplementations)
    {
        $this->interfaceImplementations = $interfaceImplementations;
    }

    public function mapInterface(string $interface): string
    {
        if (array_key_exists($interface, $this->interfaceImplementations)) {
            return $this->interfaceImplementations[$interface];
        }

        return $interface;
    }
}

As you can see, this is nothing more than a registry with all known interfaces and their implementations. The implementation of this mapper is completely up to you, you could scan certain directories or implement annotations or even hardcode the mapping (shame on you).

Next up, we need this to work with the JMS serializer. Luckily we can hook in to the pre deserialize event of the JMS serializer. Here we can intercept the type the serializer wants to deserialize, an ideal situation to use our mapper we created and replace the interface with an implementation.

namespace Acme\AppBundle\Event\EventSubscriber;

use JMS\Serializer\EventDispatcher\Events;
use JMS\Serializer\EventDispatcher\EventSubscriberInterface;
use JMS\Serializer\EventDispatcher\PreDeserializeEvent;
use Acme\AppBundle\Service\Mapping\InterfaceImplementationMapper;

class SerializerSubscriber implements EventSubscriberInterface
{
    /**
     * @var InterfaceImplementationMapper
     */
    private $interfaceImplementationMapper;

    public function __construct(InterfaceImplementationMapper $interfaceImplementationMapper)
    {
        $this->interfaceImplementationMapper = $interfaceImplementationMapper;
    }

    public static function getSubscribedEvents(): array
    {
        return [
              ['event' => Events::PRE_DESERIALIZE, 'method' => 'replaceInterfaceWithImplementation'],
        ];
    }

    public function replaceInterfaceWithImplementation(PreDeserializeEvent $event): void
    {
        $type = $event->getType();

        $event->setType(
            $this->interfaceImplementationMapper->mapInterface($type['name']),
            $type['params']
        );
    }
}
Don't forget to register the event subscriber! JMS does not use the Symfony event component, so your autowiring won't pick it up. You will need to register it manually and tag it correctly.
Acme\AppBundle\Event\EventSubscriber\SerializerSubscriber:
    public: true
    tags:
        - { name: jms_serializer.event_subscriber }

And done!

We now provide default implementations for these interfaces in our bundles, but we can override certain mappings on project level. We don't need to copy over entire controllers or services just to use a different implementation for a certain interface. But, be warned, it adds more "magic" to your application, so I advise to document this properly for your teammates!