Doctrine ORM & Repository Pattern

Jak i dlaczego warto przykryć warstwą abstrakcji Doctrine Repository.

Abstrakcyjne repozytorium udostępniane poprzez Doctrine ORM jest bardzo atrakcyjne pod względem dostarczonej funkcjonalności. Wystarczy wywołać metodę getRepository na obiekcie Entity Managera aby otrzymać obiekt repozytorium Doctrine\ORM\EntityRepository implementujący dwa interfejsy:

Doctrine\Common\Persistence\ObjectRepository

oraz

Doctrine\Common\Collections\Selectable

Przykład utworzenia repozytorium na podstawie UserEntity:

<?php

$userRepository = $entityManager->getRepository(UserEntity::class);

Autorzy Doctrine ORM zaimplementowali nieco więcej metod niż te wymuszone w.w interfejsami. Wśród nich znajduje się jednak prawdziwa perełka – metoda magiczna __call(). Dzięki niej, otrzymujemy możliwość używania niezdefiniowanego nigdzie interfejsu – chyba, że mamy na myśli schemat tabeli w bazie danych (ilość udostępnionych metod = ilość kolumn w tabeli bazodanowej * 2). Konkretniej, implementacja umożliwia nam wywoływanie nieistniejących metody w oparciu o schemat: findOneBy[ColumnName] oraz findBy[ColumnName]. Rozwiązanie wydaje się bardzo atrakcyjne dla programisty, „od tak” otrzymujemy możliwość wyszukiwania encji po wskazanym kryterium.

Okazuje się jednak, że z początkowych publicznych metod, których doliczyłem się w klasie EntityRepository dokładnie 15, nasz końcowy obiekt repozytorium utworzony dla konkretnej encji (zawierającej 10 właściwości) zawiera ich teoretycznie 35 ( 15 + (10*2)). Teoretycznie bo 20 z nich jest wynikiem implementacji metody __call(). Podsumowując: 20 nigdzie nie zdefiniowanych metod – obsługiwanych w magiczny sposób.

Często spotykam się z mniej więcej takimi rozwiązaniami:

<?php

namespace Services;

use Exception\DuplicatedUserEmailException;
use Entity\UserEntity;
use Doctrine\ORM\EntityRepository;

class CreateNewUserService
{
    private $userRepository;

    public function __construct(EntityRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    public function create(string $email)
    {
        // ...

        if ($this->userRepository->findByAddessEmail($email)) {
            throw DuplicatedUserEmailException();
        }

        $this->userRepository->add(new UserEntity($email));
    }
}
<?php

$entityManager = \Doctrine\ORM\EntityManager::create();
$userRepository = $entityManager->getRepository(\Entity\UserEntity::class);
$createNewUserService = new \Services\CreateNewUserService($userRepository);

$createNewUserService->create('[email protected]');

Wykorzystana w przykładzie metoda findByAddessEmail() jest wynikiem w.w magii Doctrine ORM. Dodatkowo jesteśmy zależnieni od zewnętrznego interfejsu Doctrine\ORM\EntityRepository z niewiadomą ilością wywołań (w naszym kodzie) niezdefiniowanych metod interfejsu. Próba podmiany implementacji repozytorium, to po prostu walka z kodem po omacku.

Podsumowując – bezpośrednie wykorzystywanie takiego obiektu repozytorium, bez przykrycia go warstwą własnej abstrakcji, jest dla mnie nie do przyjęcia ze względu na:

