Anemiczny model domeny – czyli jak zepsuć implementację logiki biznesowej

Zacznijmy od przypomnienia sobie podstawy z potwornie nudnych zajęć programowania obiektowego.
Jedną z głównych cech obiektowości jest hermetyzacja. Jak zawsze z pomocą przychodzi nam wikipedia:

Hermetyzacja
Czyli ukrywanie implementacji, enkapsulacja. Zapewnia, że obiekt nie może zmieniać stanu wewnętrznego innych obiektów w nieoczekiwany sposób. Tylko własne metody obiektu są uprawnione do zmiany jego stanu.

Ciekawym zjawiskiem jest to, że po studiach zaliczonych na piątkę, większość programistów zapomina o tej zasadzie. Powstają wtedy paskudne implementacje logiki biznesowej, które stają się bardzo ciężkie w utrzymaniu.

Przykładowa implementacja anemiczna

Weźmy pod uwagę implementację logiki zrealizowania zamówienia. Po pierwsze mamy obiekt Order, będący częścią naszej domeny biznesowej:

public class Order {
    private Integer id;
    private Status status;
    private Date completionDate;

    public Integer getId() {
        return id;
    }
    public void setId(Integer id) {
        this.id = id;
    }
    public Status getStatus() {
        return status;
    }
    public void setStatus(Status status) {
        this.status = status;
    }
    public Date getCompletionDate() {
        return completionDate;
    }
    public void setCompletionDate(Date completionDate) {
        this.completionDate = completionDate;
    }
}

Przystępując do realizacji logiki tworzymy metodę w serwisie np. OrderService:

public class OrderService {
    public void completeOrder(Integer id) {
        // wyciągamy order po id
        order.setStatus(Status.COMPLETE);
        order.setCompletionDate(new Date());
    }
}

Jest to standardowa realizacja anemiczna. Obiekt biznesowy został pozbawiony władzy nad swoim wewnętrznym stanem i de facto stał się czymś w rodzaju DTO. Cała logika biznesowa została zrealizowana w serwisie, nie w obiekcie, co bezpośrednio łamię hermetyzację.

Minusy anemicznego modelu

Na pierwszy rzut oka nie jest to karygodne rozwiązanie. Niestety w praktyce anemiczność powoduje znacznie większe szkody, niż mogło by się wydawać. Najgorszą rzeczą jest to, że taki “udawany” model obiektowy nie daje nam żadnych plusów, które zwykle wiążą się ze stosowaniem programowania obiektowego.
Konsekwencją tego jest między innymi:

  • wyciek logiki biznesowej poza model, co utrudnia jej utrzymywanie oraz przeszukiwanie,
  • drastyczne zwiększenie szansy na duplikację logiki w przyszłości,
  • eliminacja prostej reużywalności funkcjonalności w obrębie obiektu,
  • utrudnienie testowania jednostkowego, ponieważ musimy testować serwis + obiekt, nie sam obiekt,
  • zwiększenie szansy na nieoczekiwane i ciężkie do namierzenia zmiany w stanie obiektu.

Logika pisana w ten sposób z pewnością sprawi kłopoty w przyszłości. Moim zdaniem jest to jeden z głównych powodów powstawania systemów, które są trudne w utrzymaniu i rozwoju.

Jak w takim razie nie popełnić tego błędu?

Przeciwieństwem modelu anemicznego jest tzw. “rich domain model”. Mamy tutaj do czynienia z obiektami wypełnionymi logiką zmieniającą ich stan. W tym wypadku obiekt nie deklaruje setterów do zmiany stanu:

public class Order {
    private Integer id;
    private Status status;
    private Date completionDate;

    public void complete() {
        this.status = Status.COMPLETE;
        this.completionDate = new Date();
    }
    
    public Integer getId() {
        return id;
    }

    public Status getStatus() {
        return status;
    }

    public Date getCompletionDate() {
        return completionDate;
    }
}

