sobota, 13 listopada 2021

Core Code & Infrastruktura

Najprościej rzecz ujmując - Core Code - jest to kod niezależny od infrastruktury i kontekstu wywołania. Wzorce służące do jego implementacji to: Command, Serwis, Repozytorium, Fabryka, Read Model, Write Model (Encja) czy Value Object. Z drugiej strony mamy tytułową Infrastrukturę do której należą np. implementacje interfejsów repozytoriów czy kontrolery HTTP/CLI. Stosując podział na Core Code i Infrastrukturę realizujemy ważny cel: odseparowanie kodu realizującego funkcjonalność od szczegółów technicznych. Istotnym elementem w poznaniu każdego z tych wzorców jest zrozumienie w jaki sposób działają ze sobą nawzajem, oraz na jakim etapie cyklu życia aplikacji występują. W tym wpisie nie skupie się na wspomnianych wzorcach, ale na istocie tytułowego podziału na Core Code i Infrstrukturę - czyli dlaczego jest on istotny i jakie korzyści przyniesie.  
 

Odseparowanie od Infrastruktury. Why even bother...?

    Odseparowanie od infrastruktury możemy zrealizować poprzez stosowania wyżej wymienionych wzorców. Jakby się na tym zastanowić to pilnując by kod dzielił się na Core Code i Infrastrukturę, realizujemy główne założenia OOP - wprowadzanie warstw abstrakcji. Biorąc za przykłąd Serwis Aplikacyjny - czytając jego kod, nie musimy zaprzątać sobie głowy szczegółami technicznymi zapisu danych do bazu ponieważ Serwis posiada zależność w postaci Interfejs Repozytorium, która skutecznie ukrywa złożoność tego zagadnienia. Jeżeli będziemy potrzebowali tej wiedzy, po prostu wyszukamy Implementacje Repozytorium ukrywającą techniczne aspekty zapisy. Dzięki temu nie jesteśmy zbyt wcześnie obciążeni więdzą, której na danym etapie w ogóle nie potrzebujemy. 

    Często wymienianą zaletą odseparowania Core Code od Infrastruktury jest wymienialność szczegółów implementacyjnych jak np. baza danych. Prawde mówiąc wymiana bazy na inną jest dość rzadko występująca czynność w czasie życia projektu, ale jeżeli bylibyśmy do niej zmuszeni np. z przyczyn wydajnościowych to przy wydzielonej Warstwie Infrastruktury jesteśmy do tego zdolni porównywalnie mniejszym nakładem pracy. Pojęcie szczegółu implementacyjnego nie ogranicza się jedynie do bazy danych, ale jesto to o wiele szersze spektrum, gdzie do Infrastruktury zaliczamy całe frameworki, biblioteki, połączenia z zewnętrznymu systemami. Od wszystkich tych zależności możemy się odgrodzić warstwą abstrakcji co w nieznanej przyszłości może się okazać nieocenioną zaletą. Jak to powiedział Michael Feathers:  

"Avoid littering direct calls to library classes in your code. You might think that you’ll never change them, but that can become a self-fulfilling prophecy"

    Można wywnioskować, że to co robimy to izolowanie się od technologii służącej do realizowania funkcjonalności, która nie definiuje funkcjonalności samej w sobie. Przez to, że mamy fizycznie rozdzielone miejsca obsługi zapisu do bazy danych konkretnej Encji i definicji samej Encji - utrzymanie projektu staje się prostrze. Na przykład chcąc podnieść wersje biblioteki obsługującej połączenie z bazą danych będziemy operowali jedynie na Warstwie Infrastruktury nie ruszając przy tym kwestii biznesowych, w wyniku czego ryzyko nieumyślnego wprowadzenia zmiany funkcjonalności biznesowej znacząco maleje.

