How to flip Doctrine ODM repositories to Services

While working with legacy projects, I often encountered this anti-pattern of misusing repositories. Instead of easy-to-inject service, projects are locked into a service locator.

This makes code hard to upgrade and locks your project heavily to the Doctrine ODM packages. And there are plenty of them. Each extra package bites off its share of upgrade costs.

Today, we look at how to refactor the ODM service locator to independent services and separate your project from ODM. We also get a few advantages in strict type coverage.

What do I mean by service locator?

final class ProductController
{
    // ...

    public function __construct(Container $container)
    {
        $this->gptClient = $container->get(GptAPIClient);
        $this->dynamicPriceResolver = $container->get(DynamicPriceResolver);
    }
}

A service locator is a class that contains all the services. It's an anti-pattern that leaks too much and makes the service able to do everything. It used to be the way to use containers in the 2010s before we discovered dependency injection in PHP. Unfortunately, it's still in official docs on "First Steps" tutorial.

Nowadays, we use constructor injection to make service design clean, minimalistic, and neat.


Yet, ODM ships with such a service locator out of the box:

final class ProductController
{
    // ..


    public function productDetail()
    {
        $productRepository = $this->documentManager->getRepository(Product::class);
        $categoryRepository = $this->documentManager->getRepository(Category::class);

        // ...
    }
}

The documentManager is a service locator for Doctrine ODM. If we inject this service everywhere, we can get any repository we want - existing or on-the-fly. That's why legacy projects are filled with documentManager in every single possible place:


What does the code actually do?


Now:

With great power comes great responsibility
lot of wasted time and money to keep the code up to date

We should never use Doctrine\ODM\MongoDB\DocumentManager outside repository services.

The Painful Maintenance cost

The best practice nowadays is to have single service that handles the task we give it to - whether it's data transformation, calling external API, or sorting data based on user input filter:

final class ProductRepository
{
    private $repository;

    public function __construct(private DocumentManager $documentManager)
    {
        // the only allowed call of getRepository()
        $this->repository = $documentManager->getRepository(Product::class);
    }

    public function findByName(string $name): ?Product
    {
        $this->repository->findOneBy(['name' => $name]);
    }
}

Why is this ideal design?


Rule of thumb: Your controllers and services should not know about the database layer you use. Only repositories should care about that.


If we upgrade ODM 1 to 2, then to 2 to 3, 3 to 4..., we don't have to change anything. The cost of such an upgrade is the time to change composer.json, run composer update, and fix bundle configuration here and there.


How much does the "service locator" upgrade cost?

Let's list all the work we have to do:

...to sum up: a lot!


We have to get rid of these obstacles. Only that way, our next upgrade will be close to $ 0.

The service pattern is also more adaptable in case your framework DI container changes (and it will).

4 steps to Independent Repositories (and cheap upgrade)

Doctrine ODM holds the service locator pattern tight and may discourage you from moving on. But if we apply the following steps, it will go down one by one like snowflakes in a sunbeam.

1. Cleanup entity

 use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;

 /**
  * @Document(
- *     repositoryClass="App\Repository\ProductRepository"
  * )
  */
 class Product

2. Separate your Repository from /vendor

+use App\Entity\Product;
 use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
+use Doctrine\ODM\MongoDB\DocumentManager;

-final class ProductRepository extends DocumentRepository
+final class ProductRepository
 {
+    private DocumentRepository $repository;
+
+    public function __construct(private DocumentManager $documentManager)
+    {
+        $this->repository = $documentManager->getRepository(Product::class);
+    }

     // ...
 }

3. Add find*() method you need, with type declarations

Instead of docblock + DocumentRepository magic, we can now use actual PHP code and native type declarations:

-/**
- * @method Product|null find(string $id)
- */
 final class ProductRepository
 {
     // ...

+    public function find(string $id): ?Product
+    {
+        return $this->repository->find($id);
+    }
 }

Now, we've just improved our IDE, Rector, and PHPStan support. Also, if we pass an integer where the string should be $this->productRepository->find(1), we'll get an error report.

4. Replace ->getRepository(...) with service injection

-use Doctrine\ODM\MongoDB\DocumentManager;

 final class ProductController
 {
     public function __construct(
-       private DocumentManager $documentManager,
+       private ProductRepository $productRepository,
     ) {
     }

     public function productDetail(string $id)
     {
-        $productRepository = $this->documentManager->getRepository(Product::class);
-        $product = $productRepository->get($id);
+        $product = $this->productRepository->get($id);

        // ...
     }
 }

That's it!


Rule of a thumb: Handle max. 1 Repository per PR

It's tempting to do a big bang jump and refactor all the repositories at once. But it's most likely that your project has more than 5 repositories used throughout most of the codebase.

To make this change happen safely and fast, handle only one repository per pull request. Change it, do all 4 steps, and create a pull request. Update tests and make CI pass. Merge.

Rinse and repeat.


Finish this upgrade challenge, and you'll get a sweet reward:


Happy coding!




Do you learn from my contents or use open-source packages like Rector every day?
Consider supporting it on GitHub Sponsors. I'd really appreciate it!