Jak zamapować agregat DDD w Hibernate

softwareskill.pl 3 lat temu

Wstęp

Na pewno spotkałeś się z pojęciem bogatej domeny (rich domain) oraz jej przeciwieństwa czyli ADM (Anemic Domain Model). Korzystasz z encji, klas serwisowych, mapujesz dane, a także chcesz zaimplementować logikę biznesową. W jaki sposób można do tego podejść, jaka powinna lub jaka może być odpowiedzialność poszczególnych elementów aplikacji.

Domain entity

Pojęcie znane z DDD, w dużym uproszczeniu reprezentuje logiczny fragment domeny/dziedziny/obszaru, który jest identyfikowalny (posiada jakąś tożsamość – np. identyfikator), zawiera dane oraz odpowiada za zachowanie i logikę w ramach tego fragmentu. I tutaj pojawia się właśnie element związany z logiką. o ile jest logika to takiemu obiektowi bliżej jest do rich domain model niż do anemic domain model.

Operacje w systemach

W ramach aplikacji może być realizowanych wiele rodzajów operacji, mogą to być na przykład elementy z dość szerokiej listy (jak poniżej):

  • Mapowanie/transformacja danych, np.:
    • Encja do DTO.
    • Serializacja/deserializacja JSON/XML.
    • Przemapowanie z jednego modelu do drugiego (np. translacja danych związana z komunikacją z innym systemem po REST).
    • Stworzenie raportu/wydruku (HTML, CSV, Excel, PDF).
  • Walidacja
    • Walidacja wymaganych parametrów REST API (path param, request param).
    • Walidacja struktury danych dla żądania REST(JSON, swagger, OpenAPI).
    • Walidacja krzyżowa danych (np. czy PESEL i płeć pasuje).
    • Walidacja biznesowa (sprawdzenie, czy stan danych w bazie pozwala na wykonanie operacji, czy identyfikator istnieje, czy saldo konta pozwala na obciążenie w określonej kwocie).
  • Operacje CRUD (zapis danych w bazie, cache, wyszukiwanie).
  • Przetwarzanie zdarzeń/obsługa żądań.
  • Logika biznesowa
    • Wykorzystywane wszystkie powyższe elementy.
    • Ciąg logicznych kroków bazujący na danych wejściowych i wcześniej zebranych i zapisanych informacji.
    • Komunikacja z innymi systemami i/lub zasobami zewnętrznymi.

Na pewno istnieje jeszcze wiele innych rodzajów operacji – weryfikacja uprawnień, szyfrowanie, logowanie, historia zmian/audyt operacji etc.

Jeżeli chodzi o encję i logikę jaką może realizować w ramach rich domain model to kandydatami do tego są wytłuszczone elementy powyżej – o ile logika ta opierać się będzie na danych encji i bliskich powiązań tej encji – np. operacja obciążenia karty kwotą amount – encja Card powiązana relacją z BankAccount (rachunkiem) i sprawdzenie, czy saldo rachunku umożliwia obciążenie (canBeDebited(amount)).

@Entity @Data class Card { @OneToOne.... BankAccount bankAccount; @OneToMany.... List<CardLimit> cardLimits; DebitStatus debit(DebitValueObject debit) { var validationStatus = validate(debit); if(!validationStatus.isOK()){ return DebitStatus.forError(validationStatus); } bankAccount.debit(debit); cardLimits.forEach(limit -> limit.debit(debit.getAmount())); return DebitStatus.ok(); } private ValidationStatus validate(BigDecimal debitAmount) { var accoubtCanBeDebited = bankAccount.canBeDebited(debitAmount); if(!accountCanBeDebited) { return ValidationStatus.insufficientBalance(); } var limitValidationErrors = cardLimits.stream() .map(limit -> limit.validateDebit(debitAmount)) .filter(status -> status != OK) .collect(Collectors.toList()); if(!limitValidationErrors.isEmpty()) { return ValidationStatus.limitError(limitValidationErrors); } return ValidationStatus.ok(); } }

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.

Dołączam

Dziękujemy!

Wysłaliśmy Ci mail powitalny, w którym znajdziesz link do aktywacji newslettera. Do usłyszenia!

Błędy i pułapki

