Warstwa Abstrakcji - stanowi izolację pomiędzy kodem programisty a zewnętrznymi modułami, pozwala odseparować system od jego zależności przez co czyni go podatnym na zmiany i aktualizacje. Przywiązując system do innej usługi czynimy go słabym.

Save repository from save post

2015-10-20

Repozytorium, reprezentacja kolekcji pozwalająca na uzyskanie dostępu do obiektów domenowych.
Czy jednak repozytorium powinno również zajmować się aktualizowaniem stanu obiektów i zapisywaniem tego stanu?

interface Repository 
{
    public function add(Object $object);

    public function remove(Object $object);

    public function getBy(Criteria $criteria);

    public function hasAnyMatching(Criteria $criteria);

    public function update(Object $object);
}

public function update(Object $object); tego w ogóle nie powinno tu być!

Moim zdaniem jest to niepoprawne podejście, pewnego rodzaju nadinterpretacja wynikająca z niewystarczająco dobrej architektury, która prowadzi do przecieku infrastruktury do modelu domenowego.

Repozytorium a raczej kolekcja, którą przedstawia nie powinno być odpowiedzialne za aktualizację stanu. Repozytorium można porównać do półki z książkami. Półka jest pewnego rodzaju kolekcją książek. Na półkę można połóżyć nową książkę Bookshelf::add(Book $book), można też usunąć książkę z półki Bookshelf::remove(Book $book), nie ma też problemu żeby znaleźć książkę po tytule Bookshelf::findByTitle(Title $title) lub upewnić się, że książka o podanym tytule już na półce się znajduje Bookshelf::hasBookWithTitle(Title $title).
Te wszystkie operacje wydaje się być dla kolekcji naturalne, ciężko jednak wyobrazić sobie, że półka w jakiś magiczny sposób wykonuje aktualizację stanu książki. Czy komukolwiek przyszło kiedykolwiek do głowy, żeby poprosić półkę o zmianę wstępu w wybranej książce, a raczej o utrwalenie tej zmiany na papierze? Raczej nie, więc dlaczego tak wielu osobom przychodzi do głowy proszenie repozytorium o aktualizację stanu obiektu w bazie czy innym miejscu?

Raczej nie ma jednej odpowiedzi na to pytanie, podejrzewam natomiast, że jest to wynikiem stosunkowo łatwego powiązania repozytorium z taką operacją. No bo w końcu skoro dodajemy, usuwamy to dlaczego nie możemy aktualizować?

Przykład

Jednak dlaczego jest to szkodliwe? Popatrzmy na następujący kawałek kodu, załóżmy że użytkownik ma wykonać jakąś istotną operację.

class DoSomethingCommand
{
    private $email;
    private $objectId; 

    public function __construct($email, $objectId)
    {
        $this->email = $email;
        $this->objectId = $objectId;
    }

    public function email()
    {
        return $this->email;
    }

    public function objectId()
    {
        return $this->objectId;
    }
}

class ImportantService
{
    private $users;
    private $objects;

    public function __construct(UserRepository $users, ObjectRepository $objects)
    {
        $this->users = $users;
        $this->objects = $object; 
    }

    public function doSomething(DoSomethingCommand $command)
    {
        $user = $this->users->getByEmail(new Email($command->email())); 
        $object = $this->objects->getById(new Id($command->objectId()));

        $user->doSomethingWith($object); 
    }
}

Powyższy kod to komenda będąca żądaniem oraz mechanizm obsługujący komendę, usługa reprezentująca pewien proces.
W zasadzie ten kod to wszystko co spełniłoby poniższe kryteria akceptacyjne.

Feature: Do something important with object
    As a user 
    In order to do something important with object
    I need to actually do something with that object

    Scenario:
        Given I am user
        And object exists
        When I do something with object
        Then object should be modified

To bardziej masło maślane niż kryterium akceptacyjne, nie ma w zasadzie większego sensu, jednak nie to jest istotne w tym przykładzie.

Ten scenariusz można zrealizować przy pomocy implementacji InMemory dla obydwu repozytoriów.

Problemy

Jednak skoro według założenia, że repozytorium zajmuje się również aktualizacją obiektów należałoby zmodyfikować usługę w następujący sposób.