Fizyczne rozdzielenie - czyli tworzenie oddzielnych klas domeny problemu oraz infrastrukturowych w osobnych Warstwach. Warstwy według standardowego modelu dzielą się na Aplikację, Domenę oraz Infrastrukturę - realizowane są przez namespace'y. Jest jasno określone, jaką wiedzę mogą posiadać klasy w danej warstwie o bytach (klasy, interfejsy, enum) z innej warstwy. Jako, że PHP natywnie nie ma zaimplementowanego mechanizumu enkapsulacji przestrzeni nazw, nic nie stoi na przeszkodzie by zaimplementować zły kierunek komunikacji np. Wastwa Aplikacji posiada odwołania do klas z Warstwy Infrastruktury. Jedynie dyscyplina developerów w przestrzeganiu zasad może pilnować tej poprawności (być może przy pomocy analizy statycznej).  

    Z wyizolowaną Warstwą Infrastruktury sprawiamy, że możemy stosować TDD bo klasy z Core Code są łatwe w testowaniu jednostkowym. Dodatkowy nakład pracy może prowadzić do powstania modelu domenowego wedle DDD. Stosowanie tych technik niewątpliwie przyczyni się do jakości wytwarzanego oprogramowania. Dodatkowo, tworzenie klas z myślą o odseparowanej infrastrukturze, naturalnie prowadzi do implementacji popularnego wzorca architektonicznego Porty i Adaptery.  

Wewnętrzna jakość oprogramowania

    Przez Jakość Wewnętrzną rozumie się szerokopojętą czytelność kodu, łatwość w utrzymaniu i rozwijaniu aplikacji. Wydaje się, że na jakości kodu zależy głównie programistom, gdyż dla ludzi biznesu jej istotność na pierwszy rzut oka nie jest taka oczywista. Jako że oprogramowanie tworzone jest w sposób iteracyjny, pisane klasy muszą realizować spójne funkcjonalności, tworzyć luźno powiązane większe struktury i być pokryte należytą ilością testów jednostkowych. Nigdy nie wiadomo jakie zmiany najdejdą w kolejnych iteracjach, więc musimy starać się tworzyć solidne elementy budulcowe (klasy/grupy klas/moduły) w całym projekcie. Utrzymując ścisłą dyscypline przy tworzeniu wysokiej jakości oprogramowania sprawiamy, że będziemy mogli modyfikować zachowanie oprogramowania w sposób przewidywalny i bezpieczny, minimalizując ryzyko, że zmiana będzie wymagała dużej przeróbki. 

    Oprogramowanie jest oczywiście tworzone do realizowania celów biznesowych, ale musi ono też służyć developerom. Moglibyśmy napisać kiepski kod spełniający w 100% wymagania klienta (o wysokiej jakości zewnętrznej), ale wprowadzanie zmian w oprogramowaniu niskiej jakości jest skomplikowane, nieprzewidywalne i ryzykowne - z czasem wymagające coraz większej uwagi developera i nakładu czasu. 

Podsumowując: 

  1. im łatwiej i pewniej wprowadzać zmiany w oprogramowaniu tym lepiej
  2. by łatwiej wprowadzać zmiany w oprogramowaniu trzeba pisać kod o wysokiej jakości wewnętrznej
  3. istnieje szereg technik do osiągnięcia wysokiej jakości kodu   

    Jak widać tematy te są pochodną wydzielania Core Code i wzajemnie się zazębiają. Stosując wzorce Rdzenia Aplikacji będziemy mogli łatwo przetestować jednostkowo tworzone obiekty, co dowodzi że kod jest modułowy oraz niezależny od kontekstu co jest równoznaczne z wysoką jakością.    

