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.