By oszczędzić sobie czasu i korzystać z przychylności programowania obiektowego – kod który napiszemy raz, możemy ponownie wykorzystać w nowych przypadkach. Myślę że zasada DRY, zawsze siedzi nam z tyłu głowy i zna ją każdy zawodowy programista. Niestety w programowaniu nic nie jest czarno-białe i po latach pracy nad różnymi projektami pojawiło się kilka wątpliwości co do ślepego stosowania DRY.
Co robi metoda i dlaczego?
Rola obiektu jest właściwie kontekstem jego wywołania. Przeanalizujmy przypadek w którym posiadamy Encje/WriteModel Product z jeszcze nienazwaną metodą:
namespace App\Offer\Domain\Entity; final class Product { /* ... */ public function ____________(): array { return [ 'name' => $this->name, 'description' => $this->description, 'price' => $this->price, 'currency' => $this->currency, 'created_at' => $this->createdAt->format('Y-m-d H:i:s') ]; } }
Co możemy odczytać z tego kodu?
• metoda zwraca array’a który jest strukturą danych. Dodanie do niej @docBlock - /** @return array<string, mixed> */ nie wiele wniesie do klientów bo i tak by nie iterowali po zwróconej przez metodę tablicy,
• array będący strukturą danych budowany jest na podstawie wewnętrznego stanu obiektu. Na przykładzie tego nie widać, ale załużmy, że serializacji podlega każde pole klasy,
• Jest to metody typu Query (CQS) – niemodyfikująca wewnętrznego stanu obiektu czyli pozbawiona efektów ubocznych.
Kolejne wnioski możemy wyciągnąć patrząc na (jak dotąd) jedynego klienta korzystającego z tej metody:
namespace App\Offer\Infrastructure\Repository; use App\Offer\Domain\Repository\ProductRepository; use App\Offer\Domain\Entity\Product; use Doctrine\DBAL\Connection; final class ProductMySQLRepository implements ProductRepository { public function __construct(private Connection $connection) {} public function save(Product $product): void { $this->connection->executeStatement( 'INSERT INTO product ...', $product->____________() ); } }
• nienazwana metoda wywoływana jest w Warstwie Infrastruktury,
• zmapowany stan Write Modelu reprezentował będzie jeden wiersz w tabeli product,
Biorąc pod uwagę fakt, że klasa ProductMySQLRepository jest jedynym klientem metody Product::__________ - możemy założyć, że powstała ona specjalnie by być wykorzystana do utworzenia wiersza w tabeli. Programista stanął przed problemem – „jak mogę utrwalić Encję Product – nie tworząc dla niej całej armi getterów?”. Załóżmy, że zdecydował się utworzyć metodę mapującą stan na dane oczekiwane przez drugi parametr Doctrine’owego Connection::executeStatement. Czy nie przewidując dla niej innego wykorzystania (w tym punkcie czasu) w przyszłości, wywołanie jej w innym kontekście – całkowicie innym niż INSERT bazodanowy – będzie akceptowalne?
Jaka nazwa jest najodpowiedniejsza?
Do wyboru mamy:
1. toArray
2. asArray
3. serialize
4. jsonSerialize
5. map
Takie podejście sprawia, że w nazwie metody nie ma śladu po kontekście jej użycia, a który jest w całej historii powstania tej metody bardzo istotny. Bezkontekstowa nazwa metody sprawia złudne wrażenie, że wykorzystanie jej ponownie w zupełnie innym kontekście, w zupełnie innej roli jest całkowicie usprawiedliwione.
Powinniśmy więc odpowiedzieć sobie na jedno pytanie: „Czy nazwa metody powinna określać intencje jej pierwotnego użycia?” - moim zdaniem nie. Niezmienia to jednak wspomnianej już wielokrotnie istotności kontekstu dla którego została wprowadzona, na którego musimy zwracać uwagę podczas ponownego użycia metody.
Na potrzeby omawianego przykładu możemy ustalić, że asArray będzie prawdziwą nazwą Product::__________ metody. Dlaczego akturat ta nazwa została wybrana spośród innych? - można powiedzieć, że chodzi o konwencje w projekcie. Często musimy serializować obiekty do tabel, powody tego są różne. Tak jak w opisanym przykładzie, raz potrzebujemy przesłać dane do tabeli bazodanowej, innym razem wysłać je poza aplikację. Musimy przyjąć jedną konwencję nazewniczą dla tego typu metod – na co kolwiek się ostatecznie zdecydujemy, nie będzie miało ostatecznego wpływu na jakość projektu.
Jeden obiekt – dwa konteksty użycia
Zapisywanie produktu do bazy danych zostało wdrożone na produkcję miesiące temu. Biznes jednak nie pozostawił tematu na zawsze i postanowił wzbogacić moduł oferty o nowy feature. Na każde dodanie nowego produktu miał być wysyłany email do zewnętrznego systemu z szablonem umożliwiającym dynamiczne dodanie danych produktu. Podczas tworzenia templatki, można by korzystać z zestawu kluczy, których wartości zawsze byłyby dostępne wraz z wysyłanym emailem. By jeszcze skomplikować sprawę, za te czynności (wysyłanie emaili i zarządzanie templatkami) odpowiedzialny by był zewnętrzny system.
Spoglądajć na kod odpowiedzialny za zapisywanie produktu sprawa wygląda dość prosto bo...
namespace App\Offer\Application\Handler; use App\Offer\Domain\Entity\Product; use App\Offer\Domain\Repository\ProductRepository; final class CreateProductHandler { public function __construct( private ProductRepository $productRepository; ) {} public function __invoke(/*...*/): void { /*...*/ $this->productRepository->save($product); } }
...jedyne co musimy zrobić to wstrzyknąć serwis odpowiedzialny za komunikację z zewnętrznym serwisem wysyłającym emaile. Jako że mamy obiekt $product pod ręką, wraz z możliwością jego łatwej serializacji do struktury tablicowej – możemy pokusić się o implementację z jego wykorzystaniem. Metoda __invoke potrafiąca zlecać wysyłkę email’a wyglada nastepująco:
public function __invoke(/*...*/): void { /*...*/ $this->productRepository->save($product); $this->externalEmailService->send($adminEmail, $product->asArray()); }
W tym momencie struktura tablicowa zwracana przez metodę Product::asArray jest wykorzystywana dwuktornie w różnych kontekstach:
1. zapisu wiersza tabeli bazy MySQL,
2. templatce emaila znajdującego się w zewnętrznym systemie
Jak pokazuje poniższy kod w zewnętrznym systemie znalazły się „pojęcia” związane z wewnątrz-systemową reprezentacją zapisu encji z innego systemu.
Utworzono produkt {{name}}
{{created_at}}
{{description}}
Rozważmy plusy i minusy
✅ nie musimy robić nic więcej niż wywołanie metody która została już wcześniej napisana. Nazwa metody serializującej nie zawiera w sobie żadnego kontekstu w którym została utworzona dlatego korzystamy z niej z czystym sumieniem,
❌ powiązaliśmy ze sobą niejawnie dwa konteksty: zmiana klucza w zserializowanym produkcie wprowadzona na potrzeby kontekstu bazodanowego, będzie miała też swoje konsekwencje w kontekście zewnętrznego serwisu do wysyłki emaili. Ewentualne zmiany, które muszą być wprowadzone tylko w jednym z kontekstów będą wymagały wprowadzenia małych hacków,
❌ do zewnętrznego serwisu wysyłającego emaile mogą trafić nadmiarowe/poufne dane, być może o nieodpowiednich nazwach kluczy. Możemy tego uniknąć stosując drobne hacki – przemapowanie tablicy, unsetowanie,
❌ korzystamy z metody utworzonej w celu utrwalaniu Write Modelu – w sposób jakiego nie zakładał jej twórca. Traktując zwracany array jako View Model,
❌ Gdybyśmy zdecydowali się na wysyłkę emaila w Event Subscriberze nie mielibyśmy takiego łatwego dostępu do encji $product jak wcześniej. Musielibyśmy albo pozyskać ją z repozytorium i w dalszym ciągu, z uporem maniaka korzystać z niej jak z Read Modelu.
Z pozoru prostrze rozwiązanie może powodować więcej problemów w przyszłości niż rozwiązuje teraz.
View Model w nowym kontekście
By uchronić się przed silnym couplingiem pomiędzy dwoma wspomnianymi kontekstami, nie możemy cały czas polegać na jednej tablicowej strukturze danych. Musimy wprowadzić nowy byt, a z racji tego, że zserializowana encja Product była początkowo wykorzystywana w kontekście zapisu danych do tabeli bazodanowej – tą część pozostawimy w pierotnej postaci. W kontekście wysyłki maili zostanie utworzona nowa klasa typu View Model.Obiekty tego typu:
• stanowią warstwę buforową pomiędzy rdzeniem naszej aplikacji, a światem zewnętrznym,
• są niemutowalne, służą jedynie jako struktura danych bez żadnych zachowań,
• powinny posiadać odpowiednio sformatowane dane przeznaczone dla klienta.
Przykładowa implementacja mogłaby prezentować się następująco:
namespace App\Offer\Application\ViewModel; final class ProductEmail { public function __construct( private string $name, private string $description, private float $price, private string $currency, private DateTimeImmutable $createdAt ) {} public function asArray(): array { return [ 'name' => $this->name, 'description' => $this->description, 'price' => number_format($this->price) . ' ' . strtoupper($this->currency), 'createdAt' => $this->createdAt->format('Y-m-d H:i:s') ]; } }
Jak widać klucze jak i formatowanie udostepnianych danych są specjalnie dostosowane pod wymagania bytów ze świata zewnętrznego. Nie jesteśmy teraz od niczego zależni, dlatego na nowe wymagania biznesowe możemy dowolnie zmieniać serializację View Modelu – dodawać nowe klucze, modyfikować nazwy już istniejących jak i zmieniać foramtowanie samych wartości. Problemem może okazać się samo instancjonowanie tego obiektu, najpewniej w metodzie repozytorium powiązanym z Encją Product, niemniej jednak jest to niewielki koszt za cenę dobrego designe’u.
Koszt jaki musimy ponieść to:💰 Utworzenie nowej klasy View Model’u
💰 Instancjonowanie VM, najpewniej w nowej metodzie repozytorium
💰 dodając do Encji Product nowe pole, nie doda się ono automatycznie do View
Modelu co miałoby miejsce w pierwszej inkarnacji „Wysyłki emaila na utworzenie produktu”. Niewątpliwie rozpatrujemy to jako zaletę, ale również jako koszt – rzeczy o której należy pamiętać i potęcjalnie wykonać.
Złożoność
Możemy odnieść wrażenie, że wraz z dodawaniem kolejnych klas/metod rośnie złożoność projektu. W tym przypadku jest to złudne wrażenie. Pomyślmy, korzystając z jednej struktury tworzymy silny coupling pomiędzy modułami, które nie powinny być ze sobą powiązane na tym poziomie:
Dodajemy nowe pole do Encji Product ➡️ pojawia się nowy klucz możliwy do wykorzystania w templatce emaila.
lub:
Usuwamy pole z Encji Product ➡️ Zewnętrzny System przestaje wysyłać emaile o utworzeniu produktu.
I z szerszej perspektywy:
Modyfikujemy Domenę w Module Offer ➡️ Zewnętrzny System do emaili przestaje działać.
Nowy programista w projekcie, mógłby przeoczyć ten fakt i nie być nawet świadom takiego efektu ubocznego podczas dodawania nowego atrybutu Encji. Tego typu nieprzewidziane efekty uboczne są przyczyną powstania trudnych do wytropienia błędów, sprawiając, że złożoność oprogramowania rośnie.
Czas zweryfikował: rola jest jedna
Osobne byty dla innych kontekstów dają niesamowitą swobodę, ale jeżeli początkowo są identyczne i za każdym razem trzeba nanosić takie same zmiany do dwóch klas – znaczy, że nie powinniśmy w ogóle wprowadzać takiego rozdzielenia, bo pierwotny obiekt odgrywa taką samą rolę w dwóch kontekstach.
Podsumowanie
Tak jak zostało wykazane w opisywanym przypadku, by usunąć złożoność i ukryty coupling, musiały być utworzone dodatkowe byty. W konsekwencji tego, mogą pojawiać się głosy innych członków team’u:
Czasami trudno jest przewidzieć konsekwencje niektórych decyzji podczas implementacji. Dlatego warto czasami zatrzymać się i przemyśleć niektóre kwestie, jakie będą ich konsekwencję. Z pozoru „skomplikowanie” projektu dodaniem kilki nowych klas może okazać się zabawienne w czasie jego dalszego życia. Zachęcam więc do przemyśleń w tej kwestji.