Wzorce Projektowe

    Wymienione na samym początku wzorce, pomagają w pisaniu kodu odseparowanego od infrastruktury. Opracowane były na podstawie sumy doświadczeń innych programistów w budowaniu aplikacji webowych. Są one dla programisty zestawem jasno zdefiniowanych i sprawdzonych elementów budulcowych. Stosowanie wzorców sprawia, że kod jest czytelny, modułowy i łatwy w testowaniu. Developer znający całą paletę wzorców może korzystać z niej jak przybornika z narzędziami - dobierając narzędzie najlepiej dopasowane do probelmu. Nie trzeba wtedy wymyślać koła na nowo i zastanawiać się nad tym czy zastosowane rozwiązanie jest dobre. Mając sprawdzony zestaw wzorców oraz posiadając praktyczną wiedzę ich stosowania jesteśmy w stanie modyfikować oprogramowanie szybciej i pewniej. 

    W rękach developera jest to czy poszerza on swoją wiedzę w dziedzinie wytwarzania oprogramowania wysokiej jakości. Zgłębianie informacji na temat wzorców projektowych sprawia że, zaczynamy widzieć więcej. Implementując kod możemy wybiec w przyszość, przewidując jakie będzie niósł ze sobą konsekwencje, przed jakimi potencjalnymi problemami nas chroni, w jaki sposób sprawia że jest wysokiej jakości. Korzystając z danego wzorca dziesiątki bądź setki razy, posługujemy się nim coraz lepiej, widzimy jakie warianty się najlepiej sprawdzają, a w jakich sytuacjach lepiej go nie stosować.

    Można powiedzieć, że przybornik z narzędziami developera uzupełniany jest o nowe elementy gdy poszerza on swoją wiedzę - teoretyczną i praktyczną. Warto szlifować swoje umiejętności posługiwania się tymi narzędziami gdyż bezpośrednio przekładają się na umiejętności tworzenia kodu wysokiej jakości, czyniąc nasza pracę prostszą.

Na sam koniec wrzucam ciekawy cytat Matthias'a Noback'a dający wiele do myślenia:   

 “Software always becomes a mess, even if you follow all the best practices for software design  but I’m convinced that if you follow these practices it will take more time to become a mess  and this is a huge competitive advantage”

Źródła 

Wpis jak i cała koncepcja została zaczerpnięta przede wszystkim z przemyśleń Matthias'a Noback'a (blog) zawartych w jego dwóch książkach:

📕 Advanced Web Application Architecture (2020)
📕 Object Design Style Guide (2019) 

Wzmianka o Wewnętrznej jakości oprogramowania została zaczerpnięta z książki:

📕 Growing Object-Oriented Software, Guided by Tests (2009)


niedziela, 7 listopada 2021

ID w instrukcjach warunkowych

Przykład

    Biznes decyduje się by konkretni użytkownicy mieli naliczani rabat do robionego zamówienia. W tym wpisie nie będę się skupiał na naliczaniu samego rabatu, tylko na to komu ma on zostać przyznany. Poniżej znajduje się najprostsza implementacja tego zadania: 

 

namespace App\Context\Application;

final class OrderService 
{
	// ...
	public function createOrder(
		//...
		User $user	
	): void {
		// ...
		if (in_array($user->id(), [101,102])) {
			// give a discount
		}
		// ...
	}
} 

 

    Jak widać rabat dotyczy tylko użytkowników z ID 101 & 102.Serwis Aplikacyjny ma teraz wiedzę o konkretnych ID z bazy MySQL, które nie powinny wychodzić poza Warstwę Infrastruktury. Można by utworzyć dodatkową klasę, która hermetyzowała by identyfikatory, ale tak czy inaczej musiałaby się znaleźć w Warstwie Aplikacji (lub co gorsza Domeny). Najprostrzym rozwiązaniem będzie wprowadzenie wyspecjalizowanego Repozytorium składającego się na interfejs:


namespace App\Context\Application\Repository;

interface UserDiscountRepository 
{
	public function discountAllowed(int $userId): bool;
}


Oraz jego implementacji InMemory:


namespace App\Context\Infrastructure\Repository;

use App\Context\Application\Repository\UserDiscountRepository;

