Dlaczego gettery i settery są niebezpieczne? Anemic Domain Model vs Rich Domain Model

utworzone przez Clean Code, Java

Istnieje powód, dla którego korzystamy ze zmiennych prywatnych w naszych klasach. Nie chcemy ich udostępniać na zewnątrz i nie chcemy, aby ktoś na nich polegał. Może słyszałeś o tym, że publiczny dostęp do zmiennych w klasie nie jest poprawny. Zrobienie zmiennych prywatnymi, ale dodanie getterów i setterów do każdego pola nie poprawi sytuacji (ponieważ mamy taki sam dostęp do zmiennych – zupełnie jakby były publiczne) . Są natomiast sytuacje kiedy akcesory (gettery) i mutatory (settery) są niezbędne. W dzisiejszym wpisie przyjrzymy się bardziej szczegółowo często pomijanej kwestii – dla czego gettery i settery mogą być bardzo niebezpieczne oraz kiedy możesz je śmiało stosować. Następnie pokażę Tobie czym jest Anemic Domain Model oraz Rich Domain Model. Pokażę Tobie zalety bogatej domeny.

W tym artykule dowiesz się o:

  • Czym jest Anemic Domain Model?
  • Czym jest Rich Domain Model?
  • Jaka jest różnica pomiędzy Anemic Domain Model a Rich Domain Model?
  • Kiedy settety i gettery mogą być niebezpieczne?
  • Kiedy stosować settery i gettery?

Wydajność Hibernate

Twórz szybko działające aplikacje z wydajną i zoptymalizowaną obsługą bazy danych.

Ujawnienie szczegółów implementacji

Spójrz na poniższy przykład reprezentujący uproszczony model karty bankomatowej:

public class Card {
    public UUID cardId;
    public String cardNumber;
    public BigDecimal limit;
    public boolean isBlocked;
    public boolean isActive;
}

Powyższa klasa modelu karty ujawnia swoją implementację, co w tym przypadku jest bardzo niebezpieczne. Dlaczego? Możemy dowolnie manipulować wszystkimi polami w tej klasie. Bez żadnych konsekwencji możesz ustawić, że karta jest anulowana, a następnie zmienić jej limit (po zablokowaniu karty, wszelakie operacje na karcie powinny być wstrzymane). Kolejnym problemem może być zmiana identyfikatora karty w dowolnym momencie w kodzie. Przykłady rozhermetyzowania modelu można mnożyć.

Wiele się nie zmieni, jeśli dostęp do naszych zmiennych będzie prywatny, ale udostępnisz akcesory i mutatury – jak w przykładzie poniżej:

public class Card {

    private UUID cardId;
    private String cardNumber;
    private BigDecimal limit;
    private boolean isBlocked;
    private boolean isActive;

    public UUID getCardId() {
        return cardId;
    }

    public void setCardId(UUID cardId) {
        this.cardId = cardId;
    }

    public String getCardNumber() {
        return cardNumber;
    }

    public void setCardNumber(String cardNumber) {
        this.cardNumber = cardNumber;
    }

    public BigDecimal getLimit() {
        return limit;
    }

    public void setLimit(BigDecimal limit) {
        this.limit = limit;
    }

    public boolean isBlocked() {
        return isBlocked;
    }

    public void setBlocked(boolean blocked) {
        isBlocked = blocked;
    }

    public boolean isActive() {
        return isActive;
    }

    public void setActive(boolean active) {
        isActive = active;
    }
}

Lub czyściej – z pomocą Lombok:

@Getter
@Setter
// zamiast @Getter i @Setter można zastosować @Data
public class Card {
    private UUID cardId;
    private String cardNumber;
    private BigDecimal limit;
    private boolean isBlocked;
    private boolean isActive;
}

W powyższym kodzie również istnieje możliwość zmiany limitu karty tuż po jej anulowaniu. W skrócie udostępniamy wewnętrzną strukturę naszego obiektu.

Jaki wniosek płynie z powyższych przykładów? Jeśli nasza klasa nie jest prostym obiektem typu Data Transfer Object (DTO) – tylko odzwierciedla obiekt biznesowy – nie powinna po prostu przepychać danych przez settery i gettery. Zamiast tego, klasa może eksponować zachowanie, a nie stan obiektu.

