Wszyscy dookoła mówią, że encje powinny zawierać wyłącznie logikę biznesową, a nie settery. Niby wszyscy to wiedzą, ale każdy i tak robi te settery. Ja wiem, dlaczego tak się dzieje i z chęcią Wam o tym opowiem 🙂

Rola domeny w aplikacjach biznesowych

Encje należą do warstwy domeny, której charakterystyką jest odwzorowanie wszystkich reguł panujących w biznesie. Regułami biznesowymi nazywamy zależności obiektów między sobą – to w jaki sposób się ze sobą komunikują. Kiedy powiążemy to z informacją, że w programowaniu obiektowym rolę komunikatów przejmują metody, to wyjdzie nam na to, że encje powinny być bogate w metody. Przyjrzyjmy się poniższej encji:

class BlogPost
{
    protected int $id;

    protected string $name;

    protected \DateTimeImmutable $publicationDate;

    /* ... */

    public function getId(): int { /* ... */ }

    public function getName(): string { /* ... */ }

    public function setName(string $name): void { /* ... */ }

    public function getPublicationDate(): \DateTimeImmutable { /* ... */ }

    public function setPublicationDate(string $publicationDate): void { /* ... */ }

    public function getAuthor(): User { /* ... */ }

    public function setAuthor(User $author): void { /* ... */ }

    public function setStatus(string status): void { /* ... */ }

    public function getStatus(): string { /* ... */ }
}

Pomimo, że mamy encję bardzo bogatą w metody, to jednak reguł biznesowych tam nie znajdziemy. Tego typu model nazywamy modelem anemicznym. Wszystkie reguły biznesowe w tym momencie przechodzą na poziom serwisów, gdzie prawdopodobnie zostaną wymieszane z warstwą infrastruktury. W sumie, na jedno by wyszło, gdybyśmy pozbyli się setterów i getterów, a wszystkie właściwości zrobilibyśmy publiczne.

Dla kontrastu, spójrzmy na model, który posiada lepszy zestaw metod:

class BlogPost
{
    protected int $id;

    protected string $name;

    protected DateTimeImmutable $publicationDate;

    /* ... */

    public function getId(): int { /* ... */ }

    public function getName(): string { /* ... */ }

    public function getAuthorName(): User { /* ... */ }

    public function isPublished(): bool { /* ... */ }

    public function publishNow(): void { /* ... */ }

    public function publishOnDate(DateTimeInterface $publishDate): void { /* ... */ }

    public function convertToDraft(): void /* ... */ }
}

Już podczas pierwszego czytania encji możemy stwierdzić, co możemy z nią zrobić. I tak to właśnie powinno wyglądać. Podczas modelowania warstwy domeny powinniśmy kierować się wzorcem CQS (Command-Query-Separation) oraz Prawem Demeter. Szczególnie istotną rolę będzie tutaj odgrywało Prawo Demeter, które bardzo pozytywnie wpłynie na jakość agregatów.

Oczywiście, wszyscy wiemy jak konstruować dobre encje. I staramy się to robić dobrze. Ale pomimo tego jest coś, co nie przepuszcza nas dalej. Coś, co niejako zmusza nas do dostawienia tych wstrętnych setterów i getterów.

Proces konstrukcji encji w Doctrine 2

We wpisie o cyklu życia encji w Doctrine 2 poruszyłem kwestię, która pojawia się czasami jako nieprawdziwy, w dyskusji trudny do zanegowania argument. Mam na myśli to, że to Doctrine zmusza nas do implementacji setterów i getterów, aby mieć dostęp do propertiesów encji.

Powyższą tezę można zanegować, powołując się na funkcję automatycznego tworzenia przez Doctrine klas proxy, które dziedziczą po encjach. Te specjalne klasy wymuszają na nas dostęp do propertiesów na poziomie co najmniej protected, ponieważ na nich operują. Gdyby tak na prawdę Doctrine potrzebował setterów lub getterów dla każdego pola – na pewno dogenerowałby je sobie na poziomie klas proxy.