  • brak sprecyzowanego komunikatu – z jakiego konkretnego repozytorium chcemy skorzystać, możemy oczekiwać repozytorium encji użytkownika, a w rzeczywistości zostanie nam przekazane repozytorium komentarzy, oczekujemy tutaj bardzo generycznego obiektu klasy EntityRepository,
  • chęć korzystania z kontraktów (interfejsów) jest tutaj dość kłopotliwa, ponieważ konkretna implementacja dostarcza nam bardziej wzbogacone API (więcej metod) niż ta zdefiniowana w interfejsach,
  • korzystanie z magicznych rozwiązań utrudnia w tym wypadku podmianę implementacji, o ile w ściśle zdefiniowanym interfejsie wiemy jakie metody musimy zaimplementować o tyle tutaj, bez testów jednostkowych lub/i przeglądu kodu nie jesteśmy w stanie tego wykonać.

Warstwa abstrakcji

Mając na uwadze powyższe wady bezpośredniego wykorzystania gotowego rozwiązania, pokusiłem się o implementację prostej warstwy abstrakcji. Wykorzystuje ona Doctrinowe getRepository(), lecz opakowuje faktycznie wykorzystywane w aplikacji metody, ukrywając tym samym szczegóły implementacyjne repozytorium.

Zrzut struktury plików wygląda następująco:

Repository
|-- Doctrine
|   |-- UserRepository.php
|   |-- CommentRepository.php
|-- UserRepositoryInterface.php
|-- CommentRepositoryInterface.php

Folder Repository/Doctrine zawiera konkretną już implementację interfejsów zdefiniownaych w przestrzeni Repository.

Rzućmy okiem na definicję interfejsu UserRepositoryInterface:

<?php

namespace Repository;

interface UserRepositoryInterface
{
    public function getByAddessEmail(string $email) : UserEntity;
    public function add(UserEntity $userEntity) : UserEntity;
}

W dalszej części naszej aplikacji możemy jawnie wskazać którtego repozytorium oczekujemy – w tym wypadku należy pamiętać o „design by contract”, więc oczekujemy interfejsu repozytorium, a nie jego implementacji.

Przykładowa implementacja:

<?php

namespace Repository\Doctrine;

use Entity\UserEntity;
use Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManager;

class UserRepository implement UserRepositoryInterface
{
    private $entityManager;

    public function __construct(EntityManager $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    public function getByAddessEmail(string $email) : UserEntity
    {
        return $this->entityManager->getRepository(UserEntity::class)
            ->findByAddressEmail($email);
    }

    public function add(UserEntity $user) : UserEntity
    {
        $this->entityManager->persist($user);
        $this->entityManager->flush();

        return $user;
    }
}

Następnie CreateNewUserService ulegnie drobnym modyfikacją:

<?php

namespace Services;

use Exception\DuplicatedUserEmailException;
use Repository\UserRepositoryInterface;

class CreateNewUserService
{
    private $userRepository;

    public function __construct(UserRepositoryInterface $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    public function create(string $email)
    {
        // ...

        if ($this->userRepository->getByAddessEmail($email)) {
            throw DuplicatedUserEmailException();
        }

        $this->userRepository->add(new UserEntity($email));
    }
}

Wywołanie też wymaga nieco odmiennej definicji:

<?php

$entityManager = \Doctrine\ORM\EntityManager::create();
$userRepository = new \Repository\Doctrine\UserRepository($entityManager);
$createNewUserService = new \Services\CreateNewUserService($userRepository);

$createNewUserService->create('[email protected]');

Zmiany widoczne w kodzie są kosmetyczne, jednak takie podejście umożliwia nam łatwą podmianę repozytorium. Jawnie zdefiniowany interfejs informuje nas o metodach które faktycznie wykorzystywane są w aplikacji.

Dziś wykorzystujemy Doctrine, jednak jutro może się okazać, że część danych migrujemy w zupełnie inny byt i potrzebujemy dane przesyłać do zewnętrznego API. Przy wyabstrahowanym rozwiązaniu potrzebujemy jedynie zdefiniować nowy namespace np. Repository/WebService, zaimplementować interfejs w oparciu o inne źródło danych (np. z wykorzystaniem Guzzle), podmienić moment wstrzyknięcia implementacji do klasy CreateNewUserService z implementacji Doctrinowej na nową.

Podsumowanie

Aby było jasne. Nie twierdzę, że repozytorium dostarczone przez twórców Doctrine ORM jest złe. Źle jest ono zazwyczaj wykorzystane w realnym projekcie. Pokusa bezpośredniego wykorzystania Doctrinowego repozytorium jest o tyle większa, że programista otrzymuje potężny interfejs do działania na kolekcji danego typu encji m.in. za pomocą metod findBy*, findOneBy*, matching. Szybkość zaimplementowania kolejnej funkcjonalności często przegrywa z dobrymi praktykami, a w tym wypadku po prostu z abstrakcją, którą na porządku dziennym powinniśmy wykorzystywać w programowaniu obiektowym. Odbija się to czkawką gdy słyszymy od klienta o migrowaniu części danych do innego zasobu. Jednak ten problem poruszę innym razem.

Na co dzień programujący CTO w Emphie Solutions. Projektuje, tworzy oraz wdraża rozwiązania oparte o ekosystem JavaScript. Rozwija swoje umiejętności z zakresu Cloud / DevOps / SRE. Fascynat programowania, architektury, chmury i dobrych praktyk w szerokim ujęciu. Na temat technologii publikuje materiały w ramach projektu DevEnv, którego jest założycielem.
PODZIEL SIĘ