Software architecture rarely breaks in a single commit.
It usually changes gradually.
A project may start with a clear structure:
Presentation
↓
Application
↓
Domain
↓
Infrastructure
Six months later, developers may discover that:
Domain
↓
Infrastructure
has appeared in several places.
Then another dependency is introduced:
Application
↓
Presentation
The code still compiles.
The tests may still pass.
The application may still work.
But the architecture has changed.
This is architecture drift.
Architecture documentation can describe the intended design, but documentation alone cannot prevent developers from introducing dependencies that violate it.
A more reliable approach is to turn architectural decisions into executable dependency rules and run them automatically in the test pipeline.
For C# applications, ArchUnitNET provides a practical way to inspect compiled .NET code and verify architectural constraints. It can check dependencies between classes, interfaces, members, namespaces, layers, and slices, as well as detect cyclic dependencies.
What Is Architecture Drift?
Architecture drift occurs when the implementation gradually diverges from the intended architecture.
Consider a Clean Architecture-style application:
MyApp
├── Domain
├── Application
├── Infrastructure
└── Web
The intended dependency direction might be:
Web
↓
Application
↓
Domain
Infrastructure
↓
Application
↓
Domain
The domain should remain independent of infrastructure and presentation concerns.
But a developer might write:
using MyApp.Infrastructure.Persistence;
namespace MyApp.Domain.Services;
public class OrderService
{
private readonly SqlOrderRepository _repository;
public OrderService(SqlOrderRepository repository)
{
_repository = repository;
}
}
There is nothing syntactically wrong with this code.
The compiler is satisfied.
The unit tests might pass.
But the dependency direction is now:
Domain
↓
Infrastructure
The architecture has drifted.
Why Compiler Checks Are Not Enough
The compiler verifies language-level correctness.
It does not know that your architecture says:
Domain must not depend on Infrastructure.
Likewise, a normal unit test verifies behavior:
Given input
↓
Execute method
↓
Expected result
An architecture test verifies structure:
Given assembly
↓
Analyze dependencies
↓
Expected architecture
These are different testing dimensions.
| Test Type | Primary Question |
|---|
| Unit test | Does this behavior work? |
| Integration test | Do components work together? |
| API test | Does the contract work? |
| Architecture test | Does the dependency structure remain valid? |
| Static analysis | Does code follow defined rules? |
Architecture tests complement, rather than replace, the other test types.
Architecture as an Executable Specification
A traditional architecture document might say:
Domain should not depend on Infrastructure.
An executable rule converts that statement into something the CI pipeline can evaluate:
Types in Domain
↓
must not depend on
↓
Types in Infrastructure
Now the architecture is no longer only documentation.
It becomes a testable constraint.
This idea is particularly valuable for large codebases where many developers and teams continuously modify the same architecture.
Why Dependency Rules Matter
Dependency direction affects:
Maintainability
Testability
Modularity
Deployment boundaries
Team ownership
Refactoring cost
Coupling
Reusability
For example:
Domain
↓
Infrastructure
can make the domain difficult to reuse outside the current infrastructure implementation.
Similarly:
Application
↓
Web
can make application services dependent on a particular delivery mechanism.
Architecture rules make these boundaries explicit.
Install ArchUnitNET
ArchUnitNET is available as a NuGet package and has integrations for common test frameworks including xUnit, NUnit, and MSTest.
For an xUnit project:
dotnet add package TngTech.ArchUnitNET
dotnet add package TngTech.ArchUnitNET.xUnit
The exact package versions should be selected according to the .NET target framework and package versions used by your solution.
Create an Architecture Test Project
A dedicated project keeps architectural tests separate from production code.
For example:
src/
├── MyApp.Domain
├── MyApp.Application
├── MyApp.Infrastructure
└── MyApp.Web
tests/
├── MyApp.UnitTests
├── MyApp.IntegrationTests
└── MyApp.ArchitectureTests
The architecture test project can reference the assemblies that need to be inspected.
A dedicated project also makes it easier to identify architecture failures in CI.
Load the Application Architecture
ArchUnitNET analyzes compiled assemblies and imports their types into an architecture model.
A simplified setup looks like:
using ArchUnitNET.Domain;
using ArchUnitNET.Loader;
using static ArchUnitNET.Fluent.ArchRuleDefinition;
public static class ArchitectureFixture
{
public static Architecture Architecture { get; } =
new ArchLoader()
.LoadAssemblies(
typeof(MyApp.Domain.Order).Assembly,
typeof(MyApp.Application.OrderService).Assembly,
typeof(MyApp.Infrastructure.OrderRepository).Assembly,
typeof(MyApp.Web.Program).Assembly)
.Build();
}
The exact assembly-loading strategy should match the structure of your solution.
The important idea is that the architecture test operates against compiled code rather than relying only on source-file conventions.
Define Layer Boundaries
Suppose the application contains:
MyApp.Domain
MyApp.Application
MyApp.Infrastructure
MyApp.Web
You can define architectural groups around those namespaces.
For example:
var domain =
Types()
.That()
.ResideInNamespace("MyApp.Domain");
var application =
Types()
.That()
.ResideInNamespace("MyApp.Application");
var infrastructure =
Types()
.That()
.ResideInNamespace("MyApp.Infrastructure");
var web =
Types()
.That()
.ResideInNamespace("MyApp.Web");
These groups can then become the basis for dependency rules.
Prevent Domain-to-Infrastructure Dependencies
One of the most useful rules is:
Domain
↓
must not depend on
↓
Infrastructure
ArchUnitNET supports namespace-based dependency rules. Its documentation includes examples of preventing one namespace from depending on another.
A representative rule is:
[Fact]
public void DomainShouldNotDependOnInfrastructure()
{
var rule =
Types()
.That()
.ResideInNamespace("MyApp.Domain")
.Should()
.NotDependOnAny(
Types()
.That()
.ResideInNamespace("MyApp.Infrastructure"));
rule.Check(ArchitectureFixture.Architecture);
}
Now a dependency such as:
MyApp.Domain
↓
MyApp.Infrastructure
becomes a test failure.
Prevent Domain-to-Web Dependencies
The same principle can protect the domain from presentation concerns:
[Fact]
public void DomainShouldNotDependOnWeb()
{
var rule =
Types()
.That()
.ResideInNamespace("MyApp.Domain")
.Should()
.NotDependOnAny(
Types()
.That()
.ResideInNamespace("MyApp.Web"));
rule.Check(ArchitectureFixture.Architecture);
}
This prevents architectural leakage such as:
Domain
↓
ASP.NET Core
or:
Domain
↓
Controller
Protect the Application Layer
Suppose the desired rule is:
Application
↓
must not depend on
↓
Web
The rule becomes:
[Fact]
public void ApplicationShouldNotDependOnWeb()
{
var rule =
Types()
.That()
.ResideInNamespace("MyApp.Application")
.Should()
.NotDependOnAny(
Types()
.That()
.ResideInNamespace("MyApp.Web"));
rule.Check(ArchitectureFixture.Architecture);
}
This makes the dependency direction explicit.
Test Allowed Dependencies Too
Architecture testing should not only describe what is forbidden.
It can also verify required relationships.
For example:
Web
↓
Application
can be represented as an allowed dependency rule.
The important distinction is:
Forbidden dependency
=
A dependency that must never exist
Allowed dependency
=
A dependency that is part of the intended architecture
Use positive rules selectively. Too many rules can make the test suite difficult to maintain.
Prevent Circular Dependencies
Circular dependencies are particularly dangerous because they make modules increasingly difficult to separate.
For example:
Orders
↓
Customers
↓
Payments
↓
Orders
The cycle may not cause an immediate runtime failure.
But it increases coupling.
ArchUnitNET supports slice-based cycle rules. Its documentation demonstrates using:
Slices()
.Matching(...)
.Should()
.BeFreeOfCycles();
to detect cyclic dependencies.
A representative rule is:
[Fact]
public void FeatureModulesShouldBeFreeOfCycles()
{
var rule =
Slices()
.Matching("MyApp.(*)")
.Should()
.BeFreeOfCycles();
rule.Check(ArchitectureFixture.Architecture);
}
The slicing pattern should be adapted to the actual namespace structure.
Why Cycles Are Architectural Problems
Consider:
Orders
↓
Customers
↓
Orders
Removing the cycle may require:
Shared abstraction
Domain event
Interface
Dependency inversion
Module extraction
The architecture test does not tell you which refactoring is correct.
It tells you that the current dependency graph violates a constraint.
That separation is useful.
The test detects the problem.
The developer decides the architectural solution.
Enforce Naming Conventions
Architecture tests can also verify structural naming conventions.
For example:
Controllers
↓
must end with "Controller"
or:
Repositories
↓
must end with "Repository"
A representative rule:
[Fact]
public void ControllersShouldHaveCorrectNames()
{
var rule =
Classes()
.That()
.ResideInNamespace("MyApp.Web.Controllers")
.Should()
.HaveNameContaining("Controller");
rule.Check(ArchitectureFixture.Architecture);
}
ArchUnitNET supports class and inheritance-related rules in addition to dependency checks.
Prevent Infrastructure Implementations in Domain
Suppose the domain contains:
MyApp.Domain
├── Entities
├── Services
└── Repositories
The repository abstraction can remain in the domain:
public interface IOrderRepository
{
Task<Order?> GetAsync(
Guid id,
CancellationToken cancellationToken);
}
while the implementation belongs to infrastructure:
Domain
IOrderRepository
↑
Infrastructure
SqlOrderRepository
This preserves dependency inversion.
The architecture test should prevent the opposite direction:
Domain
↓
SqlOrderRepository
Enforce Dependency Inversion
An architecture rule can therefore protect the conceptual boundary:
Domain
owns abstraction
Infrastructure
implements abstraction
The compiler alone cannot enforce your intended architectural ownership if both projects are allowed to reference each other.
Project references should also be part of the solution design.
Architecture Tests Do Not Replace Project References
There is an important distinction between:
Project dependency
and:
Type dependency
For example:
MyApp.Domain.csproj
↓
MyApp.Infrastructure.csproj
is already a structural problem if the intended architecture says Domain must remain independent.
Whenever possible, enforce the boundary at multiple levels:
Solution/project references
+
Architecture tests
+
Code review
Defense in depth makes architectural violations harder to introduce.
Use Architecture Tests in CI
An architecture test is most useful when developers cannot simply ignore it.
For example:
dotnet test
should execute:
Unit tests
Integration tests
Architecture tests
A pull request then becomes:
Code change
↓
Build
↓
Tests
↓
Architecture rules
↓
Pass / Fail
If an architecture rule fails, the pull request should normally be blocked according to the team's CI policy.
Example GitHub Actions Workflow
A simplified workflow might be:
name: Build
on:
pull_request:
push:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.x'
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build
The exact .NET version and GitHub Actions versions should match your organization's supported toolchain.
The important part is that architecture tests run as part of the normal test command.
Make Failure Messages Useful
A failed architecture test should tell developers what rule was violated.
For example:
DomainShouldNotDependOnInfrastructure
is better than:
ArchitectureTest42
Add a clear test name:
[Fact]
public void DomainShouldNotDependOnInfrastructure()
{
...
}
The failure should immediately communicate:
Which boundary?
Which direction?
What is forbidden?
ArchUnitNET also supports custom reasons on rules, which can improve the diagnostic message when a rule fails.
Avoid Giant Architecture Tests
Do not create one test containing dozens of unrelated rules:
[Fact]
public void ArchitectureShouldBeCorrect()
{
// 100 rules
}
Instead:
DomainShouldNotDependOnInfrastructure
DomainShouldNotDependOnWeb
ApplicationShouldNotDependOnWeb
FeaturesShouldNotContainCycles
ControllersShouldFollowNamingRules
This makes failures easier to understand.
Avoid Over-Specifying the Architecture
Architecture tests can become counterproductive if every implementation detail becomes a rule.
For example, enforcing:
Every service must have exactly one interface
Every class must have exactly one namespace
Every folder must contain exactly three classes
may create unnecessary friction.
Focus on constraints that protect meaningful architectural decisions.
Good rules include:
Domain cannot depend on infrastructure.
Features cannot depend on each other's internals.
Modules cannot contain cycles.
Application cannot depend on presentation.
These rules protect boundaries rather than coding preferences.
Architecture Rules Should Represent Decisions
Before writing a rule, ask:
What architectural decision are we protecting?
For example:
Decision:
Domain is independent of infrastructure.
Rule:
Domain must not depend on Infrastructure.
Another:
Decision:
Feature modules communicate through public contracts.
Rule:
Feature A must not access Feature B's internal namespace.
This keeps architecture tests tied to actual design intent.
Feature-Based Architecture
Not every application uses classic layers.
A modular monolith might look like:
MyApp
├── Orders
│ ├── Application
│ ├── Domain
│ └── Infrastructure
│
├── Customers
│ ├── Application
│ ├── Domain
│ └── Infrastructure
│
└── Shared
The important rule might be:
Orders
↓
must not directly depend on
↓
Customers.Internal
Instead:
Orders
↓
Customers.Public
This creates module boundaries inside a single deployment.
Public vs Internal APIs
One practical approach is to distinguish:
Orders.Public
Orders.Internal
Then enforce:
Customers
↓
Orders.Public
and prohibit:
Customers
↓
Orders.Internal
This allows teams to evolve internal implementation details without breaking unrelated modules.
Detecting Namespace Violations
ArchUnitNET supports namespace-based dependency rules, making namespace organization useful as an architectural signal.
For example:
[Fact]
public void CustomerModuleShouldNotDependOnOrderInternals()
{
var rule =
Types()
.That()
.ResideInNamespace("MyApp.Customers")
.Should()
.NotDependOnAny(
Types()
.That()
.ResideInNamespace(
"MyApp.Orders.Internal"));
rule.Check(ArchitectureFixture.Architecture);
}
This is especially useful in modular monoliths.
Use PlantUML as an Executable Architecture
Architecture documentation often exists as diagrams.
The problem is that diagrams can become stale.
ArchUnitNET supports deriving dependency rules from PlantUML component diagrams. Its documentation shows that a PlantUML diagram can be applied as an architecture rule, allowing the implementation to be checked against the declared component relationships.
For example:
@startuml
[Domain] <<MyApp.Domain.*>>
[Application] <<MyApp.Application.*>>
[Infrastructure] <<MyApp.Infrastructure.*>>
[Web] <<MyApp.Web.*>>
Web --> Application
Application --> Domain
Infrastructure --> Application
Infrastructure --> Domain
@enduml
The architecture diagram can then become part of the test.
This creates an interesting workflow:
Architecture Decision
↓
PlantUML Diagram
↓
Executable Rule
↓
CI
The diagram is no longer merely a visual artifact.
Be Careful With PlantUML Rules
Architecture-as-code is useful, but the diagram must remain understandable.
A diagram with hundreds of dependencies becomes difficult to review.
Prefer a small number of meaningful architectural components:
Web
Application
Domain
Infrastructure
or:
Orders
Customers
Payments
Identity
Then enforce detailed rules separately when necessary.
Debugging Architecture Test Failures
Suppose this rule fails:
DomainShouldNotDependOnInfrastructure
Do not immediately remove the rule.
First identify the dependency.
Look for:
Domain
↓
Infrastructure type
Common causes include:
Concrete repository
Database context
HTTP client
Configuration API
Logging implementation
Framework-specific class
Then decide whether the dependency is:
Intentional
Accidental
Legacy
Temporary
Architecturally incorrect
The architecture test provides the signal.
The team still needs to make the architectural decision.
A Common False Positive: Debug vs Release
ArchUnitNET analyzes binaries.
Its documentation notes that certain dependencies can be discovered differently between Debug and Release builds because of compiler optimizations. The project specifically recommends being aware that running tests with dotnet test -c Release can produce different dependency results from Debug in some edge cases.
This matters in CI.
If local development runs:
dotnet test -c Debug
while CI runs:
dotnet test -c Release
a developer may see different architecture-test behavior.
Keep the test configuration consistent where practical.
Avoid Testing Only Locally
A developer might run:
dotnet test
and get:
Passed
but the architecture tests might not be included in the solution or CI pipeline.
Verify:
Architecture test project
↓
Solution
↓
CI test command
all connect correctly.
Architecture Tests and Code Review
Architecture tests do not eliminate architecture review.
Instead, they remove repetitive verification from human review.
A reviewer should focus on:
Should this dependency exist?
Why does this module need this capability?
Is this boundary still correct?
The architecture test can automatically verify:
Does Domain depend on Infrastructure?
This allows reviewers to spend more time on architectural decisions rather than manually checking imports.
Architecture Tests and AI-Generated Code
AI coding tools can make architecture drift easier to introduce because generated code may optimize for local correctness without understanding the entire repository's architectural constraints.
For example, an AI assistant may generate:
using MyApp.Infrastructure;
because that is the easiest way to implement a feature.
The code may compile and pass functional tests.
An executable architecture rule can catch the violation immediately:
Generated code
↓
Build
↓
Architecture test
↓
Violation
This makes architecture tests particularly useful in repositories where code is frequently generated or modified with AI-assisted development.
The important principle is:
Do not rely on the coding tool to remember architectural boundaries. Make the boundaries executable.
Architecture Rule Coverage
Do not measure architecture-test success by the number of rules.
Ten meaningful rules can be more valuable than 100 superficial rules.
A useful rule inventory might be:
1. Domain independence
2. Application dependency direction
3. Presentation boundaries
4. Infrastructure boundaries
5. Module boundaries
6. Cycle detection
7. Public API boundaries
8. Naming conventions
Each rule should protect an explicit architectural decision.
Common Mistakes
Treating Architecture Tests as Unit Tests
They test structure, not business behavior.
Creating Rules for Every Coding Preference
Only enforce architecture decisions that matter.
Using Namespace Rules Without a Clear Namespace Strategy
If namespaces are inconsistent, the rules may be difficult to maintain.
Ignoring Project References
Architecture tests should complement compile-time project boundaries.
Running Different Build Configurations Locally and in CI
Binary analysis can produce configuration-sensitive results in some edge cases.
Allowing Exceptions Without Documentation
An exception should explain why the dependency is acceptable.
Making Architecture Tests Too Slow
Load only the assemblies required for the architecture under test.
Treating Every Violation as a Tool Problem
A violation can reveal a genuine architectural defect.
Allowing Architecture Tests to Become Stale
Rules should evolve when the architecture intentionally changes.
Troubleshooting
Architecture Test Does Not Detect a Dependency
First verify that the relevant assemblies were loaded.
Then check whether the dependency actually exists in the compiled assembly.
Because ArchUnitNET analyzes compiled code, compiler behavior and build configuration can affect what dependencies are visible.
Rule Passes Unexpectedly
Check whether the selector actually matches any types.
A rule targeting the wrong namespace can appear successful because the intended classes were never selected.
Use focused test names and verify the namespace patterns.
Debug and Release Produce Different Results
Run the architecture test using the same configuration used by CI.
ArchUnitNET documents known Debug/Release differences caused by compiler optimizations in some dependency-analysis cases.
A Dependency Is Intentional
Do not immediately weaken the architecture.
First determine whether the architecture itself should change.
If the dependency is legitimate, update the rule to represent the new architecture.
The Architecture Test Project Cannot Load an Assembly
Verify:
Project reference
Target framework
Build output
Assembly path
Test configuration
The architecture loader must have access to the assemblies being analyzed.
Best Practices
Define architecture decisions before writing rules.
Turn important dependency constraints into executable tests.
Keep rules focused and readable.
Prefer meaningful boundaries over arbitrary style rules.
Enforce dependency direction explicitly.
Detect cyclic dependencies.
Protect module internals.
Separate public and internal APIs.
Keep project references aligned with architecture rules.
Run architecture tests in CI.
Use consistent Debug/Release behavior.
Keep architecture test names descriptive.
Review exceptions carefully.
Avoid broad namespace patterns when narrow patterns are possible.
Keep the architecture test suite maintainable.
Consider PlantUML for high-level component rules.
Update rules when architectural decisions intentionally change.
Use architecture tests as a complement to code review.
Treat AI-generated code as subject to the same architectural constraints.
Investigate violations instead of simply disabling failing rules.
Example Architecture Test Suite
A practical architecture test project might contain:
ArchitectureTests/
├── DomainRules.cs
├── ApplicationRules.cs
├── InfrastructureRules.cs
├── WebRules.cs
├── ModuleRules.cs
├── CycleRules.cs
└── ArchitectureFixture.cs
For example:
public class DomainRules
{
[Fact]
public void DomainShouldNotDependOnInfrastructure()
{
var rule =
Types()
.That()
.ResideInNamespace("MyApp.Domain")
.Should()
.NotDependOnAny(
Types()
.That()
.ResideInNamespace(
"MyApp.Infrastructure"));
rule.Check(ArchitectureFixture.Architecture);
}
[Fact]
public void DomainShouldNotDependOnWeb()
{
var rule =
Types()
.That()
.ResideInNamespace("MyApp.Domain")
.Should()
.NotDependOnAny(
Types()
.That()
.ResideInNamespace(
"MyApp.Web"));
rule.Check(ArchitectureFixture.Architecture);
}
}
The exact namespace structure should match your application.
The important characteristic is that each test expresses one architectural rule.
Architecture Governance Workflow
A mature workflow can look like this:
Architectural Decision
↓
Document Decision
↓
Define Dependency Rule
↓
Implement Architecture Test
↓
Run Locally
↓
Add to CI
↓
Review Violations
↓
Update Rule Only When Architecture Changes
This creates continuous architecture governance.
Frequently Asked Questions
What is architecture drift?
Architecture drift occurs when the implementation gradually diverges from the intended architecture.
For example, a domain layer that was intended to be independent of infrastructure gradually starts referencing infrastructure types.
Why use architecture tests?
They automatically detect structural violations that normal compilation and functional tests may not detect.
Can architecture tests replace code review?
No.
Architecture tests enforce known rules.
Architectural review is still needed when deciding whether the rules themselves should change.
Can I use ArchUnitNET with xUnit?
Yes.
ArchUnitNET provides an xUnit extension, as well as integrations for NUnit and MSTest.
Can architecture tests detect circular dependencies?
Yes.
ArchUnitNET provides slice-based cycle detection rules through BeFreeOfCycles().
Can architecture tests check class dependencies?
Yes.
ArchUnitNET can inspect dependencies between classes, interfaces, members, and other architectural elements.
Can architecture rules be based on PlantUML?
Yes.
ArchUnitNET supports applying PlantUML component diagrams as architecture rules.
Should every dependency be tested?
No.
Focus on dependencies that represent meaningful architectural constraints.
Do architecture tests affect production performance?
The tests themselves run during development and CI rather than as part of normal application request processing.
Their primary cost is test execution and architecture-analysis time.
Can architecture tests prevent AI-generated code from breaking architecture?
They can automatically detect many structural violations after generated code is introduced.
They cannot determine whether a new architectural decision is conceptually correct.
Human review remains necessary for architectural changes.
What happens when the architecture intentionally changes?
Update the architecture decision and then update the executable rule.
Do not weaken a rule simply because it is inconvenient.
The rule should reflect the current intended architecture.
Conclusion
Architecture drift is difficult to eliminate because the compiler generally does not understand architectural intent.
A compiler can tell you:
This code is valid C#.
It usually cannot tell you:
This dependency violates our architecture.
That is where executable dependency rules become valuable.
Instead of keeping the architecture only in:
Documentation
Diagrams
Developer knowledge
Code review
you can also represent it as:
Automated Tests
For a .NET application, the workflow can be:
Architecture Decision
↓
Dependency Rule
↓
ArchUnitNET Test
↓
dotnet test
↓
CI
↓
Pull Request
A rule such as:
Domain must not depend on Infrastructure
becomes an executable constraint.
A cycle rule becomes:
Modules must be free of cycles
A module boundary becomes:
Feature A cannot access Feature B internals
ArchUnitNET is designed specifically for this style of architecture testing in C#, analyzing compiled assemblies and providing rules for dependencies, namespaces, classes, cycles, and other architectural constraints.
The most important principle is not to create hundreds of architecture tests.
It is to identify the architectural decisions that would be expensive or dangerous to violate and turn those decisions into executable constraints.
The result is a development process where architecture is no longer something developers merely remember.
It becomes something the build system can verify.
If an architectural boundary matters enough to document, consider whether it also matters enough to test.