Drugim argumentem przeciw tej tezie jest nic innego, jak zbadanie tego, w jaki sposób Doctrine odtwarza stan encji po pobraniu danych z repozytorium. Ja ostatnio tego typu śledztwo przeprowadziłem i mogę powiedzieć, że do tego procesu settery nie są w ogóle potrzebne. Jeżeli chcecie dowiedzieć się, w jaki sposób z odtwarzaniem stanu encji radzi sobie Doctrine, to ponownie zachęcam Was do lektury wpisu o cyklu życia encji.

Wszystkiemu winne… formularze!

Dobra, nagłówek wszystko zdradził 😀 No, ale oprócz tego, że winne są formularze, fajnie by było dowiedzieć się dlaczego. No i pozostaje do rozwiązania również kwestia, czy jesteśmy w stanie coś z tym fantem zrobić. Ale – lećmy po kolei.

Najlepiej jest zacząć od przykładu. Przeanalizujmy więc kodzik, w którym bawimy się w obsługę formularza:

final class FormTestingController
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private FormFactoryInterface $formFactory,
        private Environment $twig,
    ) {

    }

    public function __invoke(Request $request): Response
    {
        $blogPost = $this->entityManager
            ->getRepository(BlogPost::class)
            ->findOneBy([]);

        $form = $this->formFactory->createBuilder(FormType::class, $blogPost, [
            'csrf_protection' => false,
            'data_class' => BlogPost::class,
        ])->add('name', TextType::class)
          ->add('save', SubmitType::class, ['label' => 'Create Blog'])
          ->getForm();

        $form->setData($blogPost);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $data = $form->getData();

            dd('form processing here...');
        }

        return new Response($this->twig->render("form.html.twig", [
            'form' => $form->createView(),
        ]));
    }
}

Mamy tutaj prosty kontroler, który pobiera encję (dla uproszczenia – dowolną), którą następnie wiąże z formularzem. Ten – również dla uproszczenia przykładu – budujemy za pomocą FormBuildera. W momencie, kiedy uzupełniony formularz zostaje wysłany, możemy robić jakieś operacje. My na potrzeby tego wpisu zostawiamy tam dd’ka, ale stanowczo bardziej polecam zabawę xDebugiem. Oprócz obsługi formularza, pozostaje kwestia jego wyświetlenia w szablonie (patrzcie poniżej):

