Kiedyś, podczas sesji Event Stormingu, w której brałem udział kilka lat temu, co chwilę padało zdanie „A to wyłapie jakaś polityka”. Następnie naklejaliśmy fioletową karteczkę. Wtedy nie było dla mnie jasne, czym są polityki w kontekście Stormingu oraz DDD. Dzisiaj już to wiem i chcę tą wiedzą podzielić się z Wami 🙂

Zrozumieć Politykę w DDD

Na samym początku powinniśmy odpowiedzieć sobie na pytanie, czym w ogóle są polityki w kontekście Domain Driven Design. Jeżeli weźmiemy na tapet definicję, to dowiemy się, że polityka jest sposobem rozwiązania problemu w zależności od kontekstu.

Najprościej o polityce jest mówić w domenie e-commerce, gdzie możemy mieć np. politykę cenową. Przykładowo, dla klienta, który składa właśnie pierwsze zamówienie w systemie, możemy mieć rabat 15%. Dla klienta, który już u nas coś zamówił – brak rabatu. Ale jeżeli mamy podpisaną umowę z klientem o współpracy rozszerzonej (np. jako reseller), to wtedy dajemy mu 10% rabatu na każde zamówienie. Możemy zauważyć, że mamy tutaj sposób rozwiązania problemu, jakim jest przyznanie rabatu, na podstawie kryterium klienta.

Innym przykładem polityki może być próba ściągnięcia środków na poczet kolejnego okresu rozliczeniowego aplikacji SaaS. Możemy mieć politykę, która definiuje, jak często próbujemy zapukać do konta klienta, kiedy ten nie ma środków na koncie. Możemy wysłać pierwsze trzy żądania w przeciągu godziny, następne trzy co 12 godzin, a ostatnie trzy żądania co 24 godziny. Jeżeli któreś żądanie pobierze środki z konta klienta, to wtedy polityka rezygnuje w wysłania kolejnych requestów.

Innym rodzajem polityki może być obsługa zakupów w oparciu o karty podarunkowe. Po zrobieniu zakupów za taką kartę, jakaś polityka wyłapuje to zdarzenie, po czym coś robi na tej karcie. W zależności od rodzaju karty, możemy ściągnąć środki z karty (jeżeli walidacja stwierdziła, że jest ich na tyle). Możemy wejść na ujemne saldo na karcie, bądź po prostu zablokować kartę zakupową po pierwszym zrealizowanym zakupie. Za tego typu decyzje również odpowiada polityka.

Polityka a kod aplikacji w PHP

Z perspektywy kodu polityka to nic innego jak serwis. Najlepiej, aby był to np. wzorzec strategii, który jest otwarty na rozszerzanie. Jeżeli chcesz przeczytać o innych wzorcach zgodnych z Open-Close Principle, zaglądnij do innego mojego wpisu: Wzorce projektowe przyjazne Open-Close Principle cz.1. A wracając do kodu, to przykładowa polityka może mieć następujący kształt:

<?php

namespace Application\Component\CartPromotion\Domain\Policy\CustomerBasedPromotion;

// ...

class CustomerBasedPromotionPolicy
{
    public function __construct(
        /** @var CustomerBasedPromotionPolicyRuleInterface[] */
        private readonly array $policyRules,
    ) {
    }

    public function calculatePromotion(CustomerMetadata $customer): Promotion
    {
        foreach ($this->policyRules as $policyRule) {
            Assert::isInstanceOf($policyRule, CustomerBasedPromotionPolicyRuleInterface::class);

            if ($policyRule->supports($customer)) {
                return $policyRule->calculatePromotion($customer);
            }
        }

        return new Promotion(0, PromotionType::AllProducts);
    }
}

interface CustomerBasedPromotionPolicyRuleInterface
{
    public function supports(CustomerMetadata $customer): bool;

    public function calculatePromotion(CustomerMetadata $customer): Promotion;
}

class NewCustomerPromotionPolicy implements CustomerBasedPromotionPolicyRuleInterface
{
    public function supports(CustomerMetadata $customer): bool
    {
        return $customer->hasAnyOrder() === false;
    }