Anemic Domain Model vs Rich Domain Model

Klasa ze stanem eksponowanym przez getery i settery to tzw. „anemiczny model” (Anemic Model). Swoją niechlubną nazwę zawdzięcza temu, że nie posiada ona żadnych konkretnych operacji biznesowych. Istnieje inne podejście do modelowania klas odzwierciedlających obiekty biznesowe: Rich Domain Model, czyli „Bogaty model”. Pokażę Ci różnice pomiędzy Rich Domain Model i Anemic Domain Model.

Anemic Domain Model

Anemiczny model danych to zwykły obiekt DTO, czyli klasa z prywatnymi polami oraz setterami i getterami. Przy anemicznym podejściu, nasze modele stają się tylko „kontenerami” na dane, a cała logika operacji na tych danych jest wyniesiona do klas typu: **Service, **Util, **Manager, **Helper. Funkcje w tych klasach zawierają większość logiki operacji na danych, które otrzymują w parametrze wejściowym. Sam obiekt domenowy jest rozhermetyzowany i dowolnie można manipulować jego polami. Aby nieco rozjaśnić – posłużę się przykładam karty bankomatowej i kilku operacji, które możemy na niej wykonać. Poniżej zaprezentowałem model karty bankomatowej:

@Getter
@Setter
// zamiast @Getter i @Setter można zastosować @Data
public class Card {
    private UUID cardId;
    private String cardNumber;
    private BigDecimal limit;
    private boolean isBlocked;
    private boolean isActive;
}

Omówmy biznes trzech serwisów, które będą wykonywały pewną logikę:

  • Karta powinna stworzyć się nieaktywna. Nie można zmienić jej limitów, płacić nią, ani jej zastrzec. Można ją jedynie aktywować.
  • Aktywacja nowo utworzonej karty, tylko wtedy, kiedy jest jeszcze nieaktywowana.
  • Blokowanie (zastrzeganie karty), kiedy jest aktywna. Można to zrobić tylko raz, a karta przestaje być aktywna,
  • Zmiana limitów karty, jeżeli jest aktywna.

Aktywacja karty

Pierwszy z serwisów nazwiemy CardActivationService. Jego zadanie to aktywacja nowej karty. Logika aktywacji karty jest następująca:

  • Nie można aktywować karty, która już jest aktywna lub zablokowana
  • Po aktywacji, domyślny limit ustawiamy na 500

Poniżej prezentuje przykładowy kod implementujący powyższą funkcjonalność.

public class CardActivationService {

    private final BigDecimal DEFAULT_CARD_LIMIT = BigDecimal.valueOf(500);
    private final CardRepository cardRepository;

    public CardActivationService(CardRepository cardRepository) {
        this.cardRepository = cardRepository;
    }

    public void activateCard(UUID cardId) {
        var card = cardRepository.getById(cardId)
                .orElseThrow(IllegalArgumentException::new);

        validateIsCardBlocked(card);
        validateIsCardActive(card);

        card.setActive(true);
        card.setLimit(DEFAULT_CARD_LIMIT);

        cardRepository.save(card);
    }

    private void validateIsCardBlocked(Card card) {
        if (card.isBlocked()) {
            throw new ActivateCardException();
        }
    }

    private void validateIsCardActive(Card card) {
        if (card.isActive()) {
            throw new ActivateCardException();
        }
    }
}

Blokowanie (zastrzeganie) karty

To kolejna funkcjonalność, której logika wygląda następująco:

  • Nie można zastrzec karty, która już została zastrzeżona lub jest nieaktywna

Poniżej prezentuje przykładowy kod, który implementuje powyższą logikę.

Psst… Interesujący artykuł?

Jeżeli podoba Ci się ten artykuł i chcesz takich więcej – dołącz do newslettera. Nie ominą Cię materiały tego typu.

.

public class CardBlockerService {

    private final CardRepository cardRepository;

    public CardBlockerService(CardRepository cardRepository) {
        this.cardRepository = cardRepository;
    }

    public void block(UUID cardId) {
        var card = cardRepository.getById(cardId)
                .orElseThrow(IllegalArgumentException::new);

        validateIsCardBlocked(card);
        validateIsCardNotActive(card);

        card.setBlocked(true);

        cardRepository.save(card);
    }