final class UserDiscountInMemoryRepository implements UserDiscountRepository 
{
	public function discountAllowed(int $userId): bool 
	{
		return in_array($userId, [101,102]);
	}
}

 

    Wystarczy dodać zależność w postaci UserDiscountRepository do Serwisu Aplikacyjnego. W przypadku gdy będziemy rozwijać funkcjonalność, a użytkownicy z naliczanym rabatem będą dodawani do systemu dynamicznie - implementacja OrderService nie powinna ulec zmianie.

 

namespace App\Context\Application;

use App\Context\Application\Repository\UserDiscountRepository;

final class OrderService 
{
	public function __construct(
		private UserDiscountRepository $userDiscountRepository
	) {}
	
	// ...
	public function createOrder(
		//...
		User $user	
	): void {
		// ...
		if ($this->userDiscountRepository->discountAllowed($user->id())) {
			// give a discount
		}
		// ...
	}
}

sobota, 6 listopada 2021

Źródło danych InMemory

Często dodając nowe funkcjonalności opieramy się na schemacie: 

  1. pobieranie danych ze bazy danych...
  2. ...przeprowadzanie na nich pewnych operacji

    Niekiedy dane te nie zmieniają się w czasie, są z góry ustalone i w związku z tym nie muszą być umieszczane w zewnętrznych bazach typu MySQL/Redis. Możemy bardziej skłaniać się ku przechowywaniu ich bezpośrednio w repozytorium kodu PHP. Przykładowymi danymi tego typu może być tablica asocjacyjna reprezentująca flow zmiany statusów jakiejś konkretnej Encji. Tak mogłaby prezentować się jej przykładowa implementacja:

namespace App\Context\Domain;

final class ChangeStatusProvider 
{
	public static function mapping(): array 
	{
		return [
			1 => 4,
			2 => 5,
			3 => 5
		];
	}
}

Albo z wykorzystaniem stałych:

namespace App\Context\Domain;

interface ChangeStatusProvider 
{
	public const MAPPING = [
		1 => 4,
		2 => 5,
		3 => 5
	];
}

    Klasa tego typu sama w sobie nie nastręcza problemów, ale sposób jej wykorzystania przez klienta już może. Jako klienta możemy wyobrazić sobie Value Object, który jest polem Encji, a ta elementem Agregatu. Można założyć, że ta tablica asocjacyjna nie może znajdować się bezpośrednio wewnątrz Value Object'u ponieważ jest wykorzystywana również przez innego klienta w zupełnie innym kontekście.

ValueObject wywołuje wewnętrznie metodę statyczną w celu pobrania danych.

Wspomniany Value Object będzie odpowiadał za zwracanie nowego statusu, pytać o niego będzie Encja, a Encje - Agregat.  

namespace App\Context\Domain\ValueObject;

use App\Context\Domain\ChangeStatusProvider;

final class SomeValueObject 
{
	// ...
	public function nextStatus(): int
	{
		$newStatus = ChangeStatusProvider::MAPPING($this->status);
		//...
		return $newStatus;
	}
	//...
}

    Dla klienta (Encji) nie ma różnicy czy otrzymany nowy status został wstrzyknięty przez konstruktor Value Object'u, czy tak jak teraz - pochodzi z metody statycznej. Nie ma znaczenia bo otrzymuje gotowy Value Object jako parametr konstruktora. Dla niego wygląda po prostu jak integralna część Value Object. Wszystko więc wygląda dobrze do czasu...