{# templates/form.html.twig #}

{{ form(form) }}

Zaawansowanie widoku powala, ale na potrzeby wpisu wystarczy 🙂 . Kontynuując, encja, na której pracujemy wygląda następująco:

class BlogPost
{
    protected int $id;

    protected string $name;

    public function getId(): int
    {
        return $this->id;
    }

    public function setName(string $name): void
    {
        dd('Dumping the use of setter here...');

        $this->name = $name;
    }

    public function getName(): string
    {
        return $this->name;
    }
}

Mamy tutaj ultra prosty, anemiczny wręcz model z dumpem w środku settera. Obydwa dumpy (w kontrolerze oraz encji) mają na celu dać nam informację, kiedy wykorzystany zostanie setter. Podejrzewam, że wszyscy będziemy zgodni, twierdząc, że po wysłaniu formularza pojawi nam się dump z encji. Bo tak właśnie będzie. Debugując (pamiętajcie, o wiele łatwiej jest to robić z xDebugiem!) wyjdzie nam, że za wszystko jest odpowiedzialna poniższa linijka:

form->submitReqiest($request);

Oczywiście, mówimy tylko o scenariuszu, kiedy wyślemy formularz 😉

Kopiemy, kopiemy i…

Kiedy tak będziemy debugować, szybko dotrzemy do miejsca, w którym Symfony mapuje formularze z encją:

// https://github.com/symfony/form/blob/6.3/Form.php#L546

if (\count($this->children) > 0) {
    // Use InheritDataAwareIterator to process children of
    // descendants that inherit this form's data.
    // These descendants will not be submitted normally (see the check
    // for $this->config->getInheritData() above)
    $this->config->getDataMapper()->mapFormsToData(
        new \RecursiveIteratorIterator(new InheritDataAwareIterator($this->children)),
        $viewData
    );
}

Wchodząc nieco głębiej dotrzemy do metody mapFormsToData (dalej z paczki symfony/form):

// https://github.com/symfony/form/blob/6.3/Extension/Core/DataMapper/DataMapper.php#L57

public function mapFormsToData(\Traversable $forms, mixed &$data): void
{
    if (null === $data) {
        return;
    }

    if (!\is_array($data) && !\is_object($data)) {
        throw new UnexpectedTypeException($data, 'object, array or empty');
    }

    foreach ($forms as $form) {
        $config = $form->getConfig();

        // Write-back is disabled if the form is not synchronized (transformation failed),
        // if the form was not submitted and if the form is disabled (modification not allowed)
        if ($config->getMapped() && $form->isSubmitted() && $form->isSynchronized() && !$form->isDisabled() && $this->dataAccessor->isWritable($data, $form)) {
            $this->dataAccessor->setValue($data, $form->getData(), $form);
        }
    }
}

Dalej dotrzemy do interfejsu DataAccessorInterface (klik), który poprowadzi nas do klasy PropertyPathAccessor (klik). Do tego miejsca wszystko odbywa się w paczce symfony/form. I w zasadzie, to wszystko powyższe jest mało istotne w porównaniu do tego, co dzieje się dalej. Bo interesujące jest to, że dalej wychodzimy już poza paczkę formularzy:

// https://github.com/symfony/form/blob/6.3/Extension/Core/DataAccessor/PropertyPathAccessor.php

namespace Symfony\Component\Form\Extension\Core\DataAccessor;

/* ... */

use Symfony\Component\PropertyAccess\PropertyAccessorInterface;

/* ... */

class PropertyPathAccessor implements DataAccessorInterface
{
    private PropertyAccessorInterface $propertyAccessor;

    /* ... */

    public function setValue(object|array &$data, mixed $value, FormInterface $form): void
    {
        if (null === $propertyPath = $form->getPropertyPath()) {
            throw new AccessException('Unable to write the given value as no property path is defined.');
        }

        // If the field is of type DateTimeInterface and the data is the same skip the update to
        // keep the original object hash
        if ($value instanceof \DateTimeInterface && $value == $this->getPropertyValue($data, $propertyPath)) {
            return;
        }

        // If the data is identical to the value in $data, we are
        // dealing with a reference
        if (!\is_object($data) || !$form->getConfig()->getByReference() || $value !== $this->getPropertyValue($data, $propertyPath)) {
            $this->propertyAccessor->setValue($data, $propertyPath, $value);
        }
    }
}

Uwaga, teraz patrzymy na ostatnią instrukcję z wykorzystaniem $this->propertyAccessor. W tym miejscu wychodzimy poza paczkę symfony/form i wchodzimy w symfony/property-access. PropertyAccess Jest komponentem Symfony, który odpowiada za operacje związane z odczytywaniem i zapisywaniem wartości do propertiesów klas oraz tablic za pomocą notacji tekstowej. Ta paczka jest skonfigurowana w symfony/form i jest bezpośrednio odpowiedzialna za to, w jaki sposób zapisujemy wartość do propertiesów encji z naszego przykładu.

Zrobiłem eksperyment, w którym wzbogaciłem encję BlogPost o dodatkowe metody:

class BlogPost
{
    protected int $id;

    public string $name;

    public function getId(): int
    {
        return $this->id;
    }

    public function setName(string $name): void
    {
        dd('Dumping the use of setter here...');

        $this->name = $name;
    }

    public function __set(string $key, $value): void
    {
        if ($key === 'name') {
            dd('Dumping the use of __set(...) method');
        }
    }

    public function getName(): string
    {
        return $this->name;
    }
}

Do tego pomanipulowałem trochę specyfikatorem dostępu pola name. Powyższe pomogło mi zrozumieć, w jaki sposób symfony/property-access będzie próbował dostać się do propertiesów w encji. Efekt jest następujący:

  • Jeżeli properties jest publiczny, to najpierw będzie próbował dostać się do settera (setName(...)), a jeżeli ten nie będzie dostępny, to będzie próbował zapisać wartość bezpośrednio do propertiesa.
  • Jeżeli properties nie jest publiczny, to najpierw będzie próbował dostać się do settera (setName(...)). Jeżeli ten nie będzie dostępny, to dalej będzie próbował odwołać się do pola (podobnie jak wyżej). Ponieważ to nie będzie publiczne, a my implementujemy metodę __set(...), to ona właśnie zostanie uruchomiona.
  • W przypadkach innych niż powyższe dostaniemy komunikat o błędzie.

Ponieważ encje są klasami mocno domenowymi (o czym było na początku wpisu), to opcja z polem publicznym odpada. Pozostaje nam opcja pola typu protected, oraz uzależnienie się od setterów, nie ważne w jakiej formie. Ale – czy aby na pewno? Na to pytanie odpowiemy sobie pod koniec, ale zanim to – zostaje do wyjaśnienia jeszcze jedna kwestia.

Co jeszcze uzależnia nas od setterów?

Z perspektywy odkrycia, że za wszystkim stoi symfony/property-access, należy poruszyć kwestię, czy jeszcze gdzieś Symfony zaopatrza nas w podobnego rodzaju atrakcje. Postanowiłem sprawdzić większość powszechnie wykorzystywanych komponentów Symfony, czy mają w swoich zależnościach dowiązanie do tej paczki.

Sprawdziłem:

Jak możemy zauważyć, wyłącznie symfony/form korzysta z symfony/property-access w kontekście kodu produkcyjnego. Jest kilka paczek, które korzystają z tego w trybie dev, ale o to bym się nie martwił. Podejrzewam, że korzystają z tego w celu zrobienia fixturek lub usprawnień w testach automatycznych. Czyli wygląda na to, że poza formami jesteśmy w tej kwestii bezpieczni.

Czy jesteśmy skazani na settery i gettery?

Tak, jest rozwiązanie, które pomoże nam pozbyć się tych niechcianych metod z encji. Kwestia wyjaśnienia co gdzie jak i dlaczego nie jest związana z żadną konfiguracją formów.

Podłożem problemu jest przemieszanie ze sobą dwóch warstw – Interfejsu Użytkownika oraz Domeny (więcej o warstwach przeczytacie w tym wpisie). Formularze Symfony należą do warstwy Interfejsu Użytkownika, czyli tej samej, co kontrolery. Podobnie do kontrolerów, działają one wyłącznie w kontekście HTTP, czyli tam, gdzie mamy kontekst żądania wysłanego z przeglądarki. Ich rola powinna ograniczyć się wyłącznie do przetworzenia danych przesłanych przez formularz, oraz – jak wiadomo – wyświetlenia samego formularza w szablonie.

Zgodnie z architekturą warstwową, formularze (czyli warstwa Interfejsu Użytkownika) nie powinna mieć bezpośredniego dostępu do warstwy Domeny. Wszystko powinno odbywać się przez pośrednictwo warstwy Aplikacji. Dlatego, zamiast korzystać w formularzach z obiektów encji, powinniśmy utworzyć dodatkowy namespace typu FormModel dla klas DTO, których zadaniem będzie jedynie przechowywanie tego, co zostało wysłane w formularzu. I te klasy DTO mogą mieć pola publiczne, bądź pokryte w pełni setterami i getterami. Bo po ich stronie nie ma przestrzeni na logikę biznesową.

Dalszą kwestią jest to, w jaki sposób dalej połączymy warstwę aplikacji z formularzami. Mamy do wyboru użycie serwisu aplikacyjnego, który odgrodzi formularze od tego, co dzieje się z elementami domeny. Możemy również skorzystać z CQRSa, utworzyć komendę, którą natępnie wyślemy na szynę. Wszystko, co dalej związane jest z domeną powinno opierać się już wyłącznie o utworzone niezmienniki, czyli po naszemu – te ładnie zamodelowane funkcje encji, które jednak po coś zostały stworzone.

Profesjonalista, zajmujący się na co dzień aplikacjami biznesowymi w ekosystemie PHP. Jego pasją jest odkrywanie nowych konceptów programistycznych oraz wzorce architektoniczne. Uwielbia również pisać testy, gdyż jak sam uważa, dobry kod to przetestowany kod.

Comments are closed.