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ę.
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.
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