Blog

27 marca 2015 Jakub Filipczyk

Walcz z anemią

Walcz z anemią

Spotkałem się ze stwierdzeniem, że anemiczny model to taki, który jest złożony z obiektów zawierających settery i gettery do  wszystkich swoich właściwości. Według mnie jest to anemiczny opis anemicznego modelu :).

Czym jest anemia modelu domenowego?

Anemia oznacza niedobór, ale niedobór czego? Przyjrzyjmy się bliżej próbce kodu.

// create new invoice draft
$invoice = new Invoice();
$invoice->setStatus(new InvoiceStatus(InvoiceStatus::DRAFT));
$invoice->setCustomer($customer);
$invoice->setSeller($seller);
$invoice->setPaymentType($paymentType);
$invoice->setItems($items);

// issue an invoice
$invoice->setStatus(new InvoiceStatus(InvoiceStatus::ISSUED));
$invoice->setNumber($number);
$invoice->setIssueDate($issueDate);

Widać, że faktura to obiekt złożony z wielu parametrów, widać również, że można ten obiekt wprowadzić w różne stany poprzez modyfikację wybranych parametrów. Ile jest takich stanów? Przy założeniu, że każde pole może mieć konkretną wartość badź NULL to stanów mamy 2^[liczba parametrów]. W naszym przypadku 2^7 = 128. W ilu poprawnych stanach może znajdować się faktura z punktu widzenia domeny? – Zapewne kilku. Nasza aplikacja musi zatem wychwycić wszystkie błędne stany i rzucić wyjątek. Ponieważ obiekt faktury nie realizuje walidacji poprawności swojego stanu, to odpowiedzialność ta spoczywa na kodzie klienta operującego na fakturze.

Pierwszy objaw anemii – model nie odpowiada za spójność swoich danych. Kod klienta może wprowadzić go w niewłasciwy stan.

W naszym przykładzie widać, że faktura może być w dwóch stanach – wersja robocza (draft), wystawiona (issued). Co więcej, z punktu widzenia domeny, faktura zawsze ma dane sprzedawcy, nabywcy i formę płatności. Aktualny model faktury nie eksponuje takiego wymagania. Idąc dalej widać, że aby wystawić fakturę należy zmienić jej status, ustawić numer i datę wystawienia. Co, jeśli te trzy linijki w kodzie operującym, nie byłyby opatrzone komentarzem? Czy byłoby jasne, czemu zmieniane są akurat te trzy pola?

Drugi objaw anemii – model nie jest wyrazisty, nie widać intencji w jego API. Kod klienta musi wzbogacić operacje na modelu o wyrażenie intencji – poprzez komentarze lub otoczenie metodą o wyrazistej nazwie.

Walka z anemią

Spróbujmy rozwiązać oba te problemy. Załóżmy, że istnieje wymaganie mówiące, że faktura musi mieć sprzedawcę, nabywcę, sposób płatności i przynajmniej jedną pozycję. Skoro tak, to wystarczy wymagać w konstruktorze, aby zawsze podane były te parametry. Załóżmy też, że faktura nigdy nie może być utworzona od razu jako wystawiona, a jedynie w formie wersji roboczej.

class Invoice {

    function __construct(Customer $customer, Seller $seller, PaymentType $paymentType, InvoiceItemList $items) {
        if ($items->isEmpty()) {
            throw new InvalidArgumentException('At least one invoice item is required');
        }
        $this->status = new InvoiceStatus(InvoiceStatus::DRAFT);
        $this->customer = $customer;
        $this->seller = $seller;
        $this->paymentType = $paymentType;
        $this->items = $items;
    }
}

Pozostaje kwestia zamodelowania operacji wystawienia faktury. Nie chcemy wymagać od klienta, aby wiedział jakie parametry faktury powinien zmodyfikować, aby wprowadzić ją w poprawny stan wystawienia. Dodatkowo, model powinien pilnować, aby operacja ponownego wystawienia faktury już wystawionej zakończyła się błędem.

class Invoice {