    private void validateIsCardNotActive(Card card) {
        if (!card.isActive()) {
            throw new BlockCardException();
        }
    }

    private void validateIsCardBlocked(Card card) {
        if (card.isBlocked()) {
            throw new BlockCardException();
        }
    }
}

Zmiana limitu karty

To ostatnia z trzech funkcjonalności, którą będziemy implementować w tym przykładzie. Wymagania biznesowe są takie:

  • Nie można ustawić limitu jeśli jest zerowy lub ujemny
  • Nie można ustawić limitu kiedy karta jest nieaktywna lub zastrzeżona

Poniżej prezentuje kod przykładowego rozwiązania:

public class CardLimiterService {

    private final CardRepository cardRepository;

    public CardLimiterService(CardRepository cardRepository) {
        this.cardRepository = cardRepository;
    }

    public void changeCardLimit(UUID cardId, BigDecimal newLimit) {
        var card = cardRepository.getById(cardId)
                .orElseThrow(IllegalArgumentException::new);

        validateIsCardBlocked(card);
        validateIsCardNotActive(card);
        validateCardLimit(newLimit);

        card.setLimit(newLimit);

        cardRepository.save(card);
    }

    private void validateCardLimit(BigDecimal newLimit) {
        requireNonNull(newLimit, "LimitNotProviced");
        if (newLimit.compareTo(BigDecimal.ONE) < 0) {
            throw new ChangeCardLimitException();
        }
    }

    private void validateIsCardNotActive(Card card) {
        if (!card.isActive()) {
            throw new ChangeCardLimitException();
        }
    }

    private void validateIsCardBlocked(Card card) {
        if (card.isBlocked()) {
            throw new ChangeCardLimitException();
        }
    }
}

A więc mamy prosty, anemiczny model domenowy Card, który nie zawiera żadnej logiki. Mamy również 3 serwisy (CardActivationService, CardBlockerService, CardLimiterService) w których znajduje się cała logika operacji aktywacji, blokowania oraz zmiany limitu karty.

Co może być nie tak z powyższym kodem? W końcu to bardzo popularna metoda programowania (dużo anemicznych dto i jeszcze więcej serwisów). Aby Ci to lepiej zobrazować – przedstawię Ci bogaty model domeny, a dopiero później przejdziemy do wniosków.

Rich Domain Model

Spójrzmy teraz na ten sam przykład w podejściu bogatej domeny. Limit karty możemy zmienić ze zwykłego pola typu BigDecimal na obiekt Value. Ma to taką zaletę, że walidacje limitu możemy przenieść do obiektu CardLimit. Nie będziemy w stanie stworzyć błędnego limitu karty (np. ujemnego). Nie będziemy też mogli zmodyfikować limitu po jego utworzeniu.

public class CardLimit {
    private final BigDecimal value;

    private CardLimit(BigDecimal value) {
        requireNonNull(value, "Limit not provided");
        checkArgument(value.compareTo(ZERO) >= 0, "Limit must be zero or positive.");
        this.value = value;
    }

    public static CardLimit of(final BigDecimal limit) {
        return new CardLimit(limit);
    }
}

Poniżej pokażę Tobie kod bogatej domeny karty:

public class Card {

    private static final CardLimit DEFAULT_CARD_LIMIT = CardLimit.of(BigDecimal.valueOf(500));

    private UUID cardId;
    private String cardNumber;
    private CardLimit limit;
    private boolean isBlocked;
    private boolean isActive;

    private Card(UUID cardId, String cardNumber, CardLimit limit, boolean isBlocked, boolean isActive){
        this.cardId = cardId;
        this.cardNumber = cardNumber;
        this.limit = limit;
        this.isBlocked = isBlocked;
        this.isActive = isActive;
    }

    public static Card create(UUID cardId, String cardNumber) {
        var startLimit = DEFAULT_CARD_LIMIT;
        var isBlocked = false;
        var isActive = false;
        return new Card(cardId, cardNumber, startLimit, isBlocked, isActive);
    }

    public void block() {
        checkState(canCardBeBlocked(), "Card can not be blocked");
        this.isBlocked = true;
    }

    public void changeCardLimit(CardLimit newLimit) {
        checkState(canChangeCardLimit(), "Can not change card limit");
        this.limit = newLimit;
    }

