Przejdź do głównej zawartości

Domain-driven design z agentami AI

Domain-driven design (DDD) z agentami AI działa, gdy trzy rzeczy żyją w repozytorium, a nie w głowach ludzi: język wszechobecny w pliku instrukcji agenta, niezmienniki agregatu jako testy zatwierdzone przez człowieka przed implementacją oraz granice ograniczonych kontekstów jako reguła zależności, która zatrzymuje CI. Kod pisze agent; te trzy artefakty dowodzą, że nadal pasuje do domeny.

Pół roku w projekcie i „Order” oznacza trzy różne rzeczy: wiersz w tabeli orders, DTO w checkoucie i view model w Reakcie. Agent wymyśla pola, których prawdziwa encja nie ma, każda refaktoryzacja wciąga persystencję z powrotem do warstwy domeny, a moduł Shipping po cichu importuje OrderRepository. Nikt już nie czyta każdego diffa, więc nikt nie widzi, że model i język się rozjechały, dopóki nie pokaże tego zgłoszenie błędu.

Ta strona jest dla programistów, którzy modelują domenę z agentem, i dla tech leadów, którzy odpowiadają za granice między kontekstami. Należy do etapu planowania: specyfikacja mówi, co zbudować, a ta strona pilnuje, żeby agent zbudował to w pojęciach twojej domeny.

  • Plik glosariusza, który każde narzędzie wczytuje samo, ograniczony do jednego kontekstu.
  • Zestaw testów niezmienników jednego agregatu, który zatwierdzasz, zanim powstanie implementacja, oraz sprawdzenie z podłożonym błędem, które dowodzi, że te testy potrafią się wywrócić.
  • Regułę dependency-cruiser, która zamienia „Shipping nie sięga do wnętrza Ordering” w czerwony build w CI.
  • Pięć gotowych promptów: wydobycie języka, testy niezmienników, implementacja agregatu, szkic reguły granic i audyt dryfu glosariusza.

Kolejność waży więcej niż jakikolwiek pojedynczy prompt. Każdy artefakt jest sprawdzianem dla następnego kroku, więc agent, który przeskoczy krok, wyprodukuje kod, którego nie zweryfikujesz bez czytania.

KrokArtefaktKto zatwierdzaCo sprawdza
1Glosariusz dla każdego kontekstu (CLAUDE.md / AGENTS.md / reguła projektu w Cursorze)Ekspert domenowy + tech leadNazwy w kodzie, promptach i testach
2Testy niezmienników każdego agregatuProgramista, który jest właścicielem kontekstuZachowanie agregatu
3Implementacja agregatuNikt linijka po linijce: testy z kroku 2—
4Reguła granic w CITech leadImporty między kontekstami

Kroki 1 i 4 to decyzje architektoniczne. Zapisz, dlaczego granica istnieje, w rekordzie decyzji architektonicznej, żeby agent mógł przeczytać powód, a nie tylko regułę.

Krok 1: Umieść język wszechobecny tam, gdzie agent go czyta

Dział zatytułowany „Krok 1: Umieść język wszechobecny tam, gdzie agent go czyta”

Język wszechobecny to słownictwo wspólne dla programistów i ekspertów domenowych. Umieść go w pliku instrukcji kontekstu, do którego należy, a nie w jednym olbrzymim pliku w katalogu głównym. Dzięki temu agent pracujący w Shipping nigdy nie zobaczy definicji „Account” z Billing. Każdy glosariusz niech mieści się na jednym ekranie; przycinanie plików kontekstu wyjaśnia, dlaczego długie instrukcje są ignorowane.

Treść jest w każdym narzędziu taka sama. Różni się plik i sposób wczytywania:

Utwórz regułę projektu w .cursor/rules/ dla kontekstu Ordering i zacommituj ją. Przewodnik po regułach projektu w Cursorze pokazuje, jak ograniczyć regułę do ścieżek kontekstu, żeby glosariusz działał tylko wtedy, gdy agent pracuje w src/contexts/ordering/.

Glosariusz wystarczająco precyzyjny dla agenta podaje rolę DDD każdego pojęcia i zapisuje niezmienniki jako reguły, a nie prozę:

src/contexts/ordering/AGENTS.md (albo CLAUDE.md)
# Ordering context — ubiquitous language
- **Order**: a customer's request to buy products. Aggregate root.
- **LineItem**: one product and its quantity inside an Order. Value object, immutable.
- **OrderStatus**: Pending | Paid | Shipped | Delivered | Cancelled.
- **Customer**: referenced by `CustomerId` only; owned by the Customers context.
Invariants:
- An Order has at most 10 distinct LineItems.
- Status moves only forward: Pending → Paid → Shipped → Delivered.
Cancelled is reachable from Pending and Paid only.
- `total` is derived from LineItems and never set directly.
Rules for agents:
- No persistence code (ORM decorators, `save()`) in `domain/`.
- Other contexts use `index.ts` and `events.ts` only.
- A term not in this list does not exist. Ask before inventing one.