class ImportantService
{
    public function doSomething(DoSomethingCommand $command)
    {
        $user = $this->users->getByEmail(new Email($command->email())); 
        $object = $this->objects->getById(new Id($command->objectId()));

        $user->doSomethingWith($object); 

        $this->objects->update($object);
    }
}

Kod dalej działa, kreytrium akceptacyjne zostało spełnione, w końcu w implementacjach InMemory można nie implementować metody ObjectRepository::update(Object $object).
Jednak dlaczego mamy oszpecać domenę czy nawet aplikację takimi nieistotnymi szczegółami jak aktualizacja obiektu w źródle danych?
Przecież żaden ekspert domenowy nie mówi "no i na końcu jak już coś zrobimy to jeszcze aktualizujemy stan", to czynność sama w sobie prowadzi do aktualizacji stanu, nie dodatkowa operacja aktualizacji.
Poprzez dodanie metody ObjectRepository::update(Object $object) sprawiliśmy, że domena/aplikacja (w zależności, w której warstwie znajduje się usługa) stała się świadoma warstwy persystencji przez co zmniejszyliśmy jej czytelność zakłócając ją detalami technicznymi.

Kolejnym, bardziej namacalnym problemem może być konieczność zarządzania transakcjami wewnątrz repozytoriów. Jeżeli operacje wykonywane są na kilku obiektach, a wszystkie lub niektóre z nich powinny być atomowe należy w jakiś sposób otworzyć transakcję, następnie zamknąć ją po wszystkim. Nie trudno wyobrazić sobie, że może to być skomplikowane w przypadku kiedy rozproszymy zapis pomiędzy różne obiekty.

Czy takie podejście jest bardzo szkodliwe? W odseparowanym pojedynczym przypadku nie. Jednak jednorazowe ustępstwo polegające na wprowadzeniu detalu technicznego do domeny czy sprawieniu, że aplikacja jest świadoma warstwy persystencji to dobry pretekst żeby pójść na kolejne, bardziej szkodliwe ustępstwa.
Finalnie może to nawet doprowadzić do sytuacji, w której repozytoria stają się zależnościami usług czy nawet innych encji tylko dlatego, że mają zostać wykorzystane do zaktualizowania stanu obiektu.

Inne rozwiązanie

Co jednak może być alternatywą dla PersistedRepository?
Na początku należy zauważyć, że operacja zapisu, aktualizacji stanu czy nawet usunięcia obiektów to też pewien proces. Proces, który ma początek jakim może być otwarcie transakcji oraz koniec, podsumowanie, zapis zebranych zmian.

Utwórzmy więc interfejs, który będzie odzwierciedlał ten proces.

interface Transaction
{
    public function add($entity);

    public function remove($entity);

    public function commit();

    public function rollback();
}

Zadanie transakcji jest proste, musi zostać utworzona, każda encja która zostanie do niej dodana jest od tego momentu obserwowana, encje usunięte trafiają na listę, która po wykonaniu commit jest fizycznie usuwana z źródła danych a encje niszczone w pamięci. commit jest również momentem, w którym wszystkie nowo utworzone lub zmodyfikowane encje zostają przeniesione do źródła danych.
rollback służy natomiast do wycofania zmian wprowadzonych w poszczególnych encjach.

Transakcję obsługują encje, tylko i wyłącznie encje. Powód jest bardzo prosty, encja to nic innego jak obiekt, który da się jednoznacznie zidentyfikować, oraz który istnieje w czasie. Można go utworzyć, zapisać, odtworzyć, zmodyfikować i znowu zapisać tak aby kiedyś mógł zostać również usunięty.

Jak jednak stworzyć odpowiednią implementację takiej transakcji?
Okazuje się, że i na to jest już gotowe rozwiązanie, jest nim Unit of Work mechanizm wykorzystywany przeważnie wewnątrz ORM'ów.
W bardzo dużym skrócie zadaniem UoW jest śledzenie encji, rejestrowanie zmian stanu tych encji oraz tworzenie list encji, które finalnie mają zostać usunięte. UoW jest również miejscem podsumowania czyli zapisania zgromadzonych zmian. Brzmi znajomo?

