Architektonické testy jako bezpečnostní pás proti agentic driftu

Do backendu CrushLogu jsme právě zapojili ArchUnit. Cílem nebylo vyhrát soutěž v architektonické čistotě. Šlo o odpověď na otázku, která je s každým měsícem, kdy se víc opíráme o AI agenty, ostřejší:
Když neúnavný agent udělá stovku věrohodně vypadajících změn, co zabrání tomu, aby se architektura potichu rozpadla?
Tomuto rozpadu říkáme agentic drift a architektonické testy se proti němu ukázaly jako pozoruhodně dobrý bezpečnostní pás.
Kam to v sérii zapadá
1. díl této série použil Spring Modulith k tomu, aby z hranic modulů udělal selhání buildu. Skončil záměrně poznámkou: hranice modulů jsou v éře AI-asistovaného vývoje ještě důležitější, protože jsou to mantinely, které nezávisejí na tom, že někdo drží v hlavě celý obraz.
Tenhle díl je o vrstvu níž. Stojí za to vědět jednu věc: Modulithovo verify() je pod kapotou postavené nad ArchUnitem. Modulith vám dává kurátorovaný výřez toho enginu ve tvaru hranic modulů. ArchUnit je ten engine samotný — a když po něm sáhnete přímo, můžete vyjádřit invarianty, které hranice modulů vyjádřit neumějí: „každá mutace musí být autorizovaná", „žádná field injection", „tahle anotace je v testech zakázaná". Stejná filozofie vynucování, jemnější granularita.
Jak agentic drift ve skutečnosti vypadá
Lidský autor změny nese v hlavě celý mentální model: „mutace potřebují @PreAuthorize", „neblokuj uvnitř command handleru", „doménové handlery neimportují vygenerované GraphQL typy". AI agent nese model diffu, který má před sebou. Každá jednotlivá změna je lokálně rozumná. Drift je emergentní — projeví se napříč desítkami úprav, z nichž žádná sama o sobě nevypadá špatně.
Obvyklé obrany tohle nezachytí dobře:
- Code review škáluje s pozorností recenzenta a ten porovnává vzory na řádcích, které se změnily, ne na invariantu, který se potichu oslabil o tři soubory dál.
grepaudit najde to, co vás napadlo hledat. Je slepý vůči variantě, kterou jste nepředvídali.- Style linter vidí syntaxi, ne architekturu.
Co chcete, je spustitelné vyjádření invariantu samotného — takové, které shodí build ve chvíli, kdy ho jakákoli změna, lidská nebo agentova, poruší. To je ArchUnit.
Pravidla, která jsme zamkli
Náš CLAUDE.md už pravidla kódoval v próze. Próza build neshodí. Tak jsme nejcennější invarianty přeložili do testů, s jednou tvrdou filozofií: padej tvrdě, žádný tolerovaný baseline. Baseline soubor je jen drift s papírovou stopou. Když pravidlo najde skutečné porušení, opravíš ho ve stejné změně, nebo to pravidlo nenasadíš.
Nasadili jsme tři navazující PR: bezpečnost, konvence a hranici Axon CQRS. Takhle ta pravidla skutečně vypadají — je to JUnit 5 + ArchUnit, což je nativní idiom té knihovny, i když zbytek našeho test suite je Spock/Groovy.
Bezpečnost: každá mutace je autorizovaná
Hlavní pravidlo je malé a čte se skoro jako anglická věta, kterou nahrazuje:
@ArchTest
static final ArchRule mutationsMustBeAuthorized =
methods()
.that()
.areAnnotatedWith(DgsMutation.class)
.and()
.areNotAnnotatedWith(PublicMutation.class)
.should()
.beAnnotatedWith(PreAuthorize.class)
.because(
"every GraphQL mutation must enforce authorization; intentionally public"
+ " mutations must be marked @PublicMutation");Zajímavé designové rozhodnutí je ta výjimka. Místo seznamu „mutací, které smějí být veřejné" v nějakém souboru, který nikdo nečte, opt-out žije přímo na místě volání a je nucený nést zdůvodnění:
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PublicMutation {
/** Lidsky čitelné zdůvodnění, proč je mutace bezpečné vystavit bez autentizace. */
String value();
}A druhé pravidlo dělá to zdůvodnění nepovinné jen zdánlivě — prázdný důvod shodí build stejně jako chybějící:
@ArchTest
static final ArchRule publicMutationsMustJustifyTheExemption =
methods()
.that()
.areAnnotatedWith(PublicMutation.class)
.should(/* ArchCondition: důvod nesmí být prázdný */ ...)
.because(
"each public mutation exemption must document in code why it is safe"
+ " to expose unauthenticated");Byl tu i jeden záludný bypass, který jsme museli zavřít. Mutace deklarovaná přes @DgsData(parentType = "Mutation") místo konvenčního @DgsMutation by autorizačním pravidlem proklouzla úplně — je to mutace, kterou první pravidlo nevidí. Takže třetí pravidlo tuhle formu plošně zakazuje a nutí každou mutaci projít anotací, kterou to bezpečnostní pravidlo skutečně kontroluje. Přesně tahle mezera je zřejmá, jakmile ji vyslovíte, a v review neviditelná.
Taky držíme bezpečnostní kontext uvnitř jeho package — volající musí jít přes reactive supplier, nikdy nesahat na holder přímo:
@ArchTest
static final ArchRule securityContextStaysInternal =
noClasses()
.that()
.resideOutsideOfPackage("pro.crushlog.infrastructure.security..")
.should()
.dependOnClassesThat()
.areAssignableTo(CrushlogSecurityContext.class)
.because(
"callers must use ReactiveAuthenticationSupplier.getUserId(), never"
+ " CrushlogSecurityContext directly");Konvence: scheduling a injection
Další dva invarianty, které recenzenti dřív vynucovali po paměti. Každý @Scheduled job musí být buď distribuovaně zamčený (@SchedulerLock), aby ho spustil přesně jeden pod, nebo explicitně označený @PerPodScheduled("reason"), když je běh per-pod záměrný. A produkční kód používá výhradně constructor injection — žádný field ani setter @Autowired:
@ArchTest
static final ArchRule noFieldAutowired =
noFields()
.should()
.beAnnotatedWith(Autowired.class)
.because("constructor injection only — never field/setter @Autowired");Tahle pravidla jsou přes ImportOption.DoNotIncludeTests omezená na produkční kód, takže legitimní wiring na straně testů jimi není dotčen.
Hygiena testů: zelené regresní hlídky
Některá pravidla hlídají znovuzavedení problému, který jste už uklidili. V testech zakazujeme anotace, které otravují context cache — @DirtiesContext a ve Spring Bootu 4 odstraněné @MockBean/@SpyBean — aby Spring test kontext zůstal sdílený napříč celým suite. Matchujeme je plně kvalifikovaným jménem jako string, takže hlídají i poté, co legacy typy zmizí z classpath:
@ArchTest
static final ArchRule noDirtiesContext =
bansAnnotation(
"org.springframework.test.annotation.DirtiesContext",
"@DirtiesContext rebuilds the Spring test context, destroying context-cache"
+ " sharing across the suite");Tahle pravidla jsou dnes zelená. A to je pointa — jsou to nástrahy pro dalšího agenta (nebo člověka), který sáhne po pohodlné-ale-špatné anotaci.
Hranice Axonu
Třetí PR vynutil pravidlo CQRS: Axon command/event handlery nesmějí záviset na vygenerovaných GraphQL typech — doména zůstává vyjádřená v doménových typech. V ArchUnitu je to klasický tvar závislosti mezi package:
noClasses()
.that().resideInAPackage("..domain..") // CQRS handlery
.should().dependOnClassesThat().resideInAPackage("..generated..")
.because("the domain must not leak into the GraphQL transport layer");Každé pravidlo přichází s důkazem negativní kontroly: invariant jednou záměrně porušíme a potvrdíme, že test zčervená. Zelený architektonický test, který nedokáže spadnout, je dekorace.
Ta zajímavá část: pravidla našla věci, které jsme nehledali
Tady přestává být rámec agentic driftu teorií.
Výjimka, kterou lidský audit přehlédl
Než jsme napsali pravidlo „mutace musí být autorizované", udělali jsme zodpovědnou věc — manuální grep audit neautentizovaných mutací. Našel dvě (token-based flows pro souhlas zákonného zástupce, legitimně veřejné).
Pravidlo v ArchUnitu při prvním červeném běhu našlo čtyři. Vyplavalo druhou dvojici — acceptCoachingInvitation / declineCoachingInvitation — kterou grep minul. Ukázalo se, že jsou taky legitimně token-based, takže jsme je označili @PublicMutation se zdůvodněním, místo abychom na ně lepili autentizaci. Ale ta lekce je pointa: audit, kterému jsem věřil, byl neúplný, a spustitelné pravidlo ne. Přesně takový tvar má agentic drift.
Porušení hranice, které nikdo nečekal
Pravidlo „Axon handlery nezávisejí na vygenerovaných typech" mělo opravit dvě známá místa. Červený běh našel tři — zatoulanou závislost v content-review službě admin modulu, kterou nikdo neoznačil. Opravili jsme to pořádně (zavedli chybějící doménový enum ActivityType a směrovali přes něj), místo abychom pravidlo rozšířili a porušení nechali projít.
Skutečný latentní produkční bug
Nejcennější nález ani nebyl pravidlo, které jsme nasadili. Při zjišťování, jestli je proveditelné pravidlo „žádný .block() v reaktivním kódu", sken vyhrabal ~75 blokujících míst. Většina se shlukla do legitimních výjimek (scheduled joby, async tasky, Temporal aktivity). Ale jedno vyčnívalo: SocialCommandHandler volá .block() na reaktivním Neo4j repository uvnitř @CommandHandler — přesný otisk 90sekundových stallů command handlerů, které jsme v produkci dřív honili. Audit našel skutečný latentní bug schovaný na očích, označený k vlastní opravě.
Pravidlo, které jsme odmítli napsat
Poctivost je důležitější než zelená fajfka. Chtěli jsme pravidlo vynucující „Lombok @RequiredArgsConstructor / @Builder všude", aby zabilo boilerplate.
Nešlo to — a nepředstírali jsme to. Lombok anotace jsou @Retention(SOURCE): mažou se před bytecode, takže jsou pro ArchUnit, který čte bytecode, neviditelné. Náhradu — „beany mají mít jediný konstruktor a jen final fieldy" — jsme změřili: 510 ne-final fieldů ve 54 třídách, 20 tříd s víc konstruktory (většinou Axon agregáty, které potřebují bezparametrický konstruktor). Donutit tohle pravidlo do zelené by znamenalo obří baseline soubor, tedy drift s papírovou stopou.
Tak jsme pravidlo vynechali a sáhli po správném nástroji: lombok.config plus migrace přes OpenRewrite. Pravidlo, které musíš baseline-ovat do bezcennosti, je horší než žádné — dává falešnou jistotu, a to je přesně to, co si nemůžeš dovolit, když píšou agenti.
Proč na tom v éře agentů záleží víc
Žádná z těchhle technik není nová. Architektonické testy předcházejí současné vlně AI kódování o dekádu. Co se změnilo, je tempo věrohodných změn. Agent vyprodukuje správně vypadající diffy rychleji, než stihne jakýkoli review proces vstřebat architektonický kontext za nimi. Obrany, které škálují, jsou ty, které nezávisejí na tom, že člověk drží celý model v hlavě ve chvíli review:
- Zakóduj invariant, nejen ho dokumentuj. Próza v
CLAUDE.mdagenta vede; spustitelný test ho zastaví. - Padej tvrdě, žádný baseline. Soubor tolerovaných porušení je pomalý únik.
- Dokaž, že každé pravidlo umí spadnout. Negativní kontroly, nebo je to dekorace.
- Odmítni pravidla, která neumíš poctivě vynutit. Falešně zelené pravidlo je horší než žádné.
- Označuj výjimky na místě volání a se zdůvodněním.
@PublicMutation("…")poráží seznam v souboru, který nikdo nečte — cestuje s kódem a přežije review.
Agent je rychlý a neúnavný. Dej mu mantinely, které jsou stejně neúnavné. Build buď zůstane v rámci architektury, nebo zčervená — žádná bdělost nutná, žádný ztracený kontext, žádný drift.
Toto je 3. díl série Spring Modulith v praxi. 1. díl: Vynucování architektury v rostoucím monolitu. 2. díl: Od kódu k živým diagramům architektury.
Titulní foto: Maxim Hopman na Unsplash.


