Repozytorium - najbardziej niepotrzebny wzorzec projektowy
Mawiają, że przyzwyczajenie jest drugą naturą człowieka. Sprawia ono, że bezwiednie (albo nawet bezmyślnie) wykonujemy pewne czynności. Niekiedy to jest dobre - np. z przyzwyczajenia myjemy dłonie po skorzystaniu z toalety, nawet jeśli ich nie obsikaliśmy. Niestety, czasem mimowolność przyzwyczajeń sprawia, że ludzie dokładają sobie zbędnej roboty, np. pisania kodu, który nie ma sensu. Samo pisanie jeszcze nie jest złe, gorzej, że później ktoś taką radosną twórczość musi czytać i utrzymywać. Tak zazwyczaj dzieje się w przypadku wzorca Repozytorium.
Niezrozumienia i nieporozumienia
Repozytorium jest niewątpliwie jednym z najpopularniejszych wzorców projektowych. Korzystał z niego chyba każdy programista, który pisał kiedykolwiek jakąkolwiek aplikację korzystającą z bazy danych. A przynajmniej tak się wydaje… Z mojego doświadczenia wynika, że repozytorium jest jednym z najmniej rozumianych i najczęściej niewłaściwie używanych wzorców projektowych. Najbardziej jednak smuci mnie fakt, że tak mało osób próbuje zgłębić temat i zrozumieć powody, dla których stosuje się repozytoria oraz to, jak je poprawnie zaimplementować. Zazwyczaj wychodzi się z założenia, że “to przecież takie proste, tu chodzi o dostęp do bazy” i podąża za swoją intuicją, albo po prostu stwierdza, że “u nas od zawsze tak się pisze repozytoria, więc na pewno stosujemy ten wzorzec prawidłowo”.
Na szczęście, zarówno błędne interpretacje celu, jak i błędne implementacje tego wzorca są tak częste, że w miarę łatwo sporządzić ich listę. Oto ona:
1. Repozytorium to warstwa dostępu do bazy danych
Nie, po prostu nie. Repozytorium to nie jest warstwa dostępu do danych. Repozytorium ma znajdować się pomiędzy warstwą dostępu do danych, a domeną aplikacji i zachowywać się jak kolekcja, czyli dostarczać możliwość zapisywania, pobierania i usuwania obiektów. Mnie można oczywiście nie wierzyć, ale to nie ja wymyśliłem.
Aby to wszystko miało to sens, logika biznesowa ma operować na interfejsach repozytoriów, których konkretne implementacje znajdują się w innym module aplikacji, który potrafi obsługiwać konkretne źródło danych. Dzięki temu osiągamy persistence ignorance
- logika biznesowa nie wie nic o sposobie składowania danych, osiągamy eleganckie separtion of concerns, a do tego jesteśmy w stanie testować kod biznesowy jednostkowo. Z założeń, logika biznesowa ma się zajmować procesem biznesowym, przetwarzaniem parametrów wejściowych w wyniki, a nie szczegółami infrastrukturalnymi, takimi jak miejsce przechowywania danych.
Warto dodać, że repozytorium niekoniecznie musi być powiązane z bazą danych. Równie dobrze może ono korzystać z plików albo być fasadą np. na SOAPowe API zewnętrznego serwisu. Jedyne co jest istotne, to fakt, że daje imitujący kolekcję dostęp do obiektów domenowych.
2. Stosuje się je po to, aby można było podmienić bazę lub ORMa
Już wyżej wyjaśniłem jaki jest cel istnienia repozytoriów, więc ewidentnie nie jest nim podmienianie niczego. To może być co najwyżej efekt uboczny ich zastosowania, ale… Przecież bazę danych można podmienić poprzez zmianę konfiguracji ORMa, więc argument o konieczności zastosowania repozytorium w tym przypadku nie ma sensu. Natomiast wymiana ORMa… Nawet jeśli zdecydujemy się na tak karkołomny krok, to konieczność zamiany wywołań metod z ORMa X na ORMa Y będzie najmniejszym problemem. Dużo trudniejsze będzie dopisanie brakujacych funkcji, znajdowanie obejść dla błędów i różnic w API między poszczególnymi ORMami, oraz definiowanie od nowa konfiguracji mapowań.
3. Zwracanie IQueryable
Bardzo częstym (głównie w przypadu korzystania z Entity Frameworka) błędem jest tworzenie w repozytorium metod zwracających IQueryable<T>
. Pierwszym problemem z tym związanym jest wyciekanie abstrakcji - informacja o konkretnej technologii dostępu do bazy danych wydostaje się wówczas do logiki biznesowej, co przeczy idei repozytorium. Ponadto, ponieważ konstruowanie zapytań jest wówczas przerzucone do logiki biznesowej (a czasem nawet prezentacyjnej), to oprócz złamania SRP powstaje kod trudniejszy w testowaniu. Co więcej, idą za tym potencjalne problemy wydajnościowe. Ponieważ materializacja wyników zapytania odbywać się może w różnych miejscach aplikacji, nawet w warstwie prezentacji, to bardzo prawdopodobne jest wystąpienie problemu n+1 (np. jeśli pobieramy do wyświetlenia w gridzie listę faktur, a jedną z kolumn jest miasto kontrahenta i następuje nawigacja w rodzaju Invoice.Customer.Address.City.Name
). Inna możliwość jest taka, że będziemy walczyć z błędami, gdy w momencie takiego odwołania, będzie już zamknięte połączenie do bazy.
4. Zbędne metody
Typowe repozytorium wygląda tak:
I teraz zastanówmy się - czy GetAll
ma sens? Czy w jakimkolwiek systemie jest sens pobrania wszystkich produktów na raz? Przecież to mogą być tysiące, a nawet miliony obiektów… Ani tego sensownie wyświetlić ani rozsądnie przetwarzać.
Podobnie ma się rzecz z Delete
. Produktów raczej się nie usuwa, lecz archiwizuje. Faktury się koryguje kolejnymi dokumentami księgowymi. Klientów się nie usuwa (co wtedy miałoby się niby stać z ich zamówieniami i fakturami?), co najwyżej deaktywuje ich konta. Generalnie dla cokolwiek znaczących i używanych encji w systemie stosuje się jakieś enumy bądź flagi określające ich istnienie w systemie (w naprostrzym przypadku - soft delete w oparciu o wartość logiczną), a nie fizycznie usuwa rekordy.
Ten sam problem dotyczy metod Add
i Update
. Nie każda encja w systemie może być przecież tworzona przez użytkowników. Część danych jest predefiniowana podczas tworzenia aplikacji i nie ulega zmianie później. Takie dane są tylko do odczytu, więc umieszczenie w repozytorium metod do ich modyfikacji nie ma po prostu sensu.
Ergo - repozytorium musi być dopasowane do encji, którą przechowuje. Umieszczanie tych samych metod w każdym repozytorium po prostu nie ma sensu.
5. Generyczne repozytorium
Generyczne repozytorium używane przez logike biznesową (albo kontrolery, bo tak też jest modnie) to antywzorzec, żeby było śmieszniej sprzeczny z ideą repozytoriów. Jak już wiemy, repozytorium to kontrakt dla logiki biznesowej, a nie warstwa dostępu do danych. Ma ono na celu zapewnić ścisły zestaw konkretnych (a więc niegenerycznych!) metod dostępu do danych, tak aby logika biznesowa nie była świadoma szczegółów tego, jak coś jest w źródle danych przechowywane. Nie powinno się także dawać klientowi repozytorium dostępu do metod usuwających albo dodających encje, dla których taka operacja nie ma sensu. (Patrz poprzedni punkt.)
Zarówno DbSet<T>
jak i ISession
są de facto generycznymi repozytoriami. Opakowywanie ich w klasę, której jedynym zadaniem jest wywołanie metod z ORMa, to jedynie tworzenie wrappera, który niczego nie wnosi. Jego użycie w logice biznesowej nie daje żadnej wartości dodanej w porównaniu z bezpośrednim korzystaniem z obiektu ORMa. Jest wręcz przeciwnie, bo API takiego naszego “generycznego” repozytorium zazwyczaj będzie okrojne w stosunku do możliwości stosowanego prez nas ORMa.
6. Stronicowanie danych
W lepiej przemyślanych repozytoriach, zamiast (albo obok) metody GetAll
pojawia się metoda w rodzaju GetDataPage
. Zamiast wybierać wszystkie rekordy, potrafi ona je filtrować, a następnie pobrać jedną ich stronę, która następnie zostanie wyświetlona w gridzie. Zdawać by się mogło, że to dobry pomysł… ale nie jest to prawda. Repozytorium ma być źródłem danych dla logiki biznesowej, nie dla GUI! Obiekty wyświetlane w GUI nie powinny być encjami logiki biznesowej, po prostu w celu wyświetlania danych powinno się tworzyć oddzielne modele (viewmodele). Z bardzo prostej przyczyny - zazwyczaj nie potrzebujemy pokazywać w GUI wszystkich pól danego rekordu i zazwyczaj dane, które chcemy pokazać, nie pochodzą z jednej tabeli, lecz są projekcją wielu tabel. To wymaga od nas tworzenia modeli widoków oraz klas pozwalających efektywnie je pobierać z bazy danych, wymaga specjalnych “repozytoriów” dla viewmodeli. Tylko to już nie będą repozytoria, więc nie ma sensu ich tak nazywać.
Przepychanie ogromnych grafów obiektów od bazy do interfejsu tylko po to, aby wyświetlić tylko niektóre ich pola w GUI, to bardzo zły (aczkolwiek powszechny) pomysł. Głównie ze względów wydajnościowych.
7. Repozytorium dla każdej tabeli
W projektach, w których nie ma generycznych repozytoriów, zazwyczaj każda tabela w bazie otrzymuje swoje własne repozytorium. (W ekstremalnym przypadku także taka, której jedyną rolą jest mapowanie asocjacji N:M.) W efekcie, zamiast repozytoriów, tak naprawdę otrzymujemy inaczej nazwane DAO (albo Table Data Gateway). No, ale “CośtamRepository” brzmi profesjonalniej niż “CośtamDAO”, więc wiadomo, którą nazwę wybiera ambitny programista. ;)
Repozytoria powinny być tworzone jedynie dla aggregate root'ów
. Agregat to grupa obiektów powiązanych ze sobą z punktu widzenia domeny biznesowej, tworzących razem pewną sensowną całość. Dostęp do wszystkich obiektów agregatu odbywa się przez jego root, co oznacza, że nie można nimi manipulować bez jego pośrednictwa. Root jest też jedynym obiektem, którego mogą używać obiekty spoza agregatu. Przykłady: Invoice
i InvoiceItem
. Pozycja faktury nie ma sensu bez faktury, więc dodawanie nowych obiektów InvoiceItem
odbywa się przez obiekt Invoice
, który jest w tym przypadku aggregate rootem. Co za tym idzie, nie ma sensu odczytywanie ani zapisywanie obiektów typu InvoiceItem
oddzielnie, trzeba zawsze operować na całości, a więc sensowne jest istnienie repozytorium dla obiektów Invoice
, a dla InvoiceItem
już nie.
8. Jest niezbędne do testowania
Interfejsy ułatwiają mockowanie. To fakt, któremu trudno zaprzeczyć. Dzięki posiadaniu interfejsów repozytoriów, możemy do serwisu wstrzyknąć jego mocka, który zwróci odpowiednie dane nie korzystając przy tym z bazy danych, więc będzie szybki. Wspaniale! Tylko nie potrzebujemy do tego repozytorium. NHibernate od zawsze ma swoje ISession
, więc od zawsze można je mockować, natomiast Entity Framework dostał odpowiednie interfejsy (IDbContext
, IDbSet<T>
) w wersji 6.
Alternatywnie, w celach testowych zamiast mocków można użyć SQLite w trybie in-memory. W NHibernate nie ma z tym problemu, w Entity Frameworku ponoć też się da. Potrzebna jest tylko inna konfiguracja do testów niż do właściwiej aplikacji. Co prawda testy wówczas stają się bardziej integracyjne niż jednostkowe, ale właściwie nie tracą na prędkości. Warto być świadomym takiej możliwości.
9. Rozrost repozytoriów
Ten problem wynika z założenia, że repozytorium to warstwa dostępu do danych, i tworzenia jednego repozytorium dla danej tabeli w ramach całej aplikacji.
Zaczyna się od CustomerRepository
z metodami GetCustomerById
, GetCustomerByName
, GetCustomerByNIP
. Potem okazuje się, że trzeba pobrać klienta razem z fakturami, więc ktoś zmienia zawartość tych metod tak, że zachłannie pobierane są także obiekty Invoice
. Oczywiście nigdzie w kodzie tego nie widać, ale w końcu okazuje się, że aplikacja zwolniła… Gdy już znajdzie się przyczynę tego zachowania, tworzone są nowe metody o więcej mówiących nazwach: GetCustomerWithInvoicesById
, GetCustomerWithInvoicesByName
, GetCustomerWithInvoicesByNIP
. Znowu jest dobrze.
Przynajmniej na razie, bo z czasem pojawia się potrzeba pobrania klientów wg adresu wraz z jego pracownikami, ale bez faktur: GetCustomersWithEmployeesButWithoutInvoicesByAddress
, a potem potrzeba pobrania wszystkich klientów, z fakturami, bez pracowników, z ulubionymi filmami, bez kochanek, według grupy krwi żony i imienia kota córki szefa: GetCustomersWithInvoicesWithoutEmployeesWithFavouriteMoviesWithoutMistressesByWifeBloodTypeAndBossDaughterCatName
…
Takich metod może pojawić się naprawdę wiele. Tymczasem rozwiązanie jest proste… Mimo, że klient fizycznie jest jedną osobą, to z punktu widzenia różnych modułów systemu, np.: obsługi klientów, fakturującego i magazynowego, jest trzema różnymi bytami. Inne dane są potrzebne do założenia konta, inne do wystawienia faktury, inne do realizacji zamówienia. Potrzeba być może trzech klas Customer
, ale na pewno trzech oddzielnych repozytoriów, które będą miały mniej metod niż jedno “ogólnego przeznaczenia”.
10. Nie może zawierać specyficznych metod do filtrowania danych
W punkcie 1. tej listy podlinkowałem wpis Martina Fowlera na temat repozytoriów. Tekst ten nie wyjaśnia wszystkiego, ale pojawia się tam coś takiego jak aCriteria
- obiekt, który przekazuje się do metody repozytorium, aby wybrała dane zgodne z podanym kryterium. (Takie coś ma nawet nazwę - specification pattern
.) Niekiedy na podstawie opisu Fowlera wyciągany jest wniosek, że repozytorium może mieć tylko jedną metodę “wczytującą” obiekty, która przyjmuje właśnie taki obiekt-kryterium, a nie może mieć metod w rodzaju: GetCustomerById
, GetCustomerByName
, GetCustomerByNIP
. Nie jest to prawdą. Eric Evans w swojej książce o DDD napisał wyraźnie, że są dwa podejścia jeśli chodzi o odptywanie repozytoriów. Można w nim zaimplementować metody odpowiadające dokładnie zapytaniom, które nas interesują, a można tez mieć metodę przyjmującą obiekt specyfikujący zapytanie. Można nawet te podejścia łączyć, i mieć w repozytorium zarówno metodę pozwalajacą na tworzenie dowolnych, elastycznych zapytań, jak i zdefiniowane zapytania dla najczęstszych przypadków.
11. Aktualizowanie pojedynczych wartości
Niekiedy w repozytoriach pojawiają się metody w rodzaju UpdateSomeValue(int id, string newValue>)
. Wszakże wydajniej jest zmienić wartość w jednej kolumnie niż odczytać cały obiekt, ustawić nową wartość jakiejś właściwości, a potem cały obiekt zapisać. To fakt, jeden update
w SQLu jest wydajniejszy. Pamiętajmy tylko, że:
- To nie jest zgodne z DDD.
- Trudno to nazwać nawet programowaniem obiektowym.
- Takie mikroptymalizacje obok metod wysyłających ogromne grafy obiektów do wyświetlania w GUI wyglądają po prostu przekomicznie.
- To nie jest już repozytorium. Bo repozytorium ma mieć API kolekcji, a nie metody do manipulowania pojedynczymi wartościami bezpośrednio w źródle danych.
Jak implementować repozytoria prawidłowo?
O ile w naszym projekcie w ogóle sens ma istnienie repozytoriów, robimy to tak:
- Interfejsy repozytoriów umieszczamy w projekcie domenowym, najczęściej w tym, w którym są zdefiniowane serwisy biznesowe, które z repozytoriów korzystają.
- Implementujemy logikę biznesową - oczywiście z testami, w których mockujemy repozytoria.
- Tworzymy nowy moduł (w .NET to biblioteka dll), w którym implementujemy repozytoria. W tym module możemy utworzyć sobie jakąś bazową (a więc abstrakcyjną) klasę repozytorium generycznego, z której będą korzystały konkretne implementacje. Ale nie wypuszczamy tego generycznego repozytorium poza ten moduł, ono powinno być oznaczone jako
internal
.
Powyższe kroki powtarzamy dla każdego biznesowego modułu systemu, bo zazwyczaj każdy z nich ma swoją własną subdomenę/bounded context, więc i oddzielną logikę biznesową, a co za tym idzie oddzielne repozytoria.
Sensowne stosowanie repozytorium
Martin Fowler w swojej książce “Patterns of Enterprise Application Architecture” podał takie powody stosowania repozytoriów:
- Izolacja domeny biznesowej od warstwy składowania danych.
- Szybkosć testowania dzięki dostarczeniu implementacji repozytorium operującego w pamięci.
- Przechowywanie niektórych typów obiektów w pamięci przez cały czas życia aplikacji - czyli cache.
- Pobieranie danych z innych niż baza danych źródeł.
Z 1. i 4. nie ma co dyskutować, natomiast 2. i 3. są w dzisiejszych czasach (14 lat po pierwszej publikacji tej książki) dość dyskusyjne, albowiem:
- Ad 2. Dobry ORM jest oparty na interfejsach, więc można go mockować tak samo dobrze, jak repozytoria. Ponadto, dobry ORM pozwala też na użycie bazy danych w pamięci, która jest zdecydowanie szybsza od dyskowej, a w przypadku testowych ilości danych zazwyczaj wystarczy.
- Ad 3. Sensowny ORM (czyli nie EF ;)) sam potrafi cachować obiekty, które wskaże mu programista.
W znakomitej większości aplikacji, nie jest potrzebne ani DDD, ani repozytoria. Operacje CRUDowe, a także prostą logikę dziedzinową można zaimplementować w postaci chociażby skryptu transakcji, który będzie bezpośrednio operował na kontenerze UoW z ORMa. Nie ma potrzeby dodawania tu dodatkowych pseudowarstw w postaci wrapperów na ORM nazwanych dla zmyłki repozytoriami. To naprawdę jest kawał dobrej, nikomu niepotrzebnej roboty.
Repozytoria są potrzebne i sensowne w projektach ze skomplikowaną logiką biznesową, czyli takich, w których uzasadnione jest zastosowanie podejścia DDD. Jeśli nie mamy DDD, to właściwie nie da się napisać repozytorium - co najwyżej można zrobić DAO i nazwać go Repository, bo tak lepiej wygląda w CV.
Zakończenie
Idą wakacje, robi się coraz cieplej… Naprawdę lepiej iść na rower, rolki czy sanki (pozdro Suwałki), zamiast pisać zbędny kod udający jakiś tam wzorzec o mądrze brzmiącej nazwie. A jeśli nie lubimy spędzać czasu na świeżym powietrzu, to posiedźmy w domu, popatrzmy jak nam paznokcie rosną, albo jak farba się łuszczy na ścianach. Wszystko to ma więcej sensu niż dokładanie zbędnej roboty sobie i innym przedstawicielom swojego gatunku. Z punktu widzenia cywilizacji tracenie czasu i energii na wykonywanie pracy, która nic nie wnosi do jej rozwoju nie ma sensu. Bądźmy dobrymi homo sapiens i nie róbmy rzeczy, które z sapiens mają mało wspólnego. Jest przecież jeszcze tyle przydatnego kodu do napisania! (Np. hotfixy na produkcji dla PUE ZUS.) Przestańmy zatem gonić własne kości ogonowe i tracić czas na pisanie czegoś, co nie ma sensu.