Zmiany wymagań biznesowych

    Okazuje się że, z czasem Biznes oczekuje konfigurowalnych zmian stanu. Administratorzy aplikacji mają mieć możliwość modyfikowania flow zmiany stanu według określonych ram. Dla developerów będzie to oznaczało, że mapowania statusów nie będą dłużej realizowane przez klasę/interfejs ChangeStatusProvider. Zostanie utworzona tabela w relacyjnej bazie danych + prosty interfejs do zarządzania jej zawartością. 

    W obszarze aplikacji przedstawionym w przykładzie, musimy dokonać zmian. Value Object musi teraz otrzymywać dane z zewnątrz (poprzez konstruktor). Dane o mapowaniu statusów pochodzić będą z nowo utworzonego Repozytorium. Jeżeli instancje Value Object tworzymy wewnątrz Serwisu Aplikacyjnego/Fabryki to tam będzie musiało być wstrzyknięte wspomniane Repozytorium. Gorzej jeżeli odbywa się to wewnątrz Agregatu lub Encji - wtedy dane z Repozytorium będą musiały być przekazane przez kolejne warstwy co jest utrudnieniem w implementacji zmian (jest to ciekawy temat na inny wpis).     

    W tym przypadku, do tego typu implementacji nigdy by nie doszło gdyby developer nie zdecydował się na przechowywanie danych w metodzie statycznej (lub stałych klasy). Głównym czynnikiem, który za to odpowiada jest nierozważanie danych z metody statycznej jako danych zewnętrznych. Gdyby od razu zostało przyjęte takie założenie, programista pewnie z automatu

1. utworzyłby interfejs Repozytorium (Warstwa Aplikacji)

namespace App\Context\Application;

interface StatusRepository 
{
	/** @return array<int, int> */
	public function mapping(): array;
}

2. jego implementacje (Warstwa Infrastruktury)

namespace App\Context\Infrastructure;

use App\Context\Application\StatusRepository; 

final class StatusInMemoryRepository implements StatusRepository 
{
	/** @inheritDoc */
	public function mapping(): array 
	{
		return [
			1 => 4,
			2 => 5,
			3 => 5
		];
	}
}

