Gdy aplikacja zaczyna obsługiwać skomplikowane reguły biznesowe, same kontrolery, encje z setterami i zapytania do bazy szybko przestają wystarczać. DDD pomaga połączyć język biznesu z kodem, wyznaczyć rozsądne granice modułów i umieścić reguły tam, gdzie da się je łatwo zrozumieć oraz testować. Pokażę, czym jest Domain-Driven Design, jak stosować jego wzorce w .NET, kiedy ma sens i jakie błędy najczęściej psują całą koncepcję.
DDD porządkuje złożoną logikę biznesową, ale nie jest obowiązkową architekturą każdego systemu
- DDD zaczyna się od domeny, czyli problemu biznesowego, a nie od wyboru frameworka czy bazy danych.
- Bounded Context oddziela modele, które mogą używać tych samych pojęć w różnym znaczeniu.
- Agregat chroni reguły spójności i powinien być zmieniany przez swój korzeń agregatu.
- Value Object, encja i zdarzenie domenowe rozwiązują różne problemy, więc nie należy traktować ich jako synonimów.
- Prosty CRUD nie potrzebuje pełnego DDD. Największe korzyści pojawiają się tam, gdzie reguły biznesowe są liczne i często się zmieniają.
DDD odpowiada na problem, którego nie rozwiązuje sama architektura warstwowa
Domain-Driven Design to podejście do projektowania oprogramowania, w którym najważniejszym punktem odniesienia jest domena biznesowa. Domena opisuje rzeczywisty obszar problemu, na przykład obsługę zamówień, wyceny ubezpieczeń, rozliczanie abonamentów albo planowanie dostaw.
Nie chodzi wyłącznie o nazwanie klas zgodnie z terminologią firmy. Dobre DDD sprawia, że reguły biznesowe są widoczne w kodzie, a programista i ekspert domenowy używają tych samych pojęć. Jeżeli księgowa mówi o zamknięciu okresu rozliczeniowego, kod powinien odzwierciedlać ten proces, zamiast ukrywać go za metodą UpdateStatus().
Typowa architektura warstwowa często oddziela interfejs użytkownika, logikę aplikacyjną, domenę i infrastrukturę. To przydatny podział techniczny, ale sam w sobie nie mówi, gdzie znajduje się reguła biznesowa. Można zbudować bardzo schludne warstwy, a mimo to umieścić całą logikę w kontrolerach albo serwisach aplikacyjnych.
DDD próbuje rozwiązać właśnie ten problem. Kod powinien mówić nie tylko, jakie dane przechowuje system, ale też co wolno zrobić i pod jakimi warunkami. Dlatego model domenowy nie jest zwykłą tabelą przeniesioną do klasy.
Model anemiczny nie zawsze jest błędem
W modelu anemicznym obiekty mają głównie właściwości, a reguły znajdują się w osobnych serwisach. W systemie z rozbudowanymi procesami taki układ szybko prowadzi do rozproszenia logiki i trudnych do śledzenia zależności. Przy prostym CRUD-zie może jednak być świadomym i rozsądnym wyborem.
Nie stosuję pełnego DDD do prostego panelu administracyjnego, który zapisuje kilka pól i nie ma istotnych reguł. Rozbudowywanie takiego rozwiązania o agregaty, zdarzenia i fabryki domenowe zwiększa koszt bez proporcjonalnej korzyści. DDD jest narzędziem do zarządzania złożonością, a nie odznaką dojrzałości projektu.