Jeśli język istnieje tylko w notatkach ze spotkań i starych ticketach, niech agent przygotuje szkic, a ekspert domenowy go poprawi. Recenzujesz szkic, a nie kod, który będzie go używał.

Krok 2: Najpierw zatwierdź niezmienniki agregatu jako testy

Dział zatytułowany „Krok 2: Najpierw zatwierdź niezmienniki agregatu jako testy”

Agregat istnieje po to, by chronić swoje niezmienniki, więc niezmienniki są kryteriami akceptacji. Poproś o testy przed implementacją i zatwierdź je sam: to jest kontrakt, który recenzujesz zamiast diffa. To samo podejście stosują wykonywalne kryteria akceptacji dla historyjek użytkownika.

Punkt 3 celowo żąda wszystkich 25 par. Małą maszynę stanów taniej przetestować wyczerpująco niż losowo. Losowe sekwencje poleceń tylko od czasu do czasu docierają do późnego stanu, takiego jak Shipped, więc test właściwości może przy domyślnej liczbie przebiegów przeoczyć błędne przejście z tego stanu; tabela 25 wierszy sprawdza każdą parę przy każdym uruchomieniu. Testy oparte na właściwościach stosuj do niezmienników na dużych przestrzeniach wejść, takich jak sumy i ilości, a tabele do przejść stanów.

Test tabelaryczny jest na tyle krótki, że przeczytasz go w całości:

const STATUSES: OrderStatus[] = ['Pending', 'Paid', 'Shipped', 'Delivered', 'Cancelled'];
const ALLOWED: Record<OrderStatus, OrderStatus[]> = {
Pending: ['Paid', 'Cancelled'],
Paid: ['Shipped', 'Cancelled'],
Shipped: ['Delivered'],
Delivered: [],
Cancelled: [],
};
for (const from of STATUSES) {
for (const to of STATUSES) {
const allowed = ALLOWED[from].includes(to);
it(`${from} → ${to} is ${allowed ? 'allowed' : 'rejected'}`, () => {
const order = orderIn(from); // helper testowy: buduje Order w danym statusie
if (allowed) {
order.changeStatus(to);
expect(order.status).toBe(to);
}
else expect(() => order.changeStatus(to)).toThrow(OrderInvariantError);
});
}
}

Gdy zatwierdzisz specyfikację, nie pozwól agentowi implementującemu jej zmieniać: ochrona wyroczni pokazuje reguły uprawnień i kontrolę w CI dla każdego narzędzia.

Skoro testy są ustalone, prompt implementacyjny może być krótki. Musi dodać to, czego testy nie widzą, czyli kształt DDD: zachowanie na agregacie, brak setterów i brak persystencji.

Ostatnia linijka tego promptu to twój skrót do recenzji. Porównujesz krótką listę publicznych składowych z glosariuszem zamiast czytać klasę. Składowa bez pokrycia w glosariuszu, na przykład applyDiscount(), to zmyślone zachowanie: usuń ją albo świadomie dopisz pojęcie do glosariusza.

Potem udowodnij, że testy potrafią się wywrócić. Podłóż ręcznie jeden błąd, na przykład zmień >= 10 na >= 11 w sprawdzeniu liczby pozycji, i uruchom specyfikację. Jeśli zostaje zielona, jest słabsza, niż wygląda; siła wyroczni opisuje testy mutacyjne, które robią to na dużą skalę.

Krok 4: Pilnuj ograniczonych kontekstów regułą zależności w CI

Dział zatytułowany „Krok 4: Pilnuj ograniczonych kontekstów regułą zależności w CI”

Prompt, który każe agentowi „sprawdzić naruszenia granic”, to recenzja, a nie bramka: jego kod wyjścia nie zależy od tego, co znajdzie. Zamień granicę w deterministyczną bramkę za pomocą dependency-cruiser, a agenta użyj do pisania i utrzymywania reguły. Zainstaluj go z obsługiwaną wersją typescript (zobacz ostrzeżenie niżej):

Okno terminala
npm i -D dependency-cruiser typescript@^6
.dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: 'shipping-uses-ordering-public-api-only',
severity: 'error',
comment: 'Shipping reacts to Ordering events; see docs/adr/0007-context-map.md',
from: { path: '^src/contexts/shipping/' },
to: {
path: '^src/contexts/ordering/',
pathNot: '^src/contexts/ordering/(index|events)\\.ts$',
},
},
],
options: { tsPreCompilationDeps: true },
};

Uruchamiaj ją w CI obok testów. Kończy się niezerowym kodem wyjścia przy każdym naruszeniu o poziomie error:

Okno terminala
npx depcruise src --config .dependency-cruiser.cjs

Istniejące naruszenia to decyzja tech leada, a nie agenta. Albo najpierw je napraw, albo zacznij regułę od severity: 'warn', naprawiaj po jednym naruszeniu na pull request i przełącz na error, gdy lista będzie pusta. Strona o funkcjach dopasowania omawia reguły warstw, budżety złożoności i tę samą bramkę w innych stosach.