Realizując jakąś funkcjonalność, należy zwrócić szczególną uwagę na kilka elementów, które mogą ułatwić (albo utrudnić) określenie tego, co i w jaki sposób encja powinna realizować. Elementy te to w szczególności:

  • Nieprawidłowa definicja dziedziny/obszaru – np. ogólna nazwa karty, która zawiera dane karty (dane właściciela, numer karty, saldo karty), limity karty, historię karty. Już taki podział jest nieprawidłowy, to co najmniej cztery domeny – użytkownicy, dane karty (wskazanie na id właściciela), limity karty i historia transakcji karty (obydwie wskazują na identyfikator karty). Reprezentacją mogłyby być przykładowe serwisy/fasady/dziedziny
    • UserInventory – dodawanie użytkownika, edycja danych użytkownika, aktywacja/deaktywacja
    • CardInventory – dodawanie karty, przypisywanie karty do użytkownika, ustawianie limitów, blokowanie/odblokowanie karty
    • CardTransactionExecutor – wykonywanie operacji na karcie
    • CardTransactionsHistory – dostęp do historii operacji kartowych
    • UserCardLimitVerifier – weryfikacja operacji pod kątem zgodności z ustawieniami limitów dla danej karty użytkownika
  • Zbyt późna walidacja danych – niepotrzebnie wykonywana jest dalsza logika (patrz Fail Fast opisany tutaj)
  • Exception Driven Development – sterowanie logiką aplikacji poprzez rzucanie wyjątków jest antywzorcem. Jak sama nazwa wskazuje, exception to jakiś wyjątek.
    • Jeśli rzucasz wyjątek typu catchable to musisz obsługiwać te wyjątki, przykładowo metoda nie może być użyta w przetwarzaniu strumieniowym bez łapania wyjątku
    • Jeśli rzucasz wyjątek dziedziczący z RuntimeException, tracisz z oczu fragment logiki. Lepiej jest zwracać status wykonania operacji i ew kod błędu lub prawidłowy wynik.
    • Zbieranie informacji o stosie wywołania, w którym wystąpił wyjątek, jest bardzo kosztowne. Podobnie wydruk takiego stosu, możesz dodać do tego przechwytywanie i ponowne opakowywanie wyjątku źródłowego.
  • Kod trudny w testowaniu – zbyt wiele zależności i pól w klasie, ciężko wstrzyknąć mocki, nie można napisać testów jednostkowych bez korzystania z bazy danych czy jakiegoś frameworka, skomplikowane mockowanie, zbyt duże zagnieżdżenia (nie stosowanie się do fail fast).

Apache Kafka – wydajność vs. gwarancja dostarczenia wiadomości

Jak stworzyć piekielnie szybką albo maksymalnie bezpieczną wersję producenta oraz konsumenta.

Pobierz ebooka

W końcu trzeba zacząć programować – encja

Przychodzi w końcu ten moment, w którym zaczynasz pisać fragment kodu. Chcesz lub musisz oprogramować encję, zastanawiasz się jaką logikę może ona realizować i w jaki sposób można ją zapisać. Chcesz skorzystać z rich domain model zamiast encji typu POJO (czytaj więcej o tutaj) – musisz więc sobie zdać sprawę z tego, iż klasy będą większe niż w przypadku POJO. Natomiast logika będzie realizowana w ramach logicznego obszaru, co jest olbrzymią zaletą.

Pierwszą zasadą jest unikanie setterów/getterów dla pól, jeżeli to możliwe (a najlepiej, gdyby ich w ogóle nie było) – np. metody activate/deactivate/isActive zamiast setEnabled/isEnabled, czy credit/debit dla zmiany salda konta o określoną wartość.

Korzystaj z enkapsulacji – ograniczaj dostęp do danych do niezbędnego minimum. Nie wszystkie kolumny z tabeli zmapowane na pola klasy encji muszą być widoczne na zewnątrz. Logika encji może zmieniać stan takich pól a na zewnątrz udostępniać tylko fragment – np. konto użytkownika ma datę ważności, stan aktywacji oraz informację o zablokowaniu – może istnieć metoda isOperable, która sprawdza te trzy warunki i zwraca true, o ile użytkownik jest w pełni funkcjonalny.

@Entity class UserAccount { private Boolean activated; private Instant expiresAt; private Boolean blocked; Boolean isOperable() { return activated && Instant.now().isBefore(expiresAt) && !blocked; } }

Inny przykład to sieć obiektów i transformacja tej sieci do innego modelu (np. do DTO) – o ile jest jakiś nadrzędny element (encja), to tylko on powinien mieć metodę toDTO, ponieważ inne podrzędne same nie mogą istnieć (np. osoba i dokument tożsamości).

@Entity class Person { private String firstName; @OneToOne... private IdentityDocument document; PersonDto toDto() { return PersonDto.builder() .firstName(firstName) .document(toDocumentDto(document)) .build(); } private static IdentityDocumentDto toDocumentDto(IdentityDocument document) { return IdentityDocumentDto.builder() .id(document.getId()) .build(); } }

Ale mapowanie można również wykonać w inny sposób – możesz stworzyć mapper, który zajmie się transformacją danych, lub też sam obiekt będzie miał logikę tworzenia samego siebie na podstawie danych innego obiektu.

@Entity class Person { private String firstName; @OneToOne... private IdentityDocument document; } class PersonMapper { static PersonDto toDto(final Person person) { return PersonDto.builder() .firstName(person.getFirstName()) .document( DocumentMapper.toDocumentDto(person.getDocument())) .build(); } } class DocumentMapper { static IdentityDocumentDto toDocumentDto(final IdentityDocument document) { return IdentityDocumentDto.builder() .id(document.getId()) .build(); } }

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.