Najpierw język domeny, później granice modułów
Pierwszym praktycznym krokiem jest ustalenie języka wszechobecnego, czyli wspólnego słownika używanego przez ekspertów biznesowych, analityków i programistów. To nie musi być formalny dokument. Ważniejsze jest, aby słowa takie jak „klient”, „zamówienie” czy „aktywna subskrypcja” miały jasno określone znaczenie.
To samo pojęcie może oznaczać coś innego w różnych częściach organizacji. „Klient” w module sprzedaży może oznaczać osobę, która złożyła zamówienie, a w module płatności podmiot odpowiedzialny za rozliczenie. Próba stworzenia jednej uniwersalnej klasy klienta zwykle kończy się przerośniętym modelem współdzielonym przez cały system.
Bounded Context ogranicza znaczenie modelu
Bounded Context, czyli ograniczony kontekst, wyznacza granicę, wewnątrz której model i jego pojęcia mają konkretne znaczenie. W jednym kontekście może istnieć encja Customer, a w innym Buyer albo AccountHolder. To nie jest duplikowanie dla samego duplikowania, lecz sposób na zachowanie spójności lokalnego modelu.
Przykładowy system sprzedażowy można podzielić na konteksty odpowiedzialne za katalog produktów, zamówienia, płatności i dostawę. Każdy z nich może mieć własne dane, reguły i interfejs integracyjny. Granica nie musi od razu oznaczać osobnego mikroserwisu. Na początku może być po prostu modułem w jednym monolicie.
Context Map pokazuje zależności między obszarami
Sam podział na konteksty to dopiero połowa pracy. Trzeba jeszcze opisać, jak się komunikują. Context Map przedstawia zależności między modelami, kierunek przepływu informacji i odpowiedzialność za tłumaczenie pojęć.
Jeżeli moduł zamówień otrzymuje dane z systemu płatności, nie powinien bezpośrednio przejmować jego wewnętrznych encji. Lepszym rozwiązaniem jest kontrakt, komunikat albo warstwa tłumacząca, która zamienia model jednego kontekstu na model drugiego. Dzięki temu zmiana w płatnościach nie musi rozlewać się po całej aplikacji.
Przy wyznaczaniu granic patrzę przede wszystkim na częstotliwość zmian, odpowiedzialność biznesową i potrzebę spójności transakcyjnej. Jeżeli dwa moduły stale wymagają wspólnej transakcji i mają te same reguły, ich rozdzielenie może być przedwczesne. Jeżeli natomiast często zmieniają się niezależnie, osobne granice zwykle pomagają.
Wzorce taktyczne nadają regułom biznesowym konkretne miejsce
Strategiczne DDD pomaga zdecydować, gdzie przebiegają granice. Wzorce taktyczne opisują, jak modelować kod wewnątrz takiego obszaru. Najczęściej używa się encji, obiektów wartości, agregatów, serwisów domenowych, repozytoriów i zdarzeń domenowych.
| Wzorzec | Do czego służy | Przykład |
|---|---|---|
| Encja | Reprezentuje obiekt rozpoznawany po tożsamości | Zamówienie, konto, rezerwacja |
| Value Object | Opisuje wartość bez własnej tożsamości | Adres, kwota, numer telefonu |
| Agregat | Chroni spójność grupy obiektów | Zamówienie wraz z pozycjami |
| Serwis domenowy | Obsługuje regułę, która nie pasuje do jednej encji | Kalkulator ceny ubezpieczenia |
| Zdarzenie domenowe | Informuje o fakcie, który zaszedł w domenie | OrderPlaced, PaymentAccepted |
Encja i obiekt wartości to nie to samo
Encja ma tożsamość, która pozostaje taka sama mimo zmiany właściwości. Zamówienie może zmienić status albo adres dostawy, ale nadal jest tym samym zamówieniem. Value Object opisuje wartość, dlatego dwa obiekty adresu zawierające te same dane mogą być traktowane jako równoważne.
Obiekt wartości powinien być zwykle niemutowalny i pilnować własnych reguł. Przykładowo klasa Money może zabronić ujemnej kwoty, a EmailAddress może odrzucić niepoprawny format. To drobne typy, ale usuwają wiele walidacji rozrzuconych po kontrolerach.
Agregat pilnuje niezmienników
Agregat jest granicą spójności. Jego korzeń, czyli aggregate root, powinien być jedynym miejscem, przez które kod z zewnątrz modyfikuje elementy agregatu. W przypadku zamówienia klient nie powinien samodzielnie zmieniać kolekcji pozycji. Powinien wywołać operację taką jak AddItem(), która sprawdzi reguły i dopiero wtedy zmieni stan.
public class Order
{
private readonly List _items = new();
public IReadOnlyCollection Items => _items;
public OrderStatus Status { get; private set; }
public void AddItem(ProductId productId, int quantity, Money price)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Nie można zmienić złożonego zamówienia.");
if (quantity <= 0)
throw new ArgumentOutOfRangeException(nameof(quantity));
_items.Add(new OrderItem(productId, quantity, price));
}
} Najczęstszy błąd polega na tworzeniu agregatów obejmujących pół systemu. Duży agregat powoduje długie blokady, konflikty aktualizacji i trudne testy. Zaczynam od pytania, które obiekty muszą zmienić się w jednej transakcji, zamiast grupować je według relacji między tabelami.
Zdarzenia domenowe rozdzielają skutki uboczne
Jeżeli złożenie zamówienia ma uruchomić wysłanie wiadomości, rezerwację punktów i aktualizację raportu, nie muszę umieszczać całej tej logiki w metodzie agregatu. Agregat może opublikować zdarzenie OrderPlaced, a osobne handlery wykonają działania zależne.
Zdarzenie domenowe opisuje fakt, który już nastąpił. Nie jest poleceniem w rodzaju SendEmail. To rozróżnienie pomaga utrzymać model domeny w centrum, ale trzeba pamiętać o kosztach. Komunikacja asynchroniczna oznacza ostateczną spójność, więc użytkownik może przez krótki czas zobaczyć stan pośredni.
Jak ułożyć DDD w aplikacji .NET
W aplikacjach .NET często spotykam układ z warstwą API, aplikacją, domeną i infrastrukturą. Sam podział nie gwarantuje jakości, ale dobrze ustawione zależności pomagają chronić model. Domena nie powinna znać Entity Framework Core, kontrolerów ani Azure Service Bus.
- Warstwa domeny zawiera encje, value objects, agregaty, zdarzenia i reguły biznesowe.
- Warstwa aplikacji koordynuje przypadki użycia, transakcje i uprawnienia.
- Warstwa infrastruktury implementuje repozytoria, dostęp do bazy, kolejki i zewnętrzne API.
- Warstwa prezentacji obsługuje HTTP, mapowanie żądań i odpowiedzi oraz uwierzytelnianie.
Interfejs repozytorium może znajdować się w domenie albo warstwie aplikacji, natomiast jego implementacja trafia do infrastruktury. Dzięki temu test jednostkowy agregatu nie potrzebuje uruchamiać bazy danych. To daje szybkie testy reguł i wyraźnie pokazuje, co jest logiką biznesową, a co szczegółem technicznym.
Nie każda operacja potrzebuje repozytorium domenowego
Repozytorium najlepiej pasuje do pobierania i zapisywania agregatów. Nie oznacza to, że każde zestawienie danych musi przechodzić przez rozbudowany model domenowy. Ekran raportowy może korzystać z prostego zapytania projekcyjnego, a odczyt może być zoptymalizowany niezależnie od modelu zapisu.
W tym miejscu pojawia się CQRS, czyli rozdzielenie modelu zapisu i odczytu. Nie traktuję go jako obowiązkowego dodatku do DDD. Ma sens, gdy odczyty i zapisy mają różne potrzeby, ale w małej aplikacji może tylko zwiększyć liczbę klas i poziom pośrednictwa.
Testuj zachowanie, nie strukturę tabel
Najcenniejsze testy domenowe nie sprawdzają, czy encja ma konkretną właściwość publiczną. Sprawdzają, czy zamówienie odrzuca pustą pozycję, czy nie można opłacić anulowanej rezerwacji i czy przekroczenie limitu wywołuje właściwe zdarzenie. Test powinien opisywać regułę, a nie sposób jej implementacji.
W praktyce dobrze działają testy pisane językiem domeny, na przykład „nieopłacone zamówienie nie może zostać wysłane”. Taki test jest zrozumiały dla programisty, a po niewielkim objaśnieniu także dla analityka. Zmiana mapowania w Entity Framework Core nie powinna wymuszać przepisywania tych scenariuszy.
Jak zacząć pracę z DDD bez budowania systemu od zera
Najbezpieczniej zacząć od jednego problematycznego procesu, a nie od przebudowy całej aplikacji. Dobrym kandydatem jest obszar, w którym reguły często się zmieniają, błędy są kosztowne, a kod wymaga ciągłych wyjątków i warunków.
- Wybierz proces biznesowy, na przykład naliczanie opłat albo obsługę zwrotów.
- Porozmawiaj z osobami, które znają proces, i spisz używane pojęcia oraz decyzje.
- Rozdziel fakty, polecenia i reguły, aby zobaczyć, co naprawdę dzieje się w systemie.
- Wyznacz granicę modelu i zdecyduj, które dane muszą być spójne w jednej transakcji.
- Zamknij reguły w agregacie lub value object, zamiast dodawać kolejny warunek do kontrolera.
- Dodaj testy zachowania, zanim zaczniesz przenosić kolejne fragmenty logiki.
Do warsztatów można użyć Event Stormingu, czyli pracy z wydarzeniami domenowymi na osi czasu. Nie chodzi o efektowną ścianę karteczek, ale o ujawnienie punktów decyzji, wyjątków i miejsc, w których różne zespoły rozumieją proces inaczej.
Nie przenoszę całego systemu do DDD za jednym razem. Stopniowa migracja pozwala sprawdzić, czy nowe granice rzeczywiście pomagają, a także ogranicza ryzyko wielomiesięcznej refaktoryzacji bez widocznego efektu. Czasem najlepszym rezultatem warsztatu jest decyzja, że dany moduł powinien pozostać prostym CRUD-em.
Najczęstsze błędy, które odbierają sens DDD
DDD jako zestaw folderów
Podział na katalogi Domain, Application i Infrastructure nie tworzy jeszcze architektury domenowej. Jeżeli wszystkie reguły nadal siedzą w handlerach, a encje mają publiczne settery, projekt ma tylko DDD-ową dekorację.
Jeden model dla całej firmy
Wspólna klasa używana przez sprzedaż, magazyn i fakturowanie wygląda wygodnie, dopóki każdy zespół nie zacznie dodawać własnych pól oraz wyjątków. Lepiej dopuścić do kontrolowanego powielenia danych niż utrzymywać uniwersalną encję bez jasnego właściciela.
Mikroserwis dla każdego agregatu
Agregat może być dobrą wskazówką do wyznaczania granic, ale nie oznacza automatycznie osobnego procesu, repozytorium i potoku wdrożeniowego. Mikroserwis dodaje koszty komunikacji, monitorowania, wdrożeń i obsługi awarii. Najpierw ustalam granice logiczne, a dopiero później decyduję, czy potrzebny jest podział fizyczny.
Przeczytaj również: CQRS w .NET - kiedy rozdzielenie komend i zapytań ma sens?
Nadmierne używanie zdarzeń
Jeśli każda zmiana właściwości publikuje zdarzenie, przepływ danych staje się trudny do śledzenia. Zdarzenia powinny opisywać istotne fakty domenowe, a nie zastępować zwykłe wywołania metod. Trzeba też rozwiązać problemy dostarczania wiadomości, idempotencji i ponawiania operacji.
Kiedy DDD jest dobrą inwestycją, a kiedy przesadą
| Sytuacja | Rekomendowane podejście | Dlaczego |
|---|---|---|
| Prosty panel CRUD | Klasyczny model aplikacyjny | Mało reguł i niski koszt zmiany |
| Wiele wyjątków biznesowych | DDD z bogatym modelem | Reguły wymagają jednego, czytelnego miejsca |
| Duża organizacja i kilka zespołów | DDD strategiczne i bounded contexts | Trzeba ograniczyć konflikty pojęć i zależności |
| System raportowy | Projekcje i wyspecjalizowane zapytania | Najważniejszy jest szybki odczyt danych |
| System rozwijany eksperymentalnie | Lekki model i późniejsza ewolucja | Granice domeny mogą jeszcze nie być znane |
DDD najbardziej opłaca się wtedy, gdy koszt niezrozumiałej reguły przewyższa koszt dodatkowego modelowania. Jeżeli biznes ma niewiele wyjątków, a aplikacja jest głównie formularzem zapisującym dane, prostsza architektura będzie rozsądniejsza. Największym błędem nie jest brak DDD, tylko użycie go bez potrzeby.
Moja praktyczna zasada jest prosta. Najpierw modeluję pojęcia i reguły, które naprawdę decydują o działaniu firmy, a dopiero potem dobieram wzorce. DDD nie ma sprawić, że kod będzie wyglądał bardziej zaawansowanie. Ma sprawić, że po kilku miesiącach nadal wiadomo, dlaczego system podejmuje konkretną decyzję.
Najważniejsza decyzja zapada przed napisaniem pierwszej klasy
Dobre DDD nie zaczyna się od agregatu ani od wyboru biblioteki. Zaczyna się od odpowiedzi na pytanie, jaki problem biznesowy ma pozostać spójny i kto jest właścicielem reguł. Dopiero na tej podstawie można wybrać bounded context, model encji, sposób integracji i poziom złożoności architektury.
Jeżeli potraktujesz te zasady jako zestaw narzędzi, a nie sztywny schemat, zyskasz większą swobodę. Możesz użyć samego wspólnego języka, wybranych agregatów albo pełnego modelu domenowego. Najlepsza implementacja to ta, która ułatwia zmianę reguł, ogranicza skutki błędów i pozostaje zrozumiała dla ludzi rozwijających system.
