Architektonická pravidla, která existují jen v dokumentaci, se dříve nebo později poruší. Není to otázka jestli, ale kdy.
Někdo přesune doménovou třídu do infrastrukturní vrstvy, protože „potřeboval přístup k databázi". Jiný pojmenuje handler ProcessPaymentService, protože nevěděl o zavedené konvenci. Třída, která má být internal, bude public, protože to tak IDE vygenerovalo. A v code review to nikdo nezachytí.
Testy architektury tento problém řeší elegantně: architektonická pravidla se zapíší jako unit testy a spustí se v CI. Porušení pravidla = testy spadnou. Pravidla jsou živá dokumentace – ne PDF na Confluence, ale kód, který se spouští při každém commitu.
C# a ArchUnitNET
Knihovna ArchUnitNET je .NET port Javovské ArchUnit. Nabízí fluent API pro psaní architektonických pravidel, která pak běží jako standardní unit testy.
Základní nastavení je přímočaré – načtou se vrstvy a vytvoří se sdílená instance Architecture, která reprezentuje model všech typů a jejich závislostí:
private static readonly System.Reflection.Assembly DomainAssembly =
System.Reflection.Assembly.Load("Arch.Domain");
private static readonly System.Reflection.Assembly UiAssembly =
System.Reflection.Assembly.Load("Arch.UI");
private static readonly System.Reflection.Assembly DataAssembly =
System.Reflection.Assembly.Load("Arch.Data");
private static readonly Architecture Architecture = new ArchLoader()
.LoadAssemblies(UiAssembly, DataAssembly, DomainAssembly)
.Build();
Závislosti mezi projekty
Hlavní pravidlo Clean architektury je směr závislostí – vnitřní vrstvy nesmí záviset na vnějších. Projektové reference sice zabrání zjevným cyklickým závislostem, ale NuGet balíčky a přeorganizování projektů v repozitáři mohou pravidla porušit nenápadně.
Reálný příklad: vývojář přidá do Domain projektu DbContext, aby nemusel přetahovat logiku do Application vrstvy. Kompilátor to nezastaví, pokud je balíček tranzitivně dostupný. Test ano.
[Test]
public void Domain_does_not_depend_on_data()
{
Types()
.That()
.Are(DomainLayer)
.Should()
.NotDependOnAny(DataLayer)
.Because("domain remains pure. Outer layers depend on the outside world. For more information, see 'Clean Architecture'")
.Check(Architecture);
}
[Test]
public void Domain_does_not_depend_on_UI()
{
Types()
.That()
.Are(DomainLayer)
.Should()
.NotDependOnAny(UiLayer)
.Because("domain remains pure. Outer layers depend on the outside world. For more information, see 'Clean Architecture'")
.Check(Architecture);
}
Fluent API čte jako angličtina: „typy, které jsou v doménové vrstvě, by neměly záviset na žádném typu z datové vrstvy". Při selhání test jasně řekne, který typ je problematický – a parametr .Because() zajistí, že chybová hláška obsahuje rovnou i zdůvodnění pravidla, bez nutnosti ho hledat v dokumentaci. Toto zásadně pomáhá při onboardingu nováčků do týmu.
Pojmenovávací konvence
Můžete mít například pravidlo, že třída v datové vrstvě by se měla jmenovat UserEntity, ne User, UserModel nebo UserRecord. Dokud to není vynucené, každý vývojář si zvolí variantu, která mu přijde přirozená, a po půl roce má projekt tři různé konvence vedle sebe. ArchUnitNET umí toto pravidlo prosadit pro všechny třídy v dané assembly najednou.
[Test]
public void Models_in_data_layer_have_the_entity_suffix()
{
Classes()
.That()
.Are(DataLayer)
.Should()
.HaveNameEndingWith("Entity")
.Because("we want to have a clear naming convention for our entity classes in the data layer.")
.Check(Architecture);
}
Stejným způsobem lze hlídat i viditelnost typů – například že command handlery jsou internal, protože jsou implementačním detailem a nemají být volané přímo z jiných vrstev. Nebo že doménové třídy nezávisí na konkrétních frameworcích jako Entity Framework Core, i když je balíček tranzitivně dostupný.
Testy běží stovky milisekund, nevyžadují žádnou infrastrukturu navíc a spustí se s každým buildem.
Rust a cargo-pup
Pro Rust existuje knihovna cargo-pup od Datadogu, která analogickým způsobem umožňuje psát architektonická pravidla pro Rust projekty.
Dioxus aplikace cílí na více platforem – desktop i web. V prohlížeči nelze číst soubory ze systému, takže každý přímý import std::fs nebo tokio::fs v UI modulu je potenciální chyba za běhu na webu. Toto pravidlo zajistí, že UI moduly neimportují nic platformně specifického a práce se soubory zůstane delegovaná do separátního modulu:
#[test]
fn ui_can_not_depend_on_platform_specific_modules() {
let mut builder = LintBuilder::new();
builder
.module_lint()
.lint_named("ui_can_not_depend_on_platform_specific_modules")
.matching(all_relevant_ui_modules)
.with_severity(Severity::Error)
.restrict_imports(
None,
Some(vec![
".*dioxus::desktop.*".to_string(),
".*dioxus::web.*".to_string(),
".*std::fs.*".to_string(),
".*tokio::fs.*".to_string(),
".*reqwest.*".to_string(),
]),
)
.build();
assert_rules(&builder, "UI module should only depend on platform-agnostic Dioxus module.");
}
DTO jsou privátní
Data transfer objekty jsou implementačním detailem HTTP klienta. Pokud by se dostaly do jiných modulů, každá změna HTTP API by se kaskádově promítla do kódu. Knihovna cargo-pup umí na základě pojmenování prosadit, že typy s příponou Dto zůstanou privátní:
#[test]
fn dtos_used_by_http_client_are_private() {
let mut builder = LintBuilder::new();
builder
.struct_lint()
.lint_named("dtos_used_by_http_client_are_private")
.matching(|m| m.name(".*Dto"))
.with_severity(Severity::Error)
.must_be_private()
.build();
assert_rules(&builder, "DTOs are owned by HTTP client.");
}
Délka funkcí
Dlouhé funkce jsou symptomem příliš mnoha zodpovědností na jednom místě. Pravidlo na maximální délku funguje jako síť, která zachytí funkce, které se vymkly kontrole – ne jako náhrada za dobré rozdělení logiky, ale jako pojistka pro případ, kdy si nikdo nevšiml, že funkce narostla na 500 řádků:
#[test]
fn function_size_is_restricted() {
let mut builder = LintBuilder::new();
builder
.function_lint()
.lint_named("function_length_check")
.matching(|m| m.in_module(".*"))
.with_severity(Severity::Error)
.max_length(500)
.build();
assert_rules(&builder, "We prefer short functions to long functions. It helps reduce congnitive load.");
}
Dalším typem pravidla, které cargo-pup podporuje, je zákaz unsafe funkcí. Projekt může záměrně zakázat jejich tvorbu – používat knihovny s unsafe interně je v pořádku, ale vlastní unsafe bloky mohou narušit záruky, na kterých Rust staví.
Limitace cargo-pup
Tato knihovna je užitečná, ale má zásadní omezení, na které je třeba myslet.
Inline importy nejsou detekovány
cargo-pup kontroluje pouze use příkazy na úrovni modulu. Pokud vývojář použije cestu inline přímo ve volání funkce, pravidlo se nespustí:
// Detected
use std::fs;
fn do_stuff() {
let contents = fs::read_to_string("").unwrap();
}
// NOT detected
fn do_stuff() {
let contents = std::fs::read_to_string("").unwrap();
}
Identifikace modulů
Kontrolované moduly jsou detekovány pomocí regulárních výrazů. Na první pohled přímočaré, v praxi se ale práce s nimi ukázala jako nepříjemná. Malá chyba ve vzoru (^.*::ui::.*$ vs .*::ui.*) způsobí, že pravidlo buď nechytí nic, nebo naopak přebíjí příliš mnoho. Výsledkem je false positive nebo false negative. Ladění je zdlouhavé – každou změnu patternu je nutné ověřit spuštěním testů.
Co když testy začnou padat?
Architektura se při vývoji řeší zejména proto, aby systém vykazoval očekávané kvalitativní charakteristiky – udržitelnost, testovatelnost, rozšiřitelnost. Jenže na rozdíl od funkčních chyb nejsou architektonické odchylky tak snadno vidět. Kumulují se tiše, a než si jich někdo všimne, jsou zabetonované hluboko v kódu.
Unit testy, integrační testy a UI testy jsou kontroly chování – když neprochází, ukazují na chybu. Co ale znamená, když selžou testy architektury? Dle Marka Richardse, architekta a spoluautora knihy Fundamentals of Software Architecture, neznamená selhání testu architektury nutně, že někdo udělal chybu. Může to být právě naopak – vývojář přidal novou komponentu, která tam správně patří, ale existující test o ní nevěděl. Selhání je v takovém případě spouštěč: signál, že se architektura změnila a je čas aktualizovat diagramy i samotný test.
Tento pohled mění to, jak selhání vnímáme. Jde o nejrychlejší možnou zpětnou vazbu, kterou architekt může dostat – upozornění, že realita kódu a záměr architektury se rozešly. Co s tím udělat, je pak věcí diskuze mezi vývojáři a architektem. Buď se ukáže, že nová komponenta je správná a test se upraví tak, aby architektura zůstala v souladu s implementací – nebo se přijde na to, že kód byl napsán nedbale a v budoucnu ho čeká refaktoring, abychom se k původnímu záměru vrátili. Hlavní věc je, že k té diskuzi vůbec dojde – a dojd k ní rychle, ne půl roku po tom, co byl problematický kód napsán.
Relevance v době AI
Kvalita ještě stále nevyšla z módy. Zákazníci po nás stále chtějí kvalitní software. To například znamená, že aplikace poskytuje odevzu v přijatelném časovém limitu, ale také, že přidat jednoduchou feature netrvá měsíc. Právě vhodná architektura a její dodržování zajišťují, že tento předpoklad bude splněn.
O to důležitější je si tuto vlastnost zachovat, když si necháváme kód generovat AI. Navzdory tomu, co jsem psal v předchozím odstavci, se domnívám, že v tomto případě je nutné nastavit testy architektury jako kontrolní bránu pro AI a v žádném případě si do nich nesmíme nechat sahat, pokud to sami neuznáme za vhodné.
AI nám dokáže do detailů popsat Clean architekturu nebo Vertical Slice architekturu, ale stačí se na chvíli podívat pryč od monitoru a BUM, pravidla porušena. Zkrátka je musíme vynucovat.
Závěr
Testy architektury jsou jednoduchý způsob, jak převést nepsaná architektonická pravidla do spustitelného kódu. Přispívají k rychlejšímu onboardingu nových vývojářů, slouží jako živá dokumentace a odhalují porušení architektury dříve, než se dostanou do produkce.
Pro C# projekty je ArchUnitNET zralá knihovna s expresivním API a širokými možnostmi. Pro Rust projekty je cargo-pup životaschopnou alternativou, ale vyžaduje znalost jejích limitací.
Nejlepší strategie je začít jednoduše: přidat testy závislostí mezi vrstvami. Zaberou pět minut a chrání před hlavními porušeními architektury. Zbytek se dá přidávat postupně, jak projekt roste.
Na nových projektech jsou testy architektury snadné k zavedení hned od začátku. Na zaběhlých projektech je potřeba postupovat opatrně – nejprve zmapovat skutečnou architekturu a teprve pak psát testy, které ji vynucují. Zavést testy na projektu, kde architektura nikdy nebyla jasně definovaná, by mohly znamenat měsíce padajících testů. Dokážou vám ale ukázat správný směr, kterým se máte vydat.