Dołączam

Dziękujemy!

Wysłaliśmy Ci mail powitalny, w którym znajdziesz link do aktywacji newslettera. Do usłyszenia!

Wadą tego rozwiązania jest to, iż konieczne będzie udostępnienie getterów dla mapowanych pól. Zaletą mappera jest to, iż w przypadku rozbudowanych encji nie będzie w niej długiego fragmentu kodu odpowiedzialnego za mapowanie danych (może okazać się np. iż 70%-80% ciała klasy to mapowanie danych). Kolejną zaletą mapperów może być to, iż jeżeli potrzebujemy dociągnąć jakieś dane z zewnętrznego systemu, to nie zrobimy tego w encji.

Kolejny element walidacja – tutaj w zależności od zakresu walidacji, może być ona realizowana w ramach encji dla prostszych przypadków, lub w przypadku gdy walidacja sięga dalej poza encję, lepszym rozwiązaniem może być walidowanie poza encją w osobnym walidatorze. Zachęcam również do tego, aby walidacja nie rzucała wyjątku (unikaj exception driven developement).

Przetwarzanie zdarzeń czy rozkazów wygląda podobnie – możesz stworzyć klasy handlerów, które obsłużą całość logiki, a możesz też w ramach encji realizować fragment logiki.

@Slf4j class DebitCommandHandler { private CardRepository repository; void handleDebitAccountCommand(final DebitAccountCommand command) { repository.getByCardUuid(command.getCardUuid()) .ifPresentOrElse(card -> handleCardDebit(card,command), () -> log.error("Card with Uuid={} not found", command.getCardUuid())); } private void handleCardDebit(final Card card, final DebitAccountCommand command) { var debitStatus = card.debit(command); if(debitStatus.isError()) { log.error("Error od debit error={}", debitStatus.toErrorString()); } else { log.info("Card with uuid={} debited successfully with amount={}", command.getCardUuid(), command.getAmount()); } } } @Entity @Data class Card { @OneToOne.... BankAccount bankAccount; @OneToMany.... List<CardLimit> cardLimits; DebitStatus debit(final DebitAccountCommand debit) { var validationStatus = validate(debit); if(!validationStatus.isOK()){ return DebitStatus.forError(validationStatus); } bankAccount.debit(debit); cardLimits.forEach(limit -> limit.debit(debit.getAmount())); return DebitStatus.ok(); } }

Dodatkowo w przypadku gdy zdarzenia pochodzą z systemów zewnętrznych, dobrze jest odseparować się od modelu dostawcy i stworzyć osobne obiekty reprezentujące te dane (mapowanie modelu zewnętrznego na wewnętrzny). o ile natomiast zdarzenie czy rozkaz wykracza znaczeniem poza zakres encji, to funkcjonalność obsługi powinna być delegowana do osobnej klasy serwisowej, która ewentualnie wywoła odpowiednie metody na encjach.

@Slf4j class DebitCommandHandler { private CardRepository repository; private ExternalExchangeRegistry registry; void handleDebitAccountCommand(final org.foreign.ForeignExchangeEvent event) { log.info("Foreign event received type={}, id={}", event.getType(), event.getId()); final ExternalExchangeVO externalExchangeVO = ExternalExchangeVO.of(event); var exchangeId = registry.register(externalExchangeVO); final DebitAccountCommand command = DebitAccountCommandMappef.of(event); repository.getByCardUuid(command.getCardUuid()) .ifPresentOrElse(card -> handleCardDebit(card,command), () -> handleProcessingError(exchangeId, command)); } private void handleProcessingError(final ExternalExchangeId externalExchangeId, DebitAccountCommand command) { log.error("Card with Uuid={} not found", command.getCardUuid()); registry.registerProcessingFailure(externalExchangeId); } }

Przydatne linki

    Podoba Ci się ten artykuł? Weź więcej.

    Jeżeli uważasz ten materiał za wartościowy i chcesz więcej treści tego typu – nie przegap ich i otrzymuj je prosto na swoją skrzynkę. Nawiążmy kontakt.

    Dołączam

    Dziękujemy!

    Wysłaliśmy Ci mail powitalny, w którym znajdziesz link do aktywacji newslettera. Do usłyszenia!

    Gdybyś potrzebował jeszcze więcej:

    Jesteś Java Developerem?

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

    Chcę więcej wiedzy

    Jesteś Team Leaderem? Masz zespół?

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

    Sprawdź

    Gdybyś potrzebował jeszcze więcej:

    Jesteś Java Developerem?

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

    Chcę więcej wiedzy

    Jesteś Team Leaderem? Masz zespół?

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

    Sprawdź
    Idź do oryginalnego materiału