How to have custom repositories per application in a monorepo structure on Symfony

Home/PHP, Technical, Written by our colleagues/How to have custom repositories per application in a monorepo structure on Symfony

Imagine you have a couple of applications based on Symfony, using Doctrine as the ORM, and sharing a domain. In order to avoid having a shared repository for an entity with queries intended for other applications, you’d need custom repositories for each application for the entities in the domain. Here’s my take on this issue.

If you’re very busy and have no time to spare, you can skip straight to the code, but I think having some context will help you understand where and why this problem can appear and maybe even help you avoid it in the first place.

I’ve recently been working on a project for a company that was legally required to externalize one of its departments, so they were also required to break their currently operating application in kind of half.

We decided to create two new applications, both based on Symfony3 to meet those demands. As both applications still had a common domain, we ended up with a monorepo with several bundles in a shared package and an additional bundle for each application (let’s call them AppOneBundle and AppTwoBundle). One of the shared bundles was a DomainBundle. Responsible for all of our domain objects, doctrine configurations, serialization and some validation, it also provided shelter and a warm meal for our repositories.

Everything was going quite well, considering a deadline imposed by legal requirements that completely disregarded our estimations. Until one sunny morning, when I wanted to create a query using some constants from one of the classes from the AppOneBundle.

This helped me realise that we were having dependencies from both the AppOneBundle and the AppTwoBundle in the repo. That was in the DomainBundle. In the shared package. And that was a problem.

Our deployment strategy was to deploy one of the applications – which had dependencies on the common package via composer – so the other project bundle was not even installed in that application instance, but our repos had this dependency, so it was a big No-No.

I quite like the solution we came up with, and I wanted to share it with you. Also, after a little research, I didn’t find much about this on Its Majesty, the Internet, so my motives for writing this piece also include a posterity component.

So what did I do?

I created an AbstractRepositoryMetadataListener in the Domain bundle:

namespaceDomainBundle\EventListener;
useDoctrine\Common\EventSubscriber;
useDoctrine\ORM\Event\LoadClassMetadataEventArgs;
useDoctrine\ORM\Events;
/**
* Abstract Class RepositoryMetadataListener.
*
* To be extended in each application in order to define custom repository classes
* per application for entities from the domain.
*
* @copyright Evozon Systems SRL (http://www.evozon.com/)
* @author    Calin Bolea <‍calin.bolea@evozon.com‍>
*/
abstractclassAbstractRepositoryMetadataListener implementsEventSubscriber
{
   /**
    * @return array
    */
   publicfunctiongetSubscribedEvents(): array
   {
       return[
           Events::loadClassMetadata,
       ];
   }
   /**
    * @param LoadClassMetadataEventArgs $event
    */
   publicfunctionloadClassMetadata(LoadClassMetadataEventArgs $event): void
   {
       $classMetadata= $event->getClassMetadata();
       foreach($this->getCustomRepositories() as$class=> $repository) {
           if($classMetadata->getName() !== $class) {
               continue;
           }
           $classMetadata->setCustomRepositoryClass($repository);
       }
   }
   /**
    * Returns an associated array of entities and custom repository classes for each entity.
    *
    * @return array
    */
   abstractprotectedfunctiongetCustomRepositories(): array;
}

 

My goal here was to listen when the metadata was loaded and substitute the defined repo with the app repo. I achieved this via a protected function that each application can define with an associative array, where the key was the entity and the value was the custom repo. This allowed us to have something like this in each application bundle:

namespaceAppOneBundle\EventListener;
useAppOneBundle\Repository\SomeEntityRepository;
useAppOneBundle\Repository\SomeOtherEntityRepository;
useDoctrine\Common\EventSubscriber;
useDomainBundle\Entity\SomeEntity;
useDomainBundle\Entity\SomeOtherEntity
useDomainBundle\EventSubscriber\AbstractRepositoryMetadataListener;
/**
* Class RepositoryMetadataListener.
*
* @copyright Evozon Systems SRL (http://www.evozon.com/)
* @author    Calin Bolea <‍calin.bolea@evozon.com‍>
*/
classRepositoryMetadataListener extendsAbstractRepositoryMetadataListener implementsEventSubscriber
{
   /**
    * @return array
    */
   protectedfunctiongetCustomRepositories(): array
   {
       return[
           SomeEntity::class=> SomeEntityRepository::class,
           SomeOtherEntity::class=> SomeOtherEntityRepository::class,
       ];
   }
}

 

Same thing for the AppTwoBundle repo. Also, here are the service definitions:

app.event_listener.repository_metadata_listener:
   class: AppOneBundle\EventListener\RepositoryMetadataListener
   tags:
       - { name: doctrine.event_subscriber }
other_app.event_listener.repository_metadata_listener:
   class: AppTwoBundle\EventListener\RepositoryMetadataListener
   tags:
       - { name: doctrine.event_subscriber }

That was it, everything worked. At first, I wanted to leave the domain repo there with all the common queries and extend it in the repo apps. But, as I broke it down, I realised that all queries were written for one app or the other, but in the end, I didn’t even need a common repo, so that went out the window. What we were left with instead, was a clean repo for each application.

There are still a few things that I’d like to point out:

  • You are messing with the metadata of an entity outside of its configuration, which can lead to all kinds of trouble if you are not aware of it (especially in a big team where you can’t know what everyone is doing), so including a priority setting for your listeners, to ensure they run when you want them to run would not be a very bad idea. Maybe this would be needed if you use Gedmo or some other tool that changes the ORM mapping information – just fancy words for modifying metadata.
  • The configuration option for this case is not the most advanced, that’s why I was thinking of having a bundle configuration option to define repo services for entities, which would be a tad cooler, but this is as simple as it gets, and it’s not like I want to be able to configure on the go what repo is used by the application. Also, I was thinking of the EventSubscriber, with its getSubscribedEvents, which works so nicely and I always enjoy using, even when my listener listens to just one event.
  • Another drawback of this solution is that it will overwrite any predefined repo in the entity doctrine config, so you’ll end up with a defined repo that it’s not actually doing anything. Not very nice of us. A solution for this can be throwing an error when there’s a defined repo already, but having the getCustomRepositories modified so you can add a force option, so even if it does override something, it won’t care.
  • I like this because it’s also very easy to unit test, not having magic anywhere, not a lot of edge cases and no dependencies tends to make you a happy dev when you’re unit testing.
  • Lastly, I didn’t need a parent service, and I didn’t want to write one just because I extended some classes, I think the concepts of classes and services are quite different and shouldn’t be used one on one.

In conclusion, listening on the LoadMetadataEvent is a cool way to dynamically configure the mapping of your entities and works just as good as the static way of annotations, yml, xml, php. The entity manager quickly loads entity metadata and, in a regular day, you’ll have all metadatas loaded shortly after getting the entity manager. Just be careful of who or what else in your app does something similar, so you don’t break something.

 

by Calin Bolea

This article was originally published here.

Leave A Comment