    public void activate() {
        checkState(canCardBeActivate(), "Card can not be activate");
        this.isActive = true;
        this.limit = CardLimit.of(DEFAULT_CARD_LIMIT);
    }

    public UUID getCardId() {
        return cardId;
    }

    public String getCardNumber() {
        return cardNumber;
    }

    public CardLimit getLimit() {
        return limit;
    }

    private boolean canChangeCardLimit() {
        return isActive && !isBlocked;
    }

    private boolean canCardBeBlocked() {
        return isActive && !isBlocked;
    }

    private boolean canCardBeActivate() {
        return !isActive && !isBlocked;
    }
}

Warto jest wspomnieć o użyciu checkState z biblioteki Guava. Jeśli pierwszy parametr jest wartości false – wyrzucamy wyjątek IllegalStateException z treścią z parametru drugiego. Bardzo upraszcza to kod (pozbywamy się „ifologi”).

Niebieskim tłem zaznaczyłem publiczne metody biznesowe udostępniane przez bogatą domenę. Jak widzisz, w porównaniu do poprzedniego przykładu nasz model danych nie udostępnia możliwości modyfikacji pól na zewnątrz. Zmienne mają dostęp prywatny, są ustawiane w prywatnym konstruktorze. Na zewnątrz jest udostępniona publiczna, statyczna metoda fabryki, która tworzy nowy obiekt karty. Za pomocą getterów udostępniamy tylko informację o polach:

  • cardId
  • cardNumber
  • limit

Setterów nie używamy w ogóle. Najważniejszą rzeczą w powyższym kodzie jest fakt, że obiekt bogatej domeny udostępnia metody biznesowe, eksponując zachowanie modelu a nie tylko jego stan. To bardzo ważne. Powtórzmy to jeszcze raz.

W bogatym modelu domenowym udostępniamy zachowania modelu, ograniczając możliwość modyfikacji wewnętrznych danych modelu domenowego. Logika znajduje się wewnątrz modelu, zapewniając spójne zachowania. W anemicznym modelu domenowym cała logika zarządzania danymi modelu jest wyniesiona do zewnętrznych klas a sam anemiczny model domenowy umożliwia dowolną modyfikację swoich danych powodując rozhermetyzowanie modelu.

Ale co nam to tak właściwie daje?

Bogata domena nie pozwala nam na stworzenie niepoprawnych obiektów. Nie jesteś w stanie stworzyć ujemnego limitu albo zastrzeżonej karty – co w przypadku anemicznej domeny byłoby możliwe. Stan i cechy są zawsze spójne, a gwarancją spójności jest sam obiekt, a nie warunki sprawdzane w kodzie operującym na obiektach – to znacznie upraszcza.

Jeśli bazujemy na modelu anemicznym – rozhermetyzowujemy nasz obiekt, zezwalamy na dowolną modyfikację zmiennych modelu oraz całą odpowiedzialność (logikę) zrzucamy na klasy zewnętrzne typu Service. Powstaje brzydka if-ologia i spore ryzyko, że przed wykonaniem jakiejś operacji – najzwyczajniej w świecie zapomnimy sprawdzić jakiegoś warunku, który wykluczałby możliwość wykonania danej operacji. Możemy też zapomnieć o wykonaniu dodatkowej logiki, w końcu sama logika może być rozproszona pomiędzy kilka różnych serwisów. Innymi słowy, sugerując się przykładem z kartą bankomatową – mogliśmy pominąć w serwisie warunek, że aby można było aktywować kartę – nie może ona być zablokowana.

Psst… Interesujący artykuł?

Jeżeli podoba Ci się ten artykuł i chcesz takich więcej – dołącz do newslettera. Nie ominą Cię materiały tego typu.

.

Ale co my właściwie zrobiliśmy?