3. dane do modeli domenowych (Encji/Value Object'ów) wprowadzane by były przez ich konstruktor/metody

namespace App\Context\Application;

final class AggregateFactory 
{
	public function __construct(
		// ...
		private StatusRepository $statusRepository,
		// ...
	) {}

	public function create(/*...*/): Aggregate 
	{
		// ...
		$someValueObject = new SomeValueObject(
			// ...
			$this->statusRepository->mapping()
			// ...
		);
		// ...
	}
}

 

    Dlatego istotne jest by - za każdym razem gdy zamierzamy umieścić dane w kodzie PHP - zastanowić się czy nie będzie prowadziło to do wyżej opisanych problemów w przyszłości. Mając to na uwadze, możemy ułatwić sobie i innym utrzymanie i rozwój projektu. A więc zamiast refaktoryzować metody statyczne w kierunku Wzorca Repozytorium, należy zaimplementować od razu takie rozwiązanie. Dodatkowo należy pilnować by dane do Agregatów, Encji, Value Object'ów przekazywane były przez ich konstruktory.   

Commandy Aplikacyjne niezależne od infrastruktury (cz. 1)

    Załóżmy, że posiadamy obiekt Data Transfer Object np. Command. Obiekt tego typu zajmuje się jedynie przechowywaniem danych i nie oferują żadnych zachowań poza udostępnianiem swojego stanu. Wykorzystujemy go do przekazywania parametrów do konkretnego Serwisu Aplikacyjnego realizującego funkcjonalność biznesową. W uproszczonej formie klasa typu Command przechowująca dane potrzebne do utworzenia użytkownika prezentowałaby się następująco:     


namespace Application;

final class CreateUserCommand 
{
	public function __construct(
		public readonly string $email,
		public readonly int $type,
		public readonly \DateTimeImmutable $birthDate
	) {} 
}

     

    Jako, że obiekty te tworzone są na podstawie danych z poza granic aplikacji, np. z Request'a HTTP, mogą posiadać Fabryczne Metody Pomocnicze które tworzą instancję na podstawie danych z m.in.:

  • JSON/XML z ciała Request'a HTTP
  • argumentów Command'a CLI
  • obiektu Symfony Form
  • danych z pliku CSV

 

Budowanie Command na podstawie zewnętrznych danych

    JSON na podstawie którego budujemy obiekty Command pochodzi z poza granic naszej aplikacji tzw. Outside World. Jest on wysłany po HTTP pomiędzy różnymi aplikacjami.

JSON z poza granic aplikacji. Obiekt Command jest budowany na jego podstawie.
 

    Jak wynika z diagramu, Command budowany jest na podstawie danych z obiektu JSON. Pomimo tego, że znajduje się on w Warstwie Aplikacji, jego instancjonowanie ma miejsce w Warstwie Infrastruktury - w tym przypadku Kontrolerze HTTP. 

Przyjrzyjmy się najpierw samemu obiektowy JSON

{
	"email": "john.doe@gmail.com",
	"user_type": 2,
	"birth_date": "1992-11-05"
}

Wiedza jaką powinna dysponować klasa która będzie go odczytywać to:

  • jakie posiada klucze

  • jakiego typu wartości dany klucz może przechowywać

Warto zaznaczyć, że te informacje pochodzą z wspomnianego Outside World. Oto przykład klasy odpowiadającej za mapowanie JSON'a do obiektu Command:

 

namespace Infrastructure;

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Application\CreateUserCommand;

final class UserController 
{
	public function createUserAction(Request $request): Response 
	{
		/** @var array $payload */
		$payload = $request->getParsedBody();
		
		$command = new CreateUserCommand(
			$payload['email'],
			$payload['user_type'],
			new \DateTimeImmutable($payload['birth_date'])
		);
	
		// ...
	}
}

 

    Jak widać Kontroler z Warstwy Infrastruktury posiada wiedzę o strukturze zewnętrznego obiektu, ale jest to nieco naiwna implementacja. Nie możemy ufać klientowi, co do tego czy dostarczył prawidłową strukturę/dane. JSON może 💣 nie zawierać danego klucza bądź 💣 przechowywać nieoczekiwane wartości. Moglibyśmy ograniczyć się do sprawdzenia czy dany klucz istnieje, w przeciwnym wypadku zadając NULL oczekując tym samym wyrzucenia TypeError (przechwycony gdzieś wyżej):

 

try {
	$command = new CreateUserCommand(
		$payload['email'] ?? null,
		$payload['user_type'] ?? null,
		isset($payload['birth_date']) 
        	? new \DateTimeImmutable($payload['birth_date'])
            : null
	);
	// ...
} catch (TypeError $e) {
	// ...
}

 

Bądź użyć asercji (również rzucających wyjątkiem InvalidArgumentException), które bardziej opisowo przedstawiają warunki które ma spełniać poprawny Request Payload:

try {
	Assert::notEmptyString($payload['email'] ?? null);
	Assert::integerish($payload['user_type']  ?? null);
	Assert::stringDateTime($payload['birth_date']  ?? null);

	$command = new CreateUserCommand(
		$payload['email'],
		(int) $payload['user_type'],
		new \DateTimeImmutable($payload['birth_date'])
	);
	// ...
} catch (InvalidArgumentException $e) {
	// ...
}

 

Warto zauważyć, że wiedza na temat struktury JSON'a pozostaje na poziomie Warstwy Infrastruktury i nie przenika do Warstwy Aplikacji. Jak widać Akcja Kontrolera stała się dość obszerna ponieważ w przeważającej części zajmuje się tworzeniem poprawnego obiektu Command. Warto zastanowić się nad czytelniejszym rozwiązaniem.

Command z Metodą Fabryczną

    Można by pokusić się o przeniesienie kodu odpowiadającego za interpretację JSON'a (ze świata zewnętrznego) do samej klasy Command:

final class CreateUserCommand 
{
	public function __construct(
		public readonly string $email,
		public readonly int $type,
		public readonly \DateTimeImmutable $birthDate
	) {} 
	
	/** 
	 * @param array<int|string, mixed> $payload 
	 * @throws InvalidArgumentException on invalid payload
	 */
	public static function createFromRequest(array $payload): self 
	{
		Assert::notEmptyString($payload['email'] ?? null);
		Assert::integerish($payload['user_type']  ?? null);
		Assert::stringDateTime($payload['birth_date']  ?? null);
		
		return new self(
			$payload['first_name'],
			(int) $payload['user_type'],
			new \DateTimeImmutable($payload['birth_date'])
		);
	}
}

 

    Znacznie uprościło by to implementację Kontrolera ponieważ utworznie obiektu Command sprowadzało by się tylko do jednej linii...

$command = CreateUserCommand::createFromRequest($payload);

...Ale

Warstwa Aplikacji byłaby zależna od JSON'a z poza granic aplikacji. Na każdą zmianę struktury JSON'a (Warstwa Infrastruktury) np. zmiany nazwy klucza z snake_case na camelCase, musielibyśmy modyfikować byt z rdzenia aplikacji w postaci Command'a Aplikacyjnego. Pomimo, że nazwa klucza może się wydawać tylko niewinnym string'iem to tak naprawdę jest to zależność, a w tym przypadku - błędnie ukierunkowana zależność.

Command Aplikacyjny jest bezpośrednio zależny od Warstwy Infrastruktury.

Command posiada od teraz dwie odpowiedzialności: przechowywanie danych & mapowanie obiektu JSON na swój stan. Dwie odpowiedzialności === dwa powody do zmian.  

❌ dodanie obsługi kolejnych mediów przekazu (np. CLI Command) sprawia, że klasa staje się coraz większa.

Pragmatyzm

    Jak wynika z analizy powyżej, umieszczanie kodu interpretującego JSON'a w Warstwie Aplikacji to rozwiązanie łamiące zasady czystego kodu. Z drugiej strony pozostawienie go w Kontrolerze HTTP też nie wydaje się słuszne. W nadchodzącym wpisie zostanie opisane jeszcze jedno rozwiązanie: wprowadzenie klasy Fabrycznej odpowiadającej za powiązanie JSON'a z Command'em. Można zastanowić się czy tworzenie nowej klasy dla tego typu zadania nie jest przerostem formy nad treścią. Jeżeli patrzymy tylko pod kątem czytelności, to dodanie Metody Fabrycznej do Command'a wydaje się zadowalające ponieważ w jednym miejscu mamy powiązane ze sobą zagadnienia. Dodatkowo programiści, jeżeli będą chcieli utworzyć nową instancję Command'a nie będą musieli szukać dodatkowych Klas Fabrycznych - wszystko mają pod ręką. 

    Z drugiej strony, gdy zależy nam na utrzymaniu odpowiedniego kierunku zależności - Metoda Fabryczna nie spełnia tego wymagania. Zależności do Warstwy Infrastrukturyniejawne i subtelne ponieważ opierają się jedynie na string'owym kluczu array'a otrzymanego jako parametr. Nie zmienia to jednak faktu, że jakakolwiek zmiana w obiekcie JSON będzie musiała być naniesiona w Warstwie Aplikacji. Wymienione wyże typy array i string sprawiają wrażenie, że nie niosą ze sobą obciążenia zależnościami. Gdyby były reprezentowane przez Value Object'y z Warstwy Infrastruktury, zależności stałby się jawne i od razu widoczne w sekcji use klasy CreateUserCommand.   

środa, 19 maja 2021

SOLID - zasada Open/Closed w praktyce

 

"Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification"

 

    Analiza dotyczyła będzie rozbudowania kodu o nową funkcjonalność bez  modyfikowania istniejących klas. Spotkałem się z sytuacją gdzie klasa budująca model domenowy na podstawie danych wyciągniętych z bazy danych powinna też tworzyć całe kolekcje takich modeli. Można by:

  1.  zmodyfikować klasę budowniczego dodając nową metodę buildCollection(int ...$ids): array,

  2.  istniejącą metodę budowniczego build($id): Model wywołać w pętli w klasie klienta,

Wiele zapytań do bazy

    Drugie rozwiązanie z perspektywy zasady Open/Closed wydaje się lepsze bo nie wymaga od nas modyfikacji istniejącego kodu, ale wiąże się z nim pewien problem. W ciele metody Builder::build wykonywane jest zapytanie do bazy danych (RepositoryInterface::featchById) przez co im więcej obiektów do utworzenia tym gorzej dla optymalności rozwiązania. 

Jedno zapytanie do bazy

    Można zaimplementować jednak rozwiązanie nie ingerujące w istniejący kod wykonujące tylko jedno zapytanie do bazy. Dzięki temu, że klasa Builder posiada wiedzę tylko o interfejsie repozytorium możemy utworzyć kolejną implementację RepositoryInterface - InMemoryRepository.

InMemoryRepository posiada naprawdę prosta implementację i to na jej podstawie będzie tworzony obiekt Builder w klasie klienta zwracającego kolekcje modeli.

 
final class InMemoryRepository implements RepositoryInterface 
{
	/**
     * @param Row[] $rows
     */
	public function __construct(private array $rows) {}
	
	public function featchById($id): ?Row 
    {
		return $this->rows[$id] ?? null; 
	}
	
	/** 
     * @return Row[] 
     */
	public function featchByIds(int ...$ids): array 
    {
		return array_filter(
			$this->rows,
			static fn (Row $row): bool => in_array($row->id(), $ids);  
		);
	}
}

Pozostaje tylko napełnić instancję InMemoryRepository danymi z już istniejącego PDORepository

 

Query: GetModel (dotychczasowe) 

    Istniejąca funkcjonalność pobierania tylko jednego modelu zamknięta jest w klasie GetModel która realizuje tą funkcjonalność na podstawie obiektu Builder pobranego z kontenera zależności.

Odbywa się tu tylko jedna istotna czynność:

$this->builder->build($id);
 

Query: GetModelCollection (nowe)

W przeciwieństwie do poprzedniej klasy GetModel tutaj będziemy bezpośrednio operować na PDORepository (pod przykrywką RepositoryInterface). Dzieje się tutaj o wiele więcej niż w poprzednim przypadku:

  1. pobranie danych z bazy RepositoryInterface::featchByIds,
  2. utworzenie na ich podstawie instancji InMemoryRepository,
  3. utworzenie instancji Builder na podstawie InMemoryRepository,
  4. wywołanie metody Builder::build w pętli.
/** 
 * @return Model[]
 */
public function __invoke(int ...$ids): array
{
	$builder = new Builder(
		new InMemoryRepository(
			$this->repository->featchByIds(...$ids)
		)
	);
	
	return array_map([$builder, 'build'], $ids);
} 

Podsumowanie

  • ✅ Nie zmodyfikowaliśmy żadnej istniejącej klasy,
  • nowa funkcjonalność bazuje na działających już wcześniej klasach,
  • ❌ kolejne użycie słowa kluczowego "new" dla klasy Builder,
  • Repository::fetchByIds musi być idealnie zgrane z wywołaniem Builder::build() w pętli. Repository musi dawać gwarancję, że zawsze zwracany jest komplet danych, w przeciwnym razie powinien wyrzucać wyjątek @throws NotFoundAllIds

    Dzięki takiej implementacji nie ingerowaliśmy w działający koda, a do nowej funkcjonalności wykorzystaliśmy sprawdzone/przetestowane klasy. Dodatkowo rozwiązanie jest całkowicie odseparowane (nowa klasa Query) dzięki temu w całości zachowaliśmy funkcjonalność pobierania pojedynczego modelu.

    Takie podejście uwydatnia swoje zalety gdy proces budowania modelu w budowniczym jest skomplikowany a sam Builder posiada wiele zależności.