Sześć zasad DDD dla strukturyzowania domen w aplikacjach NestJS
Przyswoj sześć praktycznych zasad projektowania napędzanego domeną do organizacji modułów, entytetów i zdarzeń w NestJS, aby funkcje pozostawały izolowane i łatwe do utrzymania.
Po pół roku pracy nad większością kodów NestJS pojawia się znajomy problem. Dodajesz jedno pole, aby obsłużyć nową funkcjonalność, a test w zupełnie niepowiązanym fragmencie aplikacji nagle przestaje działać. Ktoś z zespołu pyta, gdzie dokładnie znajduje się logika obsługi zamówień, a uczciwa odpowiedź brzmi: „Rozproszona, trochę wszędzie”.
To nie jest oznaka niedbałego projektowania. Zazwyczaj wskazuje to na to, że kod został zorganizowany wokół warstw technicznych, a nie wokół koncepcji, które reprezentuje. Pełne podejście Domain-Driven Design to złożona praktyka, której większość zespołów nigdy w pełni nie przyjmuje. Poniżej przedstawiam uproszczoną wersję: sześć zasad, które rzeczywiście przynoszą korzyści w aplikacjach Nest, pomijając te bezużyteczne. Traktuj to jako selektywne DDD.
Zasada 1 — Nie sprawiaj, by jeden model obsługiwał całą aplikację
Prawie każdy skomplikowany backend ma w swoim centrum obiekt „Bóg”. Często nazywa się on Order, posiada dziesiątki pól, które mogą być puste, a połowa kodu go importuje. W końcu zmiana wprowadzona dla zespołu magazynowego potajemnie psuje proces składania zamówień.
Ten jeden Order w rzeczywistości to trzy różne funkcje ukryte pod jedną nazwą:
- Checkout zajmuje się cenami, zniżkami oraz intencją płatności.
- Fulfillment zajmuje się numerami SKU i adresem dostawy, nie interesując się zniżkami.
- Billing zajmuje się kwotą oraz numerem faktury.
Gdy jedna klasa próbuje obsłużyć wszystkie trzy funkcje, pole z informacją o zniżce znajduje się tuż obok adresu dostawy. Zmiana w jednym elementie niesie ryzyko uszkodzenia pozostałych dwóch.
Rozwiązaniem jest przyznanie każdej dziedzinie własnego modelu i umożliwienie im komunikacji poprzez wiadomości, zamiast za pomocą wspólnej klasy.
class Cart {
lines: CartLine[];
discount: Money;
paymentIntentId: string;
}
class FulfillmentOrder {
orderId: string;
shipTo: Address;
picks: Pick[];
}
this.events.emit(new OrderPlaced(order.id, order.shipTo, picks));
Zauważ, że FulfillmentOrder jest identyfikowany wyłącznie przez orderId i w ogóle nie zawiera danych dotyczących cen ani zniżek — proces płatności przekazuje informacje o realizacji zamówienia poprzez zdarzenie OrderPlaced, zamiast ujawniać własną wewnętrzną klasę. Oznacza to, że zmiana cen nie może w żaden sposób przeniknąć do logiki magazynu tylko dlatego, że kompilator na to pozwala. Każda z tych dziedzin tworzy ograniczony kontekst: swój własny model, w którym słowo „zamówienie” oznacza jedną precyzyjną rzecz. W monolicie może to być zbiór modułów posiadających własne tabele bazodanowe; w architekturze mikrosług może to być zupełnie oddzielna usługa. W obu przypadkach obowiązuje ta sama zasada — nigdy nie dzielić modelu pomiędzy różne obszary.
Korzystnym testem jest następujący: zmiana w jednym kontekście nigdy nie powinna zmuszać cię do edycji innego. Jeśli tak się dzieje, twoje granice są wyznaczone w niewłaściwym miejscu.
Zasada 2 — Moduł to domena, a nie warstwa
Załóżmy, że twój menedżer produktu prosi o dodanie funkcji opakowywania prezentów do zamówień. Sprawdź, ile to kosztuje, gdy projekt jest zorganizowany według ról technicznych, a nie tematów:
src/
├── controllers/ # order, auth, product, shipment, payment...
├── services/ # order, auth, product, shipment, payment...
├── entities/
└── enums/ # every enum in the whole app
Otwierasz folder controllers/ i przewijasz się obok sekcji dotyczących autoryzacji i wysyłki, aby znaleźć kontroler zamówień. Następnie powtarzasz to przewijanie w folderze services/, a potem ponownie w entities/ i enums/. Cztery lub pięć folderów, cztery lub pięć razy przewijanie – a jedyna funkcja, którą dodajesz, jest rozproszona we wszystkich z nich.
To ułożenie odpowiada na pytanie „pokaż mi wszystkie kontrolery”, które prawie nikt tak naprawdę nie zadaje. Pytanie, które ludzie faktycznie mają, brzmi „pokaż mi wszystko związane z zamówieniami”. Dlatego najpierw strukturyzuj kod według domeny:
modules/orders/
├── controllers/
├── dto/
├── entities/
│ ├── order.entity.ts
│ └── order-status.enum.ts # the enum sits next to what it uses
├── repositories/
└── orders.module.ts
W tym układzie opakowywanie prezentów dotyczy dokładnie jednej folderu. Zauważ, że na najwyższym poziomie nie ma katalogu enums/ do przechowywania wszystkiego – enum powinien znajdować się obok tego, co opisuje. Jedyna zasada, której należy przestrzegać: common/ powinien zawierać tylko elementy, które nie należą do żadnej konkretnej domeny, takie jak narzędzia do paginacji czy klasa bazowego repozytorium. Gdy tylko common/ zacznie wiedzieć, czym jest zamówienie, faktycznie staje się kolejnym modulem w przebraniu.
Zasada 3 — Moduły powinny polegać na interfejsach, a nie na usługach innych modułów
Załóżmy, że dwa funkcje są wdrażane w tym samym sprintie. Strona produktu musi pokazywać „3 otwarte zamówienia”, więc catalog łączy się z modułem OrderService. Tymczasem paragon wymaga nazw produktów, więc orders łączy się z modułem ProductService. Nest odmawia połączenia tych elementów:
Nest cannot create the CatalogModule instance.
- A circular dependency between modules. Use forwardRef() to avoid it.
Zamknięcie iniekcji w funkcję forwardRef() tłumi błąd, ale jednocześnie trwale łączy te dwa moduły. Prawidłowym rozwiązaniem jest poleganie na małej interfejsie, który sam się definiuje, zamiast łączenia się z usługą innego modułu.
catalog musi zapobiec usunięciu produktu, który nadal znajduje się w otwartym zamówieniu – ale tylko orders posiada te informacje. Zamiast importować moduł orders, catalog po prostu definiuje pytanie, na które potrzebuje odpowiedzi:
export interface ProductUsageGuard {
isProductInUse(productId: string): Promise<boolean>;
}
Moduł orders dostarcza odpowiedź poprzez implementację tej interfejsu i rejestrację samego siebie, dzięki czemu catalog może zadawać pytania bez konieczności importowania cokolwiek z orders:
for (const guard of this.guards) {
if (await guard.isProductInUse(id)) throw new ProductInUseError(id);
}
Before: catalog ⇄ orders circular — Nest won't boot
After: catalog ◄──implements── orders one way — catalog owns the interface
Zależność teraz skierowana jest w jednym kierunku, więc nie ma cyklu i nie ma potrzeby odwoływania się do forwardRef(). Jako dodatkową zaletę, jeśli później pojawi się zasada typu „nie można usunąć produktu powiązanego z aktywnym abonamentem”, moduł abonamentów może zarejestrować swój własny mechanizm ochrony, a catalog w ogóle nie musi ulegać zmianom.
Zasada 4 — Utrzymuj kontrolery w prostocie i pozwól entytetom przenosić logikę
Rozważmy zasadę typu „nie można anulować zamówienia, które już zostało wysłane”. Gdzie powinna znajdować się ta logika? W wielu bazach kodowych trafia ona tam, gdzie po raz pierwszy była potrzebna — zazwyczaj ukryta wewnątrz jakiejś usługi. Następnie panel administracyjny wymaga tego samego sprawdzenia, podobnie jak nocna praca zbiorcza, a w końcu także obsługa webhooków. Każde z tych miejsc implementuje zasadę nieco inaczej, ktoś zapomina o czwartej kopii, i nagle zamówienia już wysłane są zwracane.
Gdy jednostka danych to nic więcej niż zbiór pól publicznych, które są bezpośrednio modyfikowane przez inny kod, otrzymujemy model słabo zdefiniowany — a objaw jest zawsze taki sam: reguły biznesowe wyciekają i są kopiowane do każdej usługi, która ma dostęp do tych danych.
Zamiast tego należy przenieść zasadę do obiektu, który faktycznie kontroluje stan danych:
@Entity()
export class Order {
status: OrderStatus = OrderStatus.DRAFT;
cancel(): void {
if (this.status === OrderStatus.SHIPPED) {
throw new Error('Cannot cancel an order that already shipped');
}
this.status = OrderStatus.CANCELLED;
}
Teraz istnieje dokładnie jedno miejsce, gdzie zdefiniowano funkcję „cancel”, a żaden wywołujący kod nie ma możliwości obejścia tej kontroli – po prostu nie ma alternatywnego ścieżki. Testowanie jednostkowe jest proste i nie wymaga korzystania z bazy danych. Warstwa usług po prostu koordynuje poszczególne kroki (order.cancel(), wypłata zwrotu, zapisanie danych), podczas gdy kontroler praktycznie nie pełni żadnej funkcji:
@Post(':id/cancel')
cancel(@Param('id') id: string) {
return this.orders.cancel(id);
}
Oto podział obowiązków w jednym obrazku:
HTTP ─► Controller ─► Service ─► Order (the rules)
└─────► Repository ─► DB (the queries)
Entytety egzekwują zasady, repozytoria obsługują zapytania, usługi koordynują sekwencję wywołań, a kontrolery zajmują się wyłącznie protokołem HTTP.
Zasada 5 — Projektuj dane tak, aby nie mogły istnieć nieważne stany
Zasada 4 przeniosła logikę na entytety. Dwa dodatkowe wzory pomagają dokończyć pracę i każdy z nich eliminuje określoną klasę błędów.
Obiekty wartości obsługują prymityw, który zawiera reguły. Łączna kwota zamówienia to po prostu liczba, więc nic nie stoi na przeszkodzie temu, by kupon obniżył ją poniżej zera lub aby zwrot w EUR trafił na zamówienie opłacone w USD. Reguły definiujące „pieniądze” nie znajdują się w żadnym konkretnym miejscu. Aby to naprawić, należy nadać pieniądzom własny typ, który będzie egzekwował te reguły:
export class Money {
private constructor(readonly cents: number, readonly currency: string) {}
static of(cents: number, currency: string): Money {
if (cents < 0) throw new Error('Money cannot be negative');
return new Money(cents, currency);
}
add(o: Money): Money {
if (o.currency !== this.currency) throw new Error('Currency mismatch');
return Money.of(this.cents + o.cents, this.currency);
}
}
Gdy to zostanie wdrożone, ujemna łączna kwota lub niezgodność walut nie będzie już mogła powstać — sam typ temu zapobiega. To właśnie nazywa się obiektem wartości: małym, niezmienialnym typem identyfikowanym przez swoją wartość, a nie przez identyfikator. Używaj takich obiektów wtedy, gdy prymityw idzie w parze z regułami, które musisz sprawdzać raz po raz — np. pieniądze, adresy e-mail, zakresy CIDR — ale pomijaj je w przypadku czegoś tak prostego jak zwykły identyfikator.
Agregaty obsługują zasady dotyczące kilku obiektów jednocześnie. Łączna kwota zamówienia musi zawsze odpowiadać sumie jego pozycji. Jeśli OrderLine będzie miał własny repositoryj, prędzej czy później ktoś zapisze taką pozycję bez aktualizacji rodzicielskiego zamówienia, w wyniku czego łączna kwota zostanie błędnie ustalona. Rozwiązaniem jest od samego początku uniemożliwienie takiego scenariusza: niech Order będzie korzeniem agregatu — jedynym obiektem, który jest ładowany lub zapisywany, punktem wejścia do tej części modelu. Nie istnieje OrderLineRepository; pozycje mogą być modyfikowane wyłącznie poprzez samo zamówienie:
addLine(sku: string, price: Money, qty: number): void {
if (this.status !== OrderStatus.DRAFT) throw new Error('Order already placed');
this.lines.push(new OrderLine(sku, price, qty));
this.total = this.sumOfLines();
}
Dzięki jednemu punktowi wejścia nie może dojść przypadkowo do naruszenia tej zasady. Trzymaj swoje agregaty tak małe, jak to możliwe — tylko te elementy, które rzeczywiście muszą ulec zmianie w ramach tej samej transakcji — oraz odwołuj się do innych agregatów za pomocą identyfikatorów, zamiast przechowywać bezpośrednie referencje do obiektów.
Zasada 6 — Publikuj wydarzenia zamiast bezpośrednio wywoływać usługi
Proces płatności zaczyna się prosto, ale potem funkcja place() stale się rozszerza: zapisywanie zamówienia, wywołanie usług dostawy, wywołanie systemu fakturowania, wysyłanie e-maila. W tym momencie moduł orders obejmuje już połowę aplikacji i musi być świadomy każdego kolejnego kroku. Jeśli w następnym kwartale dodasz funkcję punktów lojalnościowych, znów będziesz edytować moduł płatności z powodu czegoś, co nie ma z nim nic wspólnego.
Zmień kierunek działania. Moduł orders wykonuje swoją pracę, a następnie informuje o tym, co się stało — nie wie, kto, jeśli w ogóle, to słucha:
this.events.emit(new OrderPlaced(order.id, order.customerId, items));
Każdy zainteresowany kontekst reaguje niezależnie:
@OnEvent(OrderPlaced.name)
handle(e: OrderPlaced) { return this.shipping.createShipment(e); }
Dodawanie punktów lojalnościowych oznacza teraz konieczność umieszczenia słuchacza w module lojalności; orders pozostaje nietknięty. W przypadku oddzielnych usług ta sama zasada obowiązuje przy użyciu brokera wiadomości (np. RabbitMQ), z jedną dodatkową ochroną: transakcyjnego kontenera wyjściowego. Zapisujesz zdarzenie do tabeli outbox w ramach tej samej transakcji, która zapisuje zamówienie, a później oddzielny procesor je publikuje. Bez tego kroku awaria pomiędzy zapisaniem zamówienia a publikacją zdarzenia powoduje jego ciche utratę – w środowisku produkcyjnym to właśnie różnica między systemem niezawodnym a problematycznym.
Jedna uwaga: zdarzenia utrudniają zrozumienie ogólnego przebiegu, ponieważ nie ma jednego miejsca, gdzie widać pełną sekwencję wydarzeń. Należy je używać tylko do reakcji przekraczających granice kontekstu, a nie do kroków należących do jednej spójnej operacji.
Korzyści
Zastosowanie DDD w sposób selektywny nie polega na dodawaniu kolejnych warstw architektury. Chodzi o umieszczenie każdej części logiki tam, gdzie jej miejsce: modele zawierają reguły, moduły odpowiadają za swoje domeny, repozytoria obsługują zapytania, a wydarzenia łączą jeden kontekst z drugim. Trzymając się tych granic, aplikacja NestJS pozostaje łatwiejsza do zrozumienia, modyfikacji i rozwijania z upływem czasu.
Literatura pokrewna
- Dlaczego NestJS zwycięża w rozwoju zespołów backendu i bazy kodu — Dowiedz się, jak struktura NestJS, iniekcja zależności oraz podejście oparte na TypeScript pomagają zespołom inżynieryjnym rozwijać się bez popadania w chaos.