Domain-Driven Agents
Het falen heeft een specifieke vorm. Vraag om een veld voor "status van het vacatureaanbod" in een greenfield-repo en je krijgt het. Vraag erom in een systeem dat al vier jaar live is, en het model verzint een vierde spelling van een concept dat al drie keer bestaat, simpelweg omdat de codebase zelf nooit heeft beslist welke versie de juiste was. Het schrijft een adapter waar een directe aanroep prima was, of roept direct aan waar juist een adapter het hele doel was. Elk van deze fouten is in feite een vraag over het systeem die het systeem nergens beantwoordt. Het model gokt, en gokt vaak fout.
Brownfield-projecten zijn dus complex, en technische diepte is slechts de eerste laag. Daaronder ligt een tweede laag: verwarring, ontbrekende betekenis en het ontbreken van een gedeelde taal om dit op te lossen. Dat is de laag waar het model in valt. Het model is niet wat geüpgraded moet worden. De code is niet klaar, en 'klaar zijn' is iets dat we kunnen bouwen. Incrementeel. Stap voor stap.
Laat me laten zien hoe ik dit aanpak.
Het is makkelijker dan voorheen
Aan het begin van software engineering was er slechts één constant: technische schuld. Het is een natuurlijk gevolg van wat wij als ontwikkelaars proberen te bereiken. We zijn niet voorbereid op zakelijke beslissingen uit de toekomst die ons huidige beeld van de code verschuiven. We moeten leveren, en snel leveren, waarbij we bepaalde compromissen sluiten. Als gevolg hiervan groeit de code smell steeds verder. Het gebruikelijke antwoord is om een deel van het engineering-budget te besteden aan opschoning: reserveer 10-20% van het technologiebudget voor het oplossen van technische schuld. In theorie... in het volgende kwartaal...
Een vijfde van het budget gaat op aan het beslissen wat er moet veranderen en dat vervolgens uit te typen, en deze twee helften hebben nooit dezelfde prijs gehad. Het beslissen is ongeveer even duur gebleven. Het uittypen is ingestort. Een LLM voert het mechanische deel van een opschoning uit (het extraheren van een module, een refactor over twee pakketten, meer testdekking) tegen kosten die in niets meer lijken op die van 2020. Het afbetalen van technische schuld kost nog steeds tijd, maar significant minder. Wat overblijft voor mij, is het besluitvormende deel.
Strategisch versus tactisch
Ik splits het werk in tweeën, waarbij ik de termen van John Ousterhout's A Philosophy of Software Design leen, hoewel ik ze enigszins aanpas. Hij gebruikt 'tactisch' en 'strategisch' voor twee houdingen die je kunt aannemen tijdens het coderen: tactisch programmeren is "het nu werkend krijgen", strategisch programmeren is investeren in het ontwerp terwijl je bezig bent. Ik gebruik hetzelfde paar voor een splitsing in auteurschap, omdat de economische kosten langs die lijn lopen.
Strategisch werk is beslissen: het systeem lezen, uitwerken wat er moet veranderen en waarom, en of de wijziging daadwerkelijk ten dienste staat van de feature. Tactisch werk is die beslissing doorvoeren in de bestanden. Het eerste is het deel waarvoor je het systeem in je hoofd moet hebben. Het tweede is het deel dat goedkoop is geworden.
Mijn werkwijze
Bij het strategische deel ben ik volledig betrokken; bij het tactische deel ben ik eerder een reviewer dan een implementeerder. In het eerste pad analyseer ik de codebase op een meer generieke manier, waarbij ik beoordeel welke wijzigingen moeten worden doorgevoerd en hoe deze aansluiten bij de features die ik wil leveren. Het resultaat van deze aanpak zijn GitHub-issues die ik in elke repository aanmaak.
De issues worden vervolgens aangepakt door mijn AI-systeem, gebaseerd op skills en sub-agents. Een skill is een geschreven procedure: een markdown-bestand met instructies dat het model laadt wanneer de taak overeenkomt. Zo worden "een issue aanpakken" of "de contextmap regenereren" elke keer op dezelfde manier uitgevoerd, in plaats van op de manier waarop ik het die ochtend toevallig heb geformuleerd. Een sub-agent is een aparte modelsessie met een eigen verse context en een eigen specifieke taak (implementeren, controleren op security, controleren aan de hand van de specificatie), die een resultaat rapporteert in plaats van het volledige transcript in mijn sessie te dumpen.
Wanneer deze zijn geïmplementeerd, zijn de PR's (Pull Requests) klaar om bekeken te worden. Ik doorloop de review-sessies, accepteer de wijzigingen of vraag om verbeteringen. Ik kan dit incrementeel doen, waarbij ik let op de testdekking en op wat er kapot gaat: voordat een wijziging wordt doorgevoerd, moet ik weten welke andere delen van het systeem de component die ik aanpas gebruiken, en of de wijziging is waar zij tegen kunnen. Als software engineer coördineer ik, plan ik en creëer ik een pad voor de verbeteringen. Maar op dat punt hoef ik dat niet zelf te implementeren. De tijd is bespaard.
DDD als fundament
Dat laat de strategische helft over, en die is precies zoveel waard als de taal waarin deze is geschreven. Dit is waar DDD (Domain-Driven Design) om de hoek komt kijken.
DDD was altijd een van mijn keuzes voor software die ik een jaar later nog steeds kon wijzigen. De aanpak van Eric Evans gaf ons een manier om de communicatiekloof tussen de business en de technische kant te verkleinen. Domain-driven design, gebaseerd op een ubiquitous language (alomaanwezige taal) en bounded contexts, vertaalt wat de business nodig heeft direct naar het technische deel. Beide zijden spreken dezelfde taal. Met agents in het proces is die koppeling nog belangrijker: zo geven we onze behoeften door aan het model en zo lezen we de redenering van het model terug. Daarom bouw ik hier zo zwaar op voort.
Mijn implementatie
Elke repository die ik bezit, bevat een .workflow.json in de root. Dit is mijn eigen manifest, de plek waar een repo aan mijn tooling vertelt wat het is: welke talen het bevat, welke mappen een agent eerst moet lezen en welke checks moeten slagen voordat werk kan worden opgeleverd. Eén blok in dit bestand gaat over het domein, en het declareren van dat blok is de enige registratie die een repo nodig heeft. Er is geen tweede register dat uit sync kan raken.
Het blok benoemt het project, de bounded contexts, waar het glossarium van elke context staat, het type subdomein en elke verbinding (edge) naar een naburige context. Het voorbeeld komt uit een project van mij, job-offer-box, een tracker voor sollicitaties, gebouwd als twee repositories: een Rust-backend (onder het project hyperion) en een web-frontend. Hier is het manifest van de frontend, ingekort tot één verbinding:
{
"domain": {
"project": "job-offer-box",
"contexts": [
{
"name": "job-box-web",
"docs": "CONTEXT.md",
"subdomain": "supporting",
"edges": [
{
"to": "hyperion/job-offer-backend",
"direction": "outbound",
"pattern": "unclassified",
"owner": "supplier",
"shape": "codegen from the backend's document (scripts/generate-api.ts:12) ... conformist on write (src/lib/api/jobs.ts:37), ACL on read (src/lib/api/adapters/offer.ts:50)",
"note": "conformist on write and an anticorruption layer on read; two patterns hold at once, so neither name alone is true"
}
]
}
]
}
}
Lees dit in volgorde:
tois het adres: welke context aan de andere kant.directiongeeft aan wie wie aanroept; de web-repo roept de backend aan, dusoutbound(het manifest van de backend declareert dezelfde verbinding alsinbound).ownerbepaalt wiens model wint als de twee zijden het oneens zijn: de backend, dussupplier.patternis de relatie zelf, gekozen uit een gesloten vocabulaire; hier is hetunclassified, omdat de web-repo twee verschillende dingen tegelijk doet. Het accepteert de vorm van de backend zoals deze is bij het schrijven, en vertaalt deze naar een eigen vorm bij het lezen. Denotelegt dit uit; een enkel label zou in één geval correct zijn en in het andere onjuist.
Naast het manifest staat per context een CONTEXT.md, het levende glossarium met de precieze betekenis van elke term en de bewust afgewezen synoniemen. Twee bestanden per context, beide in eigendom van de repo die de code bezit. Niets daarboven is handmatig geschreven: de context map (het document dat elke context in de portfolio en elke verbinding tussen hen toont) is afgeleid. Een generator — een script dat elke repo op schijf doorloopt, de domeinblokken samenvoegt en deze als een enkele CONTEXT-MAP.md exporteert. De map is vervangbaar en opnieuw te genereren.
Terug naar job-offer-box. hyperion/job-offer-backend bezit de producttaal. Het persisteert Job Offer, Profile, Profile Variant, Resume, Cover Letter, onder de regel dat wanneer twee contexten dezelfde term gebruiken, degene die de duurzame staat beheert, het eigendom heeft. job-offer-box/job-box-web bezit alleen het scherm-vocabulaire (View Model, Filter State, Facet Stats) en markeert al het andere als [published], wat verbatim arriveert als gegenereerde TypeScript uit het OpenAPI-document van de backend. Dat is het niveau van precisie dat een agent nodig heeft. Richt je hem op de web-repo, dan weet hij dat het hernoemen van Job Offer bij de backend hoort, dat de adapters op het leespad met een reden bestaan, en welke woorden hij mag verzinnen. Met de map weet het model in welke context het zich bevindt, en met het glossarium kent het de woorden die daar worden gebruikt.
Beide zijden declareren, zodat inconsistentie mechanisch wordt
Elke verbinding wordt twee keer gedeclareerd, één keer vanuit elke zijde, en die duplicatie is precies het punt. De generator controleert de paren en is zorgvuldig over wat als een inconsistentie wordt geteld: een leverancier (supplier) benoemt zijn eigen standpunt (published-language), een consument benoemt het zijne (conformist, anticorruption-layer), dus de controle is een koppelingstabel.
Ik voer dit uit als een skill op drie momenten: wanneer ik een manifest heb aangepast, wanneer ik een repo onboard, en voordat ik iets wijzig waar een andere context van afhankelijk is. Elke inconsistentie die wordt gerapporteerd is een bevinding: één verbinding, één manier waarop de twee declaraties niet passen. Met één vlag maakt de skill van elke bevinding een DDD-issue aan in de repo die de foutieve zijde bezit. Het issue bevat een vingerafdruk (het type bevinding plus de twee adressen), zodat een herhaling na een gedeeltelijke oplossing hetzelfde issue bijwerkt in plaats van een tweede te openen, en een bevinding die niet meer voorkomt het issue sluit. Vanaf dat punt volgt het dezelfde lijn als alles wat hierboven beschreven is: een issue, een agent, een PR, mijn review.
Wat volgt er nu
Dat is de strategische laag, en die is al op zijn plek. Het bepaalt waar een context eindigt en hoe deze communiceert met zijn buren: de vorm van de map. De binnenkant van elke individuele context is nog steeds gewone code die het toelaat om een onzinnig object te bouwen en op te slaan.
Nu de contextmap er is en het glossarium is gedefinieerd, kan ik me concentreren op de codebase zelf: één context per keer migreren naar een echt domeinmodel gebouwd uit DDD-primitieven (value objects, aggregates, domain services en andere). Dat proces zorgt ervoor dat de codebase de vragen beantwoordt waar het model eerder over gokte: wat dit woord betekent, wie het bezit en waar deze context stopt.
Groetjes,