dwisniewski.com Po prostu o programowaniu i danych

Modelowanie domeny – problem nieomawiany, problem pomijany, problem bardzo ważny

autor Damian Wiśniewski14 stycznia 2026ArchitekturaDDD8 min czytania

Dziś poruszymy temat, który od jakiegoś czasu wisiał na mojej liście. Ostatnio obejrzałem sporo materiałów o modelowaniu domeny w DDD. Niestety wiele z nich omawia trywialne albo dobrze znane zagadnienia. Poniżej opiszę problem, z którym na pewno się wcześniej nie spotkałeś (ja też nie). Spróbujemy go rozłożyć na części – najpierw na wysokim poziomie, a potem budując głęboki model domenowy. Dołącz do mnie!

Opis problemu (w skrócie)

  • System rozlicza wspólne wydatki ponoszone w ramach jednej lokalizacji (mieszkania, biura, nieruchomości).
  • Rozliczenia są wyliczane w zdefiniowanych okresach rozliczeniowych, zwykle miesięcznych.
  • Każdy okres może mieć zmienną liczbę uczestników.
  • Uczestnik może dołączyć lub odejść w trakcie okresu rozliczeniowego.
  • Odpowiedzialność za koszty zależy od faktycznego czasu uczestnictwa, chyba że dany koszt definiuje inną regułę.
  • Każdy koszt ma kwotę pieniężną, datę, kategorię i powiązaną regułę podziału.
  • Koszty mogą być cykliczne, zależne od zużycia albo jednorazowe.
  • Koszt może dotyczyć:
    • wszystkich uczestników,
    • wybranej podgrupy,
    • uczestników z jawnymi wyłączeniami lub limitami.
  • System obsługuje wiele reguł podziału kosztów, m.in.:
    • podział równy,
    • podział proporcjonalny według zdefiniowanych wag,
    • podział czasowy (proporcjonalny do okresu),
    • podział według zużycia,
    • kombinacje wielu reguł.
  • Reguły podziału mogą zmieniać się w czasie.
  • Podział kosztów może generować różnice z zaokrągleń, które trzeba obsłużyć zgodnie z określoną polityką.
  • Koszty mogą być dodawane po zakończeniu okresu rozliczeniowego.
  • Korekty kosztów wpływają na przyszłe rozliczenia zgodnie z ustalonymi regułami korekt.
  • Okres rozliczeniowy może zostać zamknięty – wtedy wyniki uznaje się za ostateczne.
  • Dla każdego uczestnika system wylicza:
    • kwotę do zapłaty albo
    • nadpłatę.
  • Wyniki rozliczenia muszą być przejrzyste i możliwe do prześledzenia – kwotę każdego uczestnika trzeba umieć wyjaśnić aż do pojedynczych pozycji kosztowych.

Definiowanie elementów modelu

Żeby zacząć projektować model, narysujmy jego elementy. Model będzie składał się przede wszystkim z rozliczeń (Settlements). W naszym systemie to synonim słowa „zobowiązania”. Mamy też użytkowników zasobu – w naszym języku kontekstowym będzie to „uczestnik” (participant).

To podstawowe rozpoznanie elementów. Zauważ, że nie ma tu jeszcze relacji ani pól, bo to nie jest ERD (diagram związków encji). Zwizualizujmy problem. Pokażmy go z innej perspektywy – czasowej:

Ten wykres lepiej pokazuje przebieg w czasie. Nasz przedział czasu to „Okres X”. W tym okresie uczestnicy (A, B, C) ponoszą koszty. Możemy roboczo założyć, że to osoby wynajmujące miejsca w przestrzeni open space. Myślę, że to całkiem dobry przypadek użycia, który pomoże nam zwizualizować problem.

Nasze oprogramowanie ma więc zarządzać najmem danego zasobu między uczestnikami (najemcami). Jak widać, początek i koniec zobowiązania każdego uczestnika są ustalane indywidualnie (są okresy, w których ktoś nie ponosi kosztów, ale są też okresy ciągłego najmu). Wyobraźmy sobie, że jedna osoba przychodzi do biura 20 dni na 20 dni roboczych, inna z przerwami itd.