Przenieśliśmy logikę zarządzania polami modelu – właśnie do modelu. Sprawiliśmy, że nasz obiekt hermetyzuje dostęp do pewnych kluczowych zmiennych a niektóre pola są ujawnione. Udostępniamy gettery do biznesowych danych. Dane techniczne są ukryte przed światem zewnętrznym. W przykładzie z kartą – danymi technicznymi są pola isActice oraz isBlocked. Możemy zmienić wewnętrzne szczegóły bogatej domeny i w zamian za flagi, użyć np. enuma. Po takiej zmianie zewnętrzny kod o niczym się nie dowie (udostępniamy api, metody biznesowe). Wyobraź sobie teraz zmianę dwóch flag na enum w modelu anemicznym – we wszystkich serwisach korzystających z tego modelu należałoby zrobić modyfikację i przerobić całą if-ologię. Udostępnienie zachowania modelu a ukrycie (enkapsulacja) danych wewnętrznych (technicznych) sprawia, że dane w naszym modelu dane są spójne i nikt z zewnątrz nie przestawi pól w przypadkowy sposób (lub nie zapomni o pewnych warunkach)

Ale przecież …

Mógłbyś teraz powiedzieć, że tak samo możesz się pomylić w bogatym modelu domenowym i nie uwzględnić jakiegoś warunku…

Tak, to słuszna obserwacje. Weź również pod uwagę to, że jeśli logika jest rozproszona pomiędzy wiele serwisów, a sam model zezwala na dowolną modyfikację swoich wewnętrznych danych – bardzo narażamy się nie tylko na popełnienie błędu, co na nadpisanie wartości pola, niepowołany / niekontrolowany dostęp pola. Przenosząc tą samą logikę do samego modelu domenowego – jest mniejsze prawdopodobieństwo popełnienia błędu. Hermetyzujemy dane techniczne, a udostępniamy biznesowe metody. Kod staje się czytelniejszy a my rzadziej popełniamy błędy.

Łatwe testy bogatego modelu domenowego

Kolejny aspekt przemawiający za bogatym modelem domenowym – to łatwość testowania. Poniżej zaprezentowałem klasę testującą zachowania udostępniane przez nasz bogaty model domeny. Jak widzimy w pierwszym teście – nie możemy zastrzec karty, która już została zastrzeżona – dostaniemy wyjątek BlockCardException. Funkcje testowe są bardzo proste …

@ExtendWith(MockitoExtension.class)
class CardTest {

    private static CardLimit ANY_LIMIT = CardLimit.of(BigDecimal.valueOf(700));

    @Test
    public void canNotBlockAlreadyBlockedCard() {
        var card = givenBlockedCard();
        assertThatCode(() -> card.block())
                .isInstanceOf(IllegalStateException.class);
    }

    @Test
    public void canNotBlockNotActivedCard() {
        var card = givenNotActiveCard();
        assertThatCode(() -> card.block())
                .isInstanceOf(IllegalStateException.class);
    }

    @Test
    public void blocksActivedCard() {
        var card = givenActiveCard();
        assertThatCode(() -> card.block())
                .doesNotThrowAnyException();
    }

    @Test
    public void cannotActivateBlockedCard() {
        var card = givenBlockedCard();
        assertThatCode(() -> card.activate())
                .isInstanceOf(IllegalStateException.class);
    }

    @Test
    public void cannotActivateAlreadyActiveCard() {
        var card = givenActiveCard();
        assertThatCode(() -> card.activate())
                .isInstanceOf(IllegalStateException.class);
    }

    @Test
    public void activatesNewCard() {
        var card = givenNotActiveCard();
        assertThatCode(() -> card.activate())
                .doesNotThrowAnyException();
    }

    @Test
    public void cannotChangeLimitOnBlockedCard() {
        var card = givenBlockedCard();
        var limit = ANY_LIMIT;
        assertThatCode(() -> card.changeCardLimit(limit))
                .isInstanceOf(IllegalStateException.class);
    }

    @Test
    public void changesLimitActiveCard() {
        var card = givenActiveCard();
        var limit = ANY_LIMIT;
        assertThatCode(() -> card.changeCardLimit(limit))
                .doesNotThrowAnyException();
    }

    private Card givenNotActiveCard() {
        return givenCard();
    }

    private Card givenBlockedCard() {
        var card = givenCard();
        card.activate();
        card.block();
        return card;
    }

    private Card givenActiveCard() {
        var card = givenCard();
        card.activate();
        return card;
    }

    private Card givenCard() {
        final var cardId = UUID.randomUUID();
        final var cardNumber = UUID.randomUUID().toString();
        return Card.create(cardId, cardNumber);
    }
}

Poniżej widzimy wykonanie testów naszej bogatej domeny karty bankowej.

Kiedy stosować akcesory i mutatory?