interface UnitOfWork
{
    public function watch($entity);

    public function isWatched($entity);

    public function remove($entity);

    public function commit();

    public function rollback();
}

Spróbujmy teraz zrobić przykładową implementację transakcji opartą o Unit of Work dostępny w Doctrinie.

final class DoctrineTransaction implements Transaction
{
    private $entityManager;

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

    public function commit()
    {
        $this->entityManager->flush();
        $this->entityManager->getConnection()->commit();
    }

    public function rollback()
    {
        $this->entityManager->rollback();
    }

    public function add($entity)
    {
        if (!$this->entityManager->contains($entity)) {
            $this->entityManager->persist($entity);
        }
    }

    public function remove($entity)
    {
        $this->entityManager->remove($entity);
    }
}

Mając coś takiego wystarczy, że po otrzymaniu Requesta utworzymy transakcję i zamkniemy ją bezpośrednio zanim utworzony zostanie Response. Encję do transakcji można wrzucać wewnątrz repozytoriów. Czy to w metodzie add(Object $object), kiedy tworzona jest nowa encja, czy też w metodzie getBy(Criteria $criteria) kiedy encja jest pobierana. A co w przypadku kiedy encja ma być tylko pobrana w celu prezentacji a niekoniecznie wykonywania na niej operacji? Cóż, wtedy można albo stworzyć osobny "Read Model", który posłuży tylko i wyłącznie do prezentacji
danych, lub posiłkować się pustą implementacją interfejsu Transaction. Transakcja sama w sobie nie jest jednak odpowiednią zależnością dla repozytorium, to trochę tak jakby zależnością repozytorium stał się nagle obiekt reprezentujący Request http. Nie brzmi zbyt poprawnie? Dlatego zależnością repozytorium powinien stać się mechanizm, który potrafi obsługiwać transakcje. Przykładowo coś co ja osobiście nazywam PersistenceContext.

Ktoś mógłby zapytać dlaczego zamiast tworzyć interfejs transakcji nie wykorzystać od razu Unit of Work dostępny w ORM'ie? Przecież metody tych dwóch mechanizmów praktyczne się pokrywają.
Otóż dzięki wprowadzeniu interfejsu Transaction do aplikacji dodana została bardzo prosta warstwa abstrakcji, która całkowicie separuje aplikację od zewnętrznych bibliotek czy od konkretnego źródła danych. Kto był w sytuacji, gdzie po czasie okazuje się, że wykorzystanie nierelacyjnej bazy danych było jednak przejawem chwilowej mody a nie pragmatyczną decyzją na pewno doceni taką abstrakcję. Wiele aplikacji jest wręcz nie do ruszenia z powodu zabetonowania jej źródłem danych. Cały czas musimy jeszcze pamiętać że, ORM daje nam możliwość zmiany relacyjnej bazy danych... Na inną relacyjną bazę danych, a nie zawsze takiej zmiany potrzebujemy.

Nazwa Transaction to tylko przykład, równie dobrze można nazwać ten interfejs UnitOfWork czy PersistanceLayer

Podsumowanie

Umieszczenie w repozytorium metody update może wydawać się mało problematyczne. Na pewno znajdą się projekty, w których takie podejście się sprawdziło. Istnieje jednak spora szansa, że przez takie podejście ucierpi nie tylko model domenowy, w pewnym momencie może okazać się, że rozwój aplikacji staje się coraz bardziej skomplikowany, szczególnie kiedy korzystamy z różnych źródeł danych. Z drugiej jednak strony stworzenie warstwy abstrakcji reprezentującej proces zapisu do źródła danych jest bardzo tanie. Całkowicie separuje aplikację i pozwala opóźnić podjęcie decyzji jaką bazę danych, czy inny mechanizm wybrać. Dlatego zamiast zastanawiać się jak bardzo aplikacji zaszkodzić może Repository::update(Object $object) może warto zastanowić się ile zyskamy przez wprowadzenie Transaction?

Poniżej slajdy do mojej prezentacji o tym samym tytule.


Masz pytanie?
Napisz: kontakt@zawarstwaabstrakcji.pl
Akceptuję

Ten serwis używa plików cookies. Więcej o plikach cookies.