Funkcjonalność została przeniesiona do obiektu, co fundamentalnie zmienia jej organizację. W taki sposób obiekt sam zarządza swoim stanem, a cała logika biznesowa ma swoje jasne miejsce. Niweluje to wszystkie problemy, o których była mowa przy okazji implementacji anemicznej.

Jedną z najciekawszych konsekwencji bogatego modelu, jest uproszczenie serwisów. W praktyce to właśnie serwisy mają najgorszą tendencję do przerostu. W tym wypadku serwis wykonuje tylko czynności techniczne – wyciągnięcie obiektu z bazy danych i proste wywołanie metody na obiekcie:

public class OrderService {
    public void completeOrder(Integer id) {
        // wyciągamy order po id
        order.complete();
    }
}

Podsumowując…

Moim zdaniem implementacja anemiczna jest tragedią, jeśli logika jest rozbudowania. Takie podejście być może przejdzie w systemach będącymi “przeglądarkami baz danych”, gdzie biznesu jest bardzo mało. W każdym innym wypadku prędzej, czy później spowoduje problemy. Implementacja bogatego modelu danych wymaga zmiany mentalności i dyscypliny w kodowaniu, ale z pewnością jest dobrą inwestycją.

BONUS: ciekawy problem z bogatym modelem danych

Jest jedna znana mi sytuacja, w której model w pełni obiektowy sprawia problemy. Załóżmy, że wymagania biznesowe zakładają akcję, która nie jest do zrealizowania w obrębie obiektu. Może to być np. wysłanie maila przy realizacji zamówienia. Obiekt Order nie potrafi wysłać maila. Czy w takim wypadku pozostaje nam popełnić anemię i zrealizować wysyłanie w serwisie?

public class OrderService {
    public void completeOrder(Integer id) {
        // wyciągamy order po id
        order.complete();
        mailSender.sendMail(// tutaj implementacja tworzenia tresci maila);
    }
}

Mamy tutaj do czynienia z wyciekiem logiki biznesowej poza obiekt do serwisu. Czy możemy to zrobić inaczej ?

Jedną z opcji jest podanie obiektu technicznego do obiektu biznesowego:

public class OrderService {
    public void completeOrder(Integer id) {
        // wyciągamy order po id
        order.complete(mailSender);
    }
}

To rozwiązanie jest już lepsze, bo zachowuje logikę wewnątrz obiektu biznesowego. Jednak nie jest to rozwiązanie idealne:

  • pojawia się problem z testowaniem, musimy mockować “mail sendera”,
  • uzależniamy model od technologi konkretnego frameworka.

Nie są to duże problemy, ale czy można to zrobić lepiej? Okazuje się, że wprowadzenie dodatkowej warstwy abstrakcji pomiędzy “mail sendera”, a model zdziała w takim przypadku cuda. W tym celu tworzymy interfejs np. MailDelegate:

public interface MailDelegate {
    void sendCompletionEmail(// tutaj wszystkie parametry do maila);
}

public class OrderService {
    public void completeOrder(Integer id) {
        // wyciągamy order po id
        order.complete(new SpringMailDelegate(mailSender));
    }
}

public class Order {
    private Integer id;
    private Status status;
    private Date completionDate;

    public void complete(MailDelegate mailDelegate) {
        this.status = Status.COMPLETE;
        this.completionDate = new Date();
        mailDelegate.sendCompletionEmail(// tutaj wszystkie parametry do maila);
    }

    public Integer getId() {
        return id;
    }

    public Status getStatus() {
        return status;
    }

    public Date getCompletionDate() {
        return completionDate;
    }

W ten sposób pozbyliśmy się zależności pomiędzy modelem i infrastrukturą, zwiększając testowalność i przenośność kodu biznesowego. Obiekt biznesowy nie ma świadomości konkretnej implementacji obiektu zajmującego się mailami. Takie rozwiązanie w całości zapobiega wyciekowi logiki i pozwala na czystą implementację tego typu problematycznych funkcjonalności.

Leave A Comment