Event storming – jedna z technik poznawania problemu

Spróbujmy rozłożyć to na karteczki. Alberto Brandolini, twórca ES, wymyślił sposób na eksplorowanie problemów podczas projektowania oprogramowania. Więcej szczegółów znajdziesz tutaj. Nie jestem ekspertem od tej techniki, ale znam podstawowe pojęcia i spróbuję przygotować tablicę ES na poziomie eksploracji problemu (Big Picture).

Tak wyglądałaby lista zdarzeń domenowych. Możemy:

  • otworzyć okres,
  • dodać koszt,
  • uczestnik może dołączyć,
  • uczestnik może odejść,
  • okres może się zakończyć,
  • okres może zostać rozliczony (zamknięty),
  • zobowiązania mogą zostać przeliczone,
  • do uczestników może zostać wysłana faktura.

Rodzaje podziału kosztów

Oś czasu zdarzeń pomaga nam zidentyfikować najważniejsze zdarzenia w okresie rozliczeniowym. Daje to ogólny obraz tego, co dzieje się w trakcie okresu. Spójrzmy teraz, jak rozwiązać reguły podziału – jeden z kluczowych elementów rozliczeń:

  • podział równy – koszty dzielone są po równo między uczestników,
  • podział proporcjonalny według zdefiniowanych wag – każdy uczestnik ma przypisaną własną wagę dla danego typu kosztu,
  • podział czasowy (proporcjonalny) – podział między uczestników w zależności od czasu, przez jaki każdy z nich korzystał z danego zasobu,
  • podział według zużycia – podział na podstawie proporcjonalnego zużycia zasobu przez uczestników (niezależnie od czasu korzystania),
  • kombinacje wielu reguł – najtrudniejsze, choć z wysokiego poziomu nie ma to aż takiego znaczenia.

Przy implementacji modelu będziemy musieli zaimplementować odpowiednie rozwiązanie tego problemu, ale nie zajmiemy się tym w tym artykule – implementacji poświęcę osobny wpis. Na razie odłóżmy to na bok.

Struktura kosztu

Koszt powinien składać się z kilku elementów:

  • Kwota pieniężna – wartość zapisana jako liczba + waluta,
  • Data – data naliczenia kosztu,
  • Kategoria – kategoria wydatku (rachunek za prąd, korzystanie z biurka, korzystanie z mikrofalówki itp.),
  • Reguła podziału – znana z powyższej listy, określa sposób podziału kosztu między uczestników.

Myślę, że w ten sposób stworzyliśmy pierwszą encję domenową. Następną będzie na pewno Uczestnik. Uczestnik będzie prostą encją złożoną z imienia, adresu e-mail i identyfikatora. Na potrzeby tego modułu nic więcej nie potrzebujemy. Każdy uczestnik będzie miał też własne konto, na którym zapisywana będzie historia jego rozliczeń. Nazwijmy je Kontem Uczestnika.

Kolejną encją będzie prawdopodobnie Okres Rozliczeniowy. Będzie reprezentował przedział czasu (konkretnie miesiące rozliczeniowe). Jego właściwości to początek i koniec okresu oraz status (otwarty/zamknięty).

Po tym podstawowym rozpoznaniu możemy swobodnie narysować przypuszczalny kształt encji.

Encję Faktura celowo oznaczyłem innym kolorem. Nie ma jej w wymaganiach, ale jest naturalną konsekwencją działania aplikacji. Spróbujmy teraz dokładniej opisać elementy encji i relacje między nimi. Ten szkic ma jedynie zarysować i uporządkować kolejne klocki. Już na tym etapie widać, że encja Faktura zdecydowanie nie pasuje do reszty, ponieważ:

1. Dotyczy zdarzeń po zamknięciu okresu rozliczeniowego.
2. Może być wystawiona niezależnie od innych zdarzeń – jest konsekwencją, ale nie częścią procesu.

Nie odrzucajmy jednak Faktury, dopóki nie będziemy pewni, że to inny Bounded Context.

Pseudo-ERD