Reguła zależności łapie importy. Nie złapie nowej klasy Purchase, która dubluje Order, ani LineItem zmienionego po utworzeniu. To wyłapie przebieg agenta w trybie tylko do odczytu: zgłasza znaleziska, a decyduje człowiek. Każdy wariant zapisuje diff do .agents/tmp/pr.diff wewnątrz repozytorium, więc agent tylko do odczytu otworzy go bez dodatkowych uprawnień; dopisz .agents/tmp/ do .gitignore. Przepływ różni się między narzędziami:

Przełącz się na gałąź pull requesta, uruchom w terminalu mkdir -p .agents/tmp && git diff origin/main...HEAD > .agents/tmp/pr.diff i wklej agentowi prompt poniżej. Jeśli chcesz recenzji przy każdym pull requeście, porównaj opcje w botach do recenzji kodu, wśród nich Bugbot od Cursora.

Trzymaj prompt w .agents/prompts/glossary-drift.md, żeby każde narzędzie i każde zadanie CI uruchamiało ten sam tekst. Diff celowo leży wewnątrz repozytorium: w trybie dontAsk Claude Code bez pytania odmawia odczytu spoza katalogu roboczego, a audyt zgłosiłby wtedy „No drift found”, choć diffu w ogóle nie widział. W CI uruchamiaj go tylko na zaufanych gałęziach; dla pull requestów z forków postępuj według strony o uprawnieniach i piaskownicach, żeby ani instrukcje projektu, ani sekrety nie trafiły do niezaufanego kodu.

Wynik weryfikujesz przez artefakty, a nie przez czytanie agregatu:

KontrolaCo łapieKiedy działaKto zatwierdza
Specyfikacja niezmienników (krok 2)Złamane reguły biznesoweKażdy commitWłaściciel kontekstu zatwierdza ją raz
Przebieg z podłożonym błędemSpecyfikację, która nie potrafi się wywrócićPrzy zmianie specyfikacjiWłaściciel kontekstu
Lista publicznych składowych kontra glosariusz (krok 3)Zmyślone zachowaniaKażde zadanie implementacyjneProgramista, w kilka minut
Reguła depcruise (krok 4)Importy między kontekstamiKażdy pull request, blokującoTech lead jest właścicielem reguły
Audyt dryfu glosariuszaNowe synonimy, zmieniane obiekty wartości, przeciekającą persystencjęKażdy pull request, doradczoRecenzent segreguje znaleziska

Pull request jest gotowy, gdy pierwsze cztery kontrole są zielone, a każde znalezisko z audytu dryfu jest naprawione albo zamknięte zmianą glosariusza w tym samym pull requeście.

  • Glosariusz rozjeżdża się z biznesem. Pojęcie zmienia się na spotkaniu, a agent trzyma się starego. Wyjście: zmieniaj glosariusz w tym samym pull requeście co nazwę w kodzie i uruchom na nim audyt dryfu z punktem 5.
  • Jeden prompt obejmuje kilka kontekstów. Poprosisz o „model Order” bez nazwania kontekstu, a agent zleje Ordering, Shipping i Billing w jedną klasę. Wyjście: zaczynaj sesję w katalogu kontekstu (w Codeksie -C albo otwórz tylko ten folder) i nazwij kontekst w pierwszej linijce promptu.
  • Anemiczny model przechodzi testy. Agent wkłada reguły do OrderService, a Order zostawia jako gettery i settery; testy niezmienników przechodzą, bo wołają serwis. Wyjście: niech specyfikacja woła wyłącznie metody agregatu, i dodaj regułę dependency-cruiser, która zabrania specyfikacji agregatu importować serwisy, na przykład from: { path: '^src/contexts/[^/]+/domain/.*\\.test\\.ts$' }, to: { path: '/services/' }. Reguła zabraniająca domain/ importować z services/ zostaje tu zielona, bo to serwis importuje domenę, a nie odwrotnie. Drugą połowę łapie punkt 2 audytu dryfu: publiczne gettery i settery, których nie uzasadnia żadne zachowanie z glosariusza.
  • Agent osłabia czerwony test niezmiennika. Zamiast naprawić agregat, luzuje asercję. Wyjście: zablokuj edycję *.test.ts w sesji implementującej i dodaj kontrolę w CI z ochrony wyroczni.
  • Bramka granic jest zielona, bo niczego nie sprawdziła. Aktualizacja do typescript 7, zła ścieżka from.path albo przeniesiony folder sprawiają, że dependency-cruiser przegląda 0 modułów albo nic nie dopasowuje. Wyjście: dodaj kanarkowy krok CI, który wpisuje znany zły import do src/contexts/shipping/, uruchamia depcruise i oznacza zadanie jako nieudane, jeśli ten przebieg zakończy się kodem 0.
  • Istniejący kod narusza nową regułę od pierwszego dnia. Włączenie bramki od razu na error blokuje każdy pull request. Wyjście: zacznij od warn, naprawiaj po jednym naruszeniu na pull request i przełącz na error, gdy lista będzie pusta.