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 interfejsUnitOfWork
czyPersistanceLayer
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.