Poniżej znajdziesz kolejny etap projektowania: projektowanie encji i relacji między nimi, które roboczo możemy nazwać „pseudo-ERD”. Diagram związków encji (ERD) to pojęcie kojarzone z bazami danych, ale zauważ, że do tej pory nie wspomnieliśmy o warstwie persystencji. Nie wspomniałem o żadnym silniku bazy danych, frameworku ani ORM-ie, bo to nie one wyznaczają kierunek modelowania. Decyzje techniczne przyjdą później. Spójrzmy więc na ERD:

Tu widzimy bardziej szczegółowy obraz. Widzimy też, że wszystkie encje w pewnym stopniu zależą od Okresu Rozliczeniowego. Przyjrzyjmy się teraz centralnej encji, czyli Wspólnemu Wydatkowi (Shared Expense). To centralne miejsce schematu, ponieważ zawiera:

  • typy kosztów,
  • typy podziału kosztów między uczestników,
  • kwotę do podziału (SharedAmount),
  • SettlementPeriodId – referencję do naszego Aggregate Roota.

Klocki z DDD

Zaczynam używać pojęć ściśle związanych z DDD, więc warto krótko opisać podstawowe klocki taktycznego DDD (tak, jesteśmy już na pograniczu taktyki – jedyna strategiczna decyzja, jaka nam została, to czy włączyć Fakturę do naszego kontekstu, czy Fakturowanie będzie osobnym kontekstem).

Encja (Entity)

Obiekt z tożsamością, która jest ważniejsza niż jego bieżący stan. Może z czasem zmieniać atrybuty, ale pozostaje „tą samą” encją.

Przykłady: zamówienie, użytkownik, subskrypcja.

Obiekt wartości (Value Object)

Obiekt bez tożsamości, definiowany wyłącznie przez swoje wartości. Jest niemutowalny i porównywany przez równość wartości.

Przykłady: kwota pieniędzy, zakres dat, adres e-mail.

Agregat (Aggregate)

Spójna granica transakcyjna grupująca encje i obiekty wartości. Chroni niezmienniki biznesowe.

Korzeń agregatu (Aggregate Root)

Jedyne wejście do wnętrza agregatu. Tylko przez niego można modyfikować stan agregatu.

Z zewnątrz nigdy nie operujesz bezpośrednio na wewnętrznych encjach.

Repozytorium (Repository)

Abstrakcja dostępu do agregatów.

Naśladuje kolekcję w pamięci – to nie DAO ani Active Record.

Pobiera i zapisuje Aggregate Rooty, a nie dowolne encje.

Serwis domenowy (Domain Service)

Logika domenowa, która:

  • nie pasuje naturalnie do jednej encji,
  • operuje na wielu agregatach,
  • nie przechowuje stanu.

To wciąż domena, a nie infrastruktura.

Zdarzenie domenowe (Domain Event)

Zdarzenie opisujące coś, co już wydarzyło się w domenie. Służy do komunikacji i reagowania na zmiany stanu.

To nie jest polecenie ani żądanie.

Fabryka (Factory)

Odpowiada za tworzenie złożonych agregatów. Ukrywa skomplikowaną logikę inicjalizacji i pilnuje niezmienników od samego początku.

Specyfikacja/Polityka (Specification/Policy)

Enkapsuluje regułę lub warunek biznesowy. Może służyć do walidacji, filtrowania i podejmowania decyzji.

Czytelny sposób zapisu złożonej logiki warunkowej.

Moduł (pakiet)

Logiczne grupowanie elementów domeny.
Pomaga utrzymać spójność i czytelność modelu.

Nie mylić z bounded contextem – to poziom niżej.

Model jest niekompletny

Mam wrażenie, że bezpośrednią relację między SettlementPeriod, SharedExpense i Participant można by zamknąć w mini-agregacie o nazwie ParticipantExpense, który działałby jako most między pojedynczym uczestnikiem kosztu a całym okresem rozliczeniowym. Co więcej, skoro nie mamy żadnej referencji do wspólnego zasobu, warto zgrupować to w ParticipantExpense. Kolejna iteracja naszego modelu wyglądałaby tak:

Sporo się zmieniło. Po pierwsze pojawiło się oznaczenie (C), czyli kolekcje obiektów. Z perspektywy relacyjnej jedna encja zawiera kolekcję innych encji. Dodałem też (VO) – Value Object. To jeden z opisanych wyżej klocków DDD.

Idąc dalej, zaznaczyłem też zakres naszego agregatu: jednostkę spójności, która agreguje wiele encji i zapewnia spójność podczas operacji biznesowych. Zaznaczyłem również Aggregate Root, którym w naszym przypadku jest SettlementPeriod. Do agregatu można się dostać z zewnątrz wyłącznie przez Aggregate Root. To bardzo przydatne, by logika się nie rozlewała, i pomaga zapewnić enkapsulację. Coś o tym było kiedyś na studiach, prawda? :) Usunąłem też z agregatu Participant i Facility.

Nasz model powinien zawierać jedynie referencje do nich, ale nie operuje bezpośrednio na uczestnikach ani na obiekcie (Facility). Zarządzaniu użytkownikami będzie prawdopodobnie poświęcony inny BC, a nawet inny moduł, i z pewnością osobny agregat zadba o jego spójność. To samo dotyczy Facility – w naszym agregacie chcemy tylko niezbędnych elementów; wystarczy referencja do FacilityID; nie powinno nas obchodzić, gdzie obiekt się znajduje ani jak się nazywa. Z perspektywy naszego modelu nie jest to istotne.

Poszedłem krok dalej i dodałem też ramę Bounded Contextu, który nazwałem Settlements. Postanowiłem nie włączać do niego Faktury – po analizie i krótkiej sesji modelowania już wiem, że będzie to osobny BC. Może nawet osobny moduł. Kto wie?

Wszystko pięknie, ale jak to teraz zakodować?

Jak zauważyłem, nasz model jest teraz kompletny, a przynajmniej tak się wydaje. W tym artykule nie pokażę implementacji. Poświęcę jej całą drugą część, ale tytułem wstępu przedstawię diagram klas UML. Do tej pory skupialiśmy się na rzeczownikach – encjach. Teraz, żeby było bardziej funkcjonalnie, dodamy do UML-a czasowniki, czyli procesy i akcje – zachowania klas.

Zauważ też, że brakuje nam tzw. stanu wejściowego. Za jego sformułowanie będzie odpowiadał inny BC: Facility Management, w którym można dodawać zasoby do obiektów i uczestników do obiektów. Będzie to pewnie standardowy CRUD z dodawaniem, edycją i usuwaniem. Możemy go sobie wyobrazić jako panel administracyjny dla zarządców, w którym wprowadzają wszystkie te dane. Nasz moduł będzie jedynie reagował na zdarzenia domenowe takie jak:

  • SharedExpenseCorrected
  • ParticipantJoined
  • ParticipantLeft
  • SettlementPeriodStarted
  • SettlementPeriodClosed

W naszym kontekście zakładamy, że takie powiązanie już istnieje i jedynie je odczytujemy (Read Model), by zbudować nasz agregat i zarządzać jego zachowaniem i spójnością.

Diagram klas – stworzony w Mermaid

Powyższy diagram to tylko szkic; gdy zacznę kodować, sporo się pewnie zmieni. Drugiej części tego wpisu spodziewaj się wkrótce. Tymczasem zostawiam cię z tymi wnioskami i zachęcam do podzielenia się przemyśleniami w komentarzach.

Problem początkowo wydawał się dość trywialny, ale im głębiej wchodzimy, tym bardziej się komplikuje. W rzeczywistości wciąż daleko nam do pisania kodu. Mamy koncepcję, mamy diagramy, ale kod musi działać. Może stworzenie aplikacji rozwiązującej ten problem byłoby dobrym pomysłem. Nie wiem, zobaczę, jak to się rozwinie w najbliższych dniach. Na razie myślę, że całkiem dobrze rozpracowaliśmy sam model. Ale jak to często bywa w programowaniu, diabeł tkwi w szczegółach.