    function issue(InvoiceNumber $number, Date $issueDate) {
        if ($this->status == InvoiceStatus::ISSUED) {
            throw new Exception('Invoice has been already issued');
        }
        $this->status = new InvoiceStatus(InvoiceStatus::ISSUED);
        $this->number = $number;
        $this->issueDate = $issueDate;
    }
}

Po tych zmianach kod operujący na modelu faktury wygląda tak:

$invoice = new Invoice($customer, $seller, $paymentType, $items);
$invoice->issue($number, $issueDate);

Problem dużej liczby parametrów konstruktora

W pierwotnej wersji model tworzony był etapami poprzez wywołania odpowiednich setterów. Kolejność ich wywoływania mogła być dowolna. W aktualnej wersji konstruktor wymaga podania 4. parametrów i łatwo można sobie wyobrazić, że mogłoby być ich jeszcze więcej.

Wywołanie wieloargumentowego konstruktora wymaga większego wysiłku od programisty i jest niewygodne, gdy w aplikacji jest kilka miejsc, gdzie konstruujemy obiekt faktury. Czy jest to kompromis, na który musimy się zgodzić rezygnując z anemicznego modelu? Nie. Istnieje wzorzec konstrukcyjny, który przychodzi z pomocą – Budowniczy (Builder)

class InvoiceBuilder {
    private $customer;
    private $seller;
    private $paymentType;
    private $items;

    public function setCustomer(Customer $customer) {
        $this->customer = $customer;
        return $this;
    }

    public function setSeller(Seller $seller) {
        $this->seller = $seller;
        return $this;
    }

    public function setPaymentType(PaymentType $paymentType) {
        $this->paymentType = $paymentType;
        return $this;
    }

    public function setItems(InvoiceItemList $items) {
        $this->items = $items;
        return $this;
    }

    public function build() {
        return new Invoice($this->customer, $this->seller, $this->paymentType, $this->items);
    }
}

Kod operujący na modelu:

$invoice = (new InvoiceBuilder())
    ->setCustomer($customer)
    ->setSeller($seller)
    ->setPaymentType($paymentType)
    ->setItems($items)
    ->build();

$invoice->issue($number, $issueDate);

Geneza

Na zakończenie wymienię kilka przyczyn powstawania anemicznych modeli:

Ograniczenia bibliotek ORM. Spotkałem się z bibliotekami typu ORM, które zakładały, że encje muszą mieć bezargumentowy konstruktor, co jest równoznaczne z ustawianiem wszystkich pól poprzez settery.

Ewolucja aplikacji CRUD. Istnieją aplikacje, które są, de facto, prostą nakładką na bazę danych, pozbawioną jakiejkolwiek logiki. Z czasem, gdy pojawiają się coraz to bardziej złożone wymagania, rozwija się logika biznesowa, ale nie w ramach modelu, a w kodzie na nim operującym.

Przeciek obiektów transportowych DTO. Warstwa prezentacji danych (GUI, WebService) może wymagać danych w postaci obiektów transportowych, które z definicji pozwalają na swobodną manipulację swoim stanem. Są to struktury danych. Może się zdażyć, że obiekty te “wyciekną” w głąb aplikacji i będa włączone do modelu domenowego.

Kopiowanie code snippets. Każda biblioteka udostępnia przykłady użycia. Wystarczy otworzyć dokumentację popularnych ORM – Doctrine, Propel, a ujrzymy takie przykłady tworzenia encji:

propel_anemic_snippet

Trzeba pamiętać, że code snippets najczęsciej mają na celu pokazać naprostsze przykłady użycia, a nie najlepsze praktyki.

The end

A czy Twoje aplikacje mają bogaty model domenowy? Może masz doświadczenie z wychodzeniem z anemii modelu? Czekam na komentarze.

Zobacz na blogu

09.09.2022
Marcin Jahn
It’s Not Just HTTP It’s Not Just HTTP

In today’s world of cloud-based solutions, distributed systems, and microservices-based architectures, network communication is a...

23.08.2022
Adam Mrowiec
Konferencja IPC 2022 Berlin Konferencja IPC 2022 Berlin

Pandemia wreszcie się kończy, dlatego w tym roku postanowiliśmy wrócić do naszych wyjazdów na konferencje....