    public function calculatePromotion(CustomerMetadata $customer): Promotion
    {
        return new Promotion(0.15, PromotionType::CrossSellingProductsOnly);
    }
}

class PartnerPromotionPolicy implements CustomerBasedPromotionPolicyRuleInterface
{
    public function supports(CustomerMetadata $customer): bool
    {
        return $customer->hasPartnerAgreement() === true;
    }

    public function calculatePromotion(CustomerMetadata $customer): Promotion
    {
        return new Promotion($customer->getDiscount(), PromotionType::AllProducts);
    }
}

class EmployeePromotionPolicy implements CustomerBasedPromotionPolicyRuleInterface
{
    public function supports(CustomerMetadata $customer): bool
    {
        return $customer->worksForUs() === true;
    }

    public function calculatePromotion(CustomerMetadata $customer): Promotion
    {
        return new Promotion(0.15, PromotionType::AllProducts);
    }
}

Powyższa polityka wybiera promocję na podstawie tego, z jakim typem klienta mamy do czynienia. Jeżeli przyjdzie do nas biznes i powie, że polityka promocji w firmie się zmieniła i od teraz dla każdego klienta ma zostać wybrana najlepsza dostępna promocja, na którą się załapuje – zmieniamy tylko jedną klasę:

<?php

namespace Application\Component\CartPromotion\Domain\Policy\CustomerBasedPromotion;

// ...

class CustomerBasedPromotionPolicy
{
    public function __construct(
        /** @var CustomerBasedPromotionPolicyRuleInterface[] */
        private readonly array $policyRules,
    ) {
    }

    public function calculatePromotion(CustomerMetadata $customer): ?Promotion
    {
        $bestPromotion = new Promotion(0);
        foreach ($this->policyRules as $policyRule) {
            Assert::isInstanceOf($policyRule, CustomerBasedPromotionPolicyRuleInterface::class);

            if ($policyRule->supports($customer) === false) {
                continue;
            }

            $promotion = $policyRule->calculatePromotion($customer);
            if ($promotion->isBetterThan($bastPromotion) {
                $bestPromotion = $promotion;
            }
        }

        return $bestPromotion;
    }
}

Powyższy przykład przedstawia politykę, która równocześnie jest fabryką zwracającą odpowiedni obiekt klasy Promotion. Nie jest to jednak reguła – polityka oprócz zwracania obiektów domenowych równie dobrze może „zrobić” coś na agregacie. Ważne, aby operacja ta była wykonana za pośrednictwem jego rdzenia.

Reaktywność Polityki

Z jednej strony polityka jest serwisem, który na podstawie danych wejściowych jest w stanie zdecydować, czy powinien się załączyć, czy też nie. Z drugiej strony jest coś, co pozwala polityce „wyłapać” jakieś zdarzenie lub zareagować na coś w odpowiednim czasie. Na początku nie było łatwo mi to zrozumieć. Dziś już wiem, że to nie polityka reaguje. Reaguje jakaś część systemu, która może uruchomić taką politykę. Takimi częściami systemu są:

  • Command Handler, który jest wejściem procesu biznesowego
  • Event Listener, który reaguje na zdarzenia, które miały miejsce w systemie
  • CRON, który pozwala na cykliczne uruchamianie kodu, w którym będziemy mogli załączyć politykę

Jest jeszcze jedena pozycja, którą wypadało by w tym miejscu wspomnieć: Pani Kazia. Mam tu na myśli aktora, który korzysta z aplikacji. Tym aktorem może być osoba fizyczna, która jest zdolna do uruchamia procesów w aplikacji. Manualnie, bo manualnie, ale uruchamia je. Podobnie jak CRON, jednak z tą różnicą, że Pani Kazia robi to wtedy, kiedy chce, a CRON robi to cyklicznie. Oprócz człowieka aktorem uruchamiającym polityki w aplikacji może być również system zewnętrzny, który np. wysyła żądanie na wystawiony przez nas endpoint lub wysyła wiadomość przechwytywaną przez nasz skrypt konsumujący.

Polityka wstrzykiwana do agregatu

Polityki są serwisami, zatem mogą korzystać z dobrodziejstw, jakie niesie ze sobą kontener Dependency Injection. Możemy do polityki wstrzykiwać parametry czy inne serwisy. Tak jak możemy zobaczyć we wcześniejszym przykładzie – wstrzyknęliśmy reguły polityki przez kontener. A pamiętać należy, że polityki mogą być stanowczo bardziej zaawansowanymi regułami biznesowymi, niż te, które naskrobałem.

Oprócz tego, że polityka może być wykorzystana w mechanizmie reaktywnym (CRON, Event Listener czy Command Handler), to może również być wykorzystywana przez agregaty. W końcu możemy wyobrazić sobie proces biznesowy polegający zwrocie pozycji z zamówienia:

<?php

namespace Application\Component\CartPromotion\Domain\Entity;

// ...

class Order
{
    protected array $items;

    protected array $returns;

    /** @var $items<string, int> */
    public function returnItems(
        array $itemsToReturn,
        ReturnPolicy $returnPolicy,
    ): void {
        foreach ($itemsToReturn as $returnSku => $returnQuantity) {
            Assert::isString($returnSku);
            Assert::isInteger($returnQuantity);

            foreach ($this->items as $item) {
                if ($item->isForSku($returnSku) === false) {
                    continue;
                }

                $item->returnQuantity($returnQuantity);
                $this->returns[] = $returnPolicy->generateReturnDocument(
                    $item->getSku(), $returnQuantity, $item->hasVirtualShipment(),
                );
            }

            throw new LogicException(sprintf('Item %s has not been found', $returnSku));
        }
    }
}

Sam agregat zamówienia nie zna procesu zwrotu. Ten może być na tyle skomplikowany, że umieszczenie go w agregacie może nie być dobrym pomysłem. Do tego pamiętajmy, że polityka tworzenia dokumentów dot. zwrotów może być inna dla pozycji zamówienia na produkty fizyczne, a inna na pozycje zamówienia na produkty wirtualne. Wzorzec strategii, który kryje się pod polityką ReturnPolicy jest czymś o wiele czystszym niż seria warunków w obrębie metody w agregacie.

Jest tutaj jeszcze jedna wątpliwość, którą chciałbym wyjaśnić. Jedną z zasad agregatów jest ochrona granic agregatu, czyli nie zwracanie przez agregat swoich bebechów na zewnątrz. A w tym miejscu mamy politykę, która nie ma bezpośredniego dostępu do elementu składowego agregatu. I to jest zachowanie prawidłowe. Pamiętajmy, że agregat ma chronić wszystkie swoje składowe tak, aby żaden zewnętrzny mechanizm nie miał bezpośredniego wpływu na jego składowe.

Dodatkowo, polityki są niejednokrotnie stosowane do sterowania zachowaniami agregatów, stąd czasami można znaleźć polityki, do których przekazywany jest pełny agregat:

<?php

namespace Application\Component\CartPromotion\Domain\Entity;

// ...

class Order
{
    protected array $items;

    protected array $returns;

    /** @var $items<string, int> */
    public function returnItems(
        array $itemsToReturn,
        ReturnPolicy $returnPolicy,
    ): void {
        foreach ($itemsToReturn as $returnSku => $returnQuantity) {
            Assert::isString($returnSku);
            Assert::isInteger($returnQuantity);

            foreach ($this->items as $item) {
                if ($item->isForSku($returnSku) === false) {
                    continue;
                }

                $returnPolicy->performReturn($this, $returnSku, $returnQuantity);

                $this->returns[] = $returnPolicy->generateReturnDocument(
                    $item->getSku(), $returnQuantity, $item->hasVirtualShipment(),
                );
            }

            throw new LogicException(sprintf('Item %s has not been found', $returnSku));
        }
    }
}

Jak widzimy, do metody performReturn(...) przekazujemy obiekt agregatu, z którego możemy korzystać do woli.

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.