Są sytuacje kiedy akcesory i mutatory są nieocenione. Obiekty typu DTO (Data Transfer Object) to przykład ich wykorzystania. Modele DTO są wykorzystywane jako encje przy komunikowaniu się z bazami danych, czy jako obiekty transferu danych w RestAPI. Stanowią pierwszą linię na granicach naszej aplikacji, gdzie ulegają przekształceniom w modele domenowe. Poniżej zamieściłem przykład obiektu DTO.

public class AddressDTO {
    private String country;
    private String city;
    private String zipCode;
    private String street;
    private String streetNumber;
    private String homeNumber;

    public String getCountry() {
        return country;
    }

    public void setCountry(String country) {
        this.country = country;
    }

    public String getCity() {
        return city;
    }

    public void setCity(String city) {
        this.city = city;
    }

    public String getZipCode() {
        return zipCode;
    }

    public void setZipCode(String zipCode) {
        this.zipCode = zipCode;
    }

    public String getStreet() {
        return street;
    }

    public void setStreet(String street) {
        this.street = street;
    }

    public String getStreetNumber() {
        return streetNumber;
    }

    public void setStreetNumber(String streetNumber) {
        this.streetNumber = streetNumber;
    }

    public String getHomeNumber() {
        return homeNumber;
    }

    public void setHomeNumber(String homeNumber) {
        this.homeNumber = homeNumber;
    }
}

To samo, ale krócej osiągniemy za pomocą Lombok

@Data
public class AddressDTO {
    private String country;
    private String city;
    private String zipCode;
    private String street;
    private String streetNumber;
    private String homeNumber;
}

Innym przykładem, kiedy możemy chcieć wykorzystać setter może być walidacja danych przed zmianą stanu:

public void setType(String type) {
	if(!isValidType(type)) {
		throw new IllegalArgumentException("Invalid file type: " + type);
	}
	this.type = type;
}

Kolejny przykład to zwracanie kopii listy, tak aby nikt nie mógł zmodyfikować oryginału – kod poniżej:

public class Debts {
	private final List<Debt> debts;

	public Debts(List<Debt> debts) {
		this.debts = debts;
	}

	public List<Debt> getDebts() {
		return List.copyOf(debts);
	}
}

Podsumowanie

Rich Domain Model jest przeciwieństwem Anemic Domain Model. Bogata domena umieszcza logikę interpretacji danych w bogatym modelu domenowym. Ma to duży wpływ na spójność obiektu, ponieważ to logika opakowuje dane w samym modelu. Metody bogatej domeny mogą reagować na zmiany stanu danych – i to właśnie nazywamy eksponowaniem zachowania a nie prostych danych.

Anemicznego modelu danych użyjemy, kiedy potrzebujemy obiektu transferu danych – DTO. Będą to np. encje, które są mapowane na tabele bazodanowe, czy obiekty przekazywane zwracane, czy pobierane w RestAPI.

Bogatą domenę użyjemy, jeśli nasza logika aplikacji jest bardziej skomplikowana niż prosta aplikacja CRUD. Rich Domain Model zastosujemy, kiedy chcemy eksponować zachowanie naszego modelu, a nie jego strukturę wewnętrzną.

Warto jest uważać na bezmyślne dodawanie getterów i setterów do naszych modeli danych. Wystarczy jedna adnotacja Lombok @Data, aby sprawić, że rozhermetyzujemy nasz model i stanie się anemiczny, co może mieć swoje niebezpieczne konsekwencje dla całego systemu.

Wpis który czytasz to zaledwie fragment wiedzy zawartej w Programie szkoleniowym Java Developera od SoftwareSkill. Mamy do przekazania sporo usystematyzowanej wiedzy z zakresu kluczowych kompetencji i umiejętności Java Developera. Program składa się z kilku modułów w cotygodniowych dawkach wiedzy w formie video.

Gdybyś potrzebował jeszcze więcej:

Jesteś Java Developerem?

Przejdź na wyższy poziom wiedzy 
„Droga do Seniora” 🔥💪

Jesteś Team Leaderem? Masz zespół?

Podnieś efektywność i wiedzę swojego zespołu 👌

Piguła wiedzy o najlepszych praktykach testowania w Java

Pobierz za darmo książkę 100 stron o technikach testowania w Java

Dyskusja