Architectural rules that exist only in documentation will sooner or later be broken. It's not a question of if, but when.
Someone moves a domain class into the infrastructure layer because they "needed database access". Someone else names a handler ProcessPaymentService because they didn't know about the established convention. A class that should be internal ends up public because that's what the IDE generated. And nobody catches it in code review.
Architecture tests solve this problem elegantly: architectural rules are written as unit tests and run in CI. Violating a rule = tests fail. The rules are living documentation – not a PDF on Confluence, but code that runs on every commit.
C# and ArchUnitNET
The ArchUnitNET library is a .NET port of the Java ArchUnit. It offers a fluent API for writing architectural rules that then run as standard unit tests.
The basic setup is straightforward – layers are loaded and a shared Architecture instance is created, representing a model of all types and their dependencies:
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();
Dependencies Between Projects
The core rule of Clean Architecture is the direction of dependencies – inner layers must not depend on outer ones. Project references will prevent obvious circular dependencies, but NuGet packages and repository reorganization can break the rules in subtle ways.
A real-world example: a developer adds DbContext to the Domain project to avoid moving logic to the Application layer. The compiler won't stop this if the package is transitively available. A test will.
[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);
}
The fluent API reads like plain English: "types that are in the domain layer should not depend on any type from the data layer". When a test fails, it clearly identifies which type is problematic – and the .Because() parameter ensures the error message includes the reasoning behind the rule, so you don't have to hunt for it in the documentation. This is especially valuable during onboarding of new team members.
Naming Conventions
You might have a rule that a class in the data layer should be named UserEntity, not User, UserModel, or UserRecord. Without enforcement, every developer picks whichever variant feels natural to them, and after six months the project has three different conventions coexisting. ArchUnitNET can enforce this rule for all classes in a given assembly at once.
[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);
}
The same approach works for type visibility – for example, that command handlers are internal because they are implementation details and should not be called directly from other layers. Or that domain classes don't depend on specific frameworks like Entity Framework Core, even when the package is transitively available.
The tests run in hundreds of milliseconds, require no additional infrastructure, and run with every build.
Rust and cargo-pup
For Rust, there is the cargo-pup library from Datadog, which similarly allows writing architectural rules for Rust projects.
A Dioxus application targets multiple platforms – desktop and web. In a browser you cannot read files from the filesystem, so any direct import of std::fs or tokio::fs in a UI module is a potential runtime failure on the web. This rule ensures that UI modules don't import anything platform-specific and file operations remain delegated to a separate module:
#[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.");
}
DTOs Are Private
Data transfer objects are an implementation detail of the HTTP client. If they leaked into other modules, every HTTP API change would cascade through the codebase. cargo-pup can enforce, based on naming, that types with the Dto suffix remain private:
#[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.");
}
Function Length
Long functions are a symptom of too many responsibilities in one place. A maximum length rule acts as a net that catches functions that have grown out of control – not as a substitute for good logic decomposition, but as a safety net for when nobody noticed a function had grown to 500 lines:
#[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.");
}
Another type of rule that cargo-pup supports is banning unsafe functions. A project can deliberately forbid writing them – using libraries that use unsafe internally is fine, but custom unsafe blocks can undermine the guarantees that Rust is built on.
Limitations of cargo-pup
This library is useful, but has significant limitations to be aware of.
Inline Imports Are Not Detected
cargo-pup only checks use statements at the module level. If a developer uses a path inline directly in a function call, the rule won't trigger:
// 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();
}
Module Identification
The modules being checked are identified using regular expressions. Straightforward at first glance, but in practice working with them proved to be frustrating. A small mistake in a pattern (^.*::ui::.*$ vs .*::ui.*) causes the rule to either catch nothing or match too broadly, resulting in false positives or false negatives. Debugging is tedious – every pattern change must be verified by running the tests.
What If the Tests Start Failing?
Architecture is addressed during development primarily so the system exhibits the expected quality characteristics – maintainability, testability, extensibility. But unlike functional bugs, architectural deviations aren't so easily visible. They accumulate silently, and by the time anyone notices, they're cemented deep in the codebase.
Unit tests, integration tests, and UI tests are behavioral checks – when they fail, they point to a bug. But what does it mean when architecture tests fail? According to Mark Richards, architect and co-author of Fundamentals of Software Architecture, an architecture test failure doesn't necessarily mean someone made a mistake. It may be quite the opposite – a developer added a new component that legitimately belongs there, but the existing test didn't know about it. In that case, the failure is a trigger: a signal that the architecture has changed and it's time to update diagrams and the test itself.
This perspective changes how we perceive failures. It's the fastest possible feedback an architect can get – a notification that the reality of the code and the architectural intent have diverged. What to do about it is then a matter for discussion between developers and the architect. Either the new component turns out to be correct and the test is updated so the architecture stays aligned with the implementation – or it becomes clear that the code was written carelessly, requiring future refactoring to return to the original intent. The key thing is that the discussion happens at all – and that it happens quickly, not six months after the problematic code was written.
Relevance in the Age of AI
Quality has not gone out of fashion. Customers still want quality software. That means, for example, that an application responds within an acceptable time limit, but also that adding a simple feature doesn't take a month. It is precisely appropriate architecture and adherence to it that ensures this expectation is met.
This becomes even more important when we let AI generate code for us. Despite what I wrote in the previous paragraph, I believe that in this case it is necessary to set architecture tests as a control gate for AI – and under no circumstances should we let them be modified unless we ourselves deem it appropriate.
AI can describe Clean Architecture or Vertical Slice Architecture in great detail, but the moment you blink BAM – rules violated. Simply put, we need to enforce them.
Conclusion
Architecture tests are a simple way to convert unwritten architectural rules into executable code. They contribute to faster onboarding of new developers, serve as living documentation, and detect architectural violations before they reach production.
For C# projects, ArchUnitNET is a mature library with an expressive API and broad capabilities. For Rust projects, cargo-pup is a viable alternative, but requires awareness of its limitations.
The best strategy is to start simple: add dependency tests between layers. They take five minutes and protect against the most common architectural violations. The rest can be added incrementally as the project grows.
On new projects, architecture tests are easy to introduce from the very beginning. On established projects, you need to proceed carefully – first map the actual architecture, then write tests that enforce it. Introducing tests on a project where the architecture was never clearly defined could mean months of failing tests. They can, however, show you the right direction to head in.