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