Architecture Tests: The Guardrail Your AI Agent Can't Talk Around

Architecture Tests: The Guardrail Your AI Agent Can't Talk Around
Every team with a layered solution has the same diagram. Domain in the middle, application around it, infrastructure outside, API at the edge. Arrows point inwards.
The diagram is in the wiki. The rule is in the onboarding deck. It is repeated in code review, in ADRs, and in the README.
None of that is executable.
So one afternoon a using Microsoft.EntityFrameworkCore; appears at the top of a domain entity. It compiles. Tests are green. The pull request is 400 lines long and the reviewer reads the business logic, because that is where the bugs usually are.
The arrow is now pointing outwards, and nobody will notice until the day someone tries to move that module.
The agent changed the math
This was already a slow leak before. It is a faster one now.
An agent reads your repository, copies the local patterns, and produces plausible code at a speed no reviewer matches. It optimises for one thing: it compiles and the tests pass.
Ask it to add persistence to an entity and it will happily reference your DbContext from the domain project — not out of malice, but because nothing in the repository told it otherwise. A convention written in prose is, to an agent, a suggestion in the context window that competes with everything else in the context window.
The bottleneck was never generation. It is review. And review is exactly where architectural drift hides, because a dependency direction is invisible in a diff: the offending line is a single using, twelve files away from the interesting change.
You cannot review your way out of that. You need the rule to be a test.
| Convention as prose | Convention as a test |
|---|---|
| Lives in a wiki nobody re-reads | Lives next to the code |
| Enforced when a reviewer notices | Enforced on every run |
| Ambiguous at the edges | Names the exact offending type |
| An agent may or may not read it | An agent gets a red build |
| Drifts silently | Fails loudly |
What an architecture test actually is
It is a plain unit test. No new infrastructure, no runner, no pipeline stage.
It loads assemblies, walks their type references, and asserts on the dependency graph:
No type in
Acme.Domainmay depend onAcme.Infrastructure.
NetArchTest.Rules gives you a fluent API over that graph. The whole suite runs in a couple of seconds because it never executes your code — it only reads metadata.
Set up the project
A dedicated xUnit project on net10.0, referencing every project it polices:
dotnet new xunit -n Acme.ArchitectureTests -o tests/Acme.ArchitectureTests
dotnet add tests/Acme.ArchitectureTests/Acme.ArchitectureTests.csproj \
package NetArchTest.Rules
<ItemGroup> <PackageReference Include="NetArchTest.Rules" Version="1.3.2" /> <PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\..\src\Acme.Domain\Acme.Domain.csproj" /> <ProjectReference Include="..\..\src\Acme.Application\Acme.Application.csproj" /> <ProjectReference Include="..\..\src\Acme.Infrastructure\Acme.Infrastructure.csproj" /> <ProjectReference Include="..\..\src\Acme.Api\Acme.Api.csproj" /> </ItemGroup>
Referencing every project is the point: the test project sits outside the architecture and is allowed to see all of it.
Two small helpers carry the whole suite:
using System.Reflection; using NetArchTest.Rules; namespace Acme.ArchitectureTests; internal static class ArchRules { internal static Assembly Load(string assemblyName) => Assembly.LoadFrom( Path.Combine(AppContext.BaseDirectory, $"{assemblyName}.dll")); internal static string Describe( string assemblyName, IEnumerable<string> forbidden, TestResult result) => $"{assemblyName} must not depend on " + $"[{string.Join(", ", forbidden)}] but these types do: " + string.Join(", ", result.FailingTypeNames ?? []); }
Loading by name from AppContext.BaseDirectory instead of typeof(SomeClass).Assembly matters more than it looks: a rule expressed as a string can be generated in a loop. That is what makes one test cover twelve assemblies instead of twelve copy-pasted methods.
Rule 1 — dependencies only point inwards
The Clean Architecture rule, written once, applied to every assembly:
public class LayerTests { private static readonly string[] DomainAssemblies = ["Acme.Domain", "ModuleA.Domain", "ModuleB.Domain", "Shared.Domain"]; private static readonly string[] ApplicationAssemblies = ["Acme.Application", "ModuleA.Application", "ModuleB.Application", "Shared.Application"]; private static readonly string[] InfrastructureAssemblies = ["Acme.Infrastructure", "ModuleA.Infrastructure", "ModuleB.Infrastructure"]; private const string ApiNamespace = "Acme.Api"; // Assembly names double as root namespaces in this solution. public static TheoryData<string, string[]> LayerDependencyRules() { TheoryData<string, string[]> rules = []; foreach (string assembly in DomainAssemblies) { rules.Add(assembly, [.. ApplicationAssemblies, .. InfrastructureAssemblies, ApiNamespace]); } foreach (string assembly in ApplicationAssemblies) { rules.Add(assembly, [.. InfrastructureAssemblies, ApiNamespace]); } foreach (string assembly in InfrastructureAssemblies) { rules.Add(assembly, [ApiNamespace]); } return rules; } [Theory] [MemberData(nameof(LayerDependencyRules))] public void Layer_does_not_depend_on_an_outer_layer( string assemblyName, string[] forbiddenNamespaces) { TestResult result = Types.InAssembly(ArchRules.Load(assemblyName)) .ShouldNot() .HaveDependencyOnAny(forbiddenNamespaces) .GetResult(); Assert.True(result.IsSuccessful, ArchRules.Describe(assemblyName, forbiddenNamespaces, result)); } }
One test method. Eleven test cases. Add a module tomorrow, add one string to one array.
That ratio is what keeps the suite alive. Architecture tests die when adding a project means writing a new test class, because nobody does it.
Rule 2 — keep infrastructure libraries out of the domain
Layer references are the obvious leak. The subtle one is a NuGet package.
A domain project that references EF Core has not broken any project reference rule — Acme.Domain still depends on nothing of yours. It has simply stopped being a domain.
private static readonly string[] PersistenceAndTransportLibraries = [ "Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore", "Dapper", "Npgsql", "MediatR" ]; [Theory] [MemberData(nameof(DomainAssemblyNames))] public void Domain_stays_free_of_persistence_and_transport_libraries(string assemblyName) { TestResult result = Types.InAssembly(ArchRules.Load(assemblyName)) .ShouldNot() .HaveDependencyOnAny(PersistenceAndTransportLibraries) .GetResult(); Assert.True(result.IsSuccessful, ArchRules.Describe(assemblyName, PersistenceAndTransportLibraries, result)); }
This is the rule agents break first, and it is the rule that is hardest to spot in review: [Table("orders")] on an entity looks like domain modelling until you read the using.
Rule 3 — modules stay strangers
In a modular monolith, the layer rule is only half the architecture. The other half is horizontal: ModuleA must not reach into ModuleB. They share Shared.* and nothing else.
Without a test, this rule survives about six months. A direct call is one using away and always faster than the correct route.
public class ModuleTests { private static readonly Dictionary<string, string[]> Modules = new() { ["ModuleA"] = ["ModuleA.Domain", "ModuleA.Application", "ModuleA.Infrastructure"], ["ModuleB"] = ["ModuleB.Domain", "ModuleB.Application", "ModuleB.Infrastructure"], ["ModuleC"] = ["ModuleC.Domain", "ModuleC.Application", "ModuleC.Infrastructure"] }; public static TheoryData<string, string[]> ModuleIsolationRules() { TheoryData<string, string[]> rules = []; foreach ((string module, string[] assemblies) in Modules) { string[] otherModules = [.. Modules.Keys.Where(m => m != module).SelectMany(m => Modules[m])]; foreach (string assembly in assemblies) { rules.Add(assembly, otherModules); } } return rules; } [Theory] [MemberData(nameof(ModuleIsolationRules))] public void Module_does_not_depend_on_another_module( string assemblyName, string[] forbiddenNamespaces) { TestResult result = Types.InAssembly(ArchRules.Load(assemblyName)) .ShouldNot() .HaveDependencyOnAny(forbiddenNamespaces) .GetResult(); Assert.True(result.IsSuccessful, ArchRules.Describe(assemblyName, forbiddenNamespaces, result)); } }
Two exemptions are deliberate, and both belong in a comment on the test class:
- the composition root (
Acme.Api, or whereverProgram.cslives) sees every module by design — that is its job; Shared.*may be referenced by everyone, but must reference no module. That last rule deserves its own test, because aSharedproject that grows a dependency on a business module has quietly become that module.
[Theory] [InlineData("Shared.Domain")] [InlineData("Shared.Application")] public void Shared_does_not_depend_on_a_business_module(string assemblyName) { string[] modules = ["ModuleA.", "ModuleB.", "ModuleC."]; TestResult result = Types.InAssembly(ArchRules.Load(assemblyName)) .ShouldNot() .HaveDependencyOnAny(modules) .GetResult(); Assert.True(result.IsSuccessful, ArchRules.Describe(assemblyName, modules, result)); }
This is the test that keeps a modular monolith extractable. The day you split a module into its own service, the architecture tests have already told you it is possible.
The failure message is the product
Note the Describe helper in every assertion. That is not polish.
Compare what a failing build tells the reader:
Assert.True() Failure
Expected: True
Actual: False
against:
Acme.Domain must not depend on [Acme.Application, Acme.Infrastructure, Acme.Api]
but these types do: Acme.Domain.Orders.Order, Acme.Domain.Orders.OrderRepository
The first one gets a human opening the solution to investigate. It gets an agent guessing — and a guessing agent tends to fix the test rather than the code.
The second one names the file, the rule, and the direction. It is a repair instruction. result.FailingTypeNames is the single most valuable thing NetArchTest returns; spend the three lines it takes to surface it.
Write your assertion messages for the reader who is a machine. They are the only documentation an agent reads at exactly the moment it can act on it.
Close the loop
The test project is the guardrail. It only works if it is on the path.
Run it in CI — no special step, it is already a test:
- run: dotnet test --no-restore
Point the agent at it. One line in .github/copilot-instructions.md or AGENTS.md:
Architectural rules are enforced by `tests/Acme.ArchitectureTests`. Run `dotnet test tests/Acme.ArchitectureTests` before considering a change done. Never add an exemption to those tests to make a build pass — fix the dependency.
The last sentence matters. An agent with a red architecture test and permission to edit tests will take the shortest path to green, and the shortest path is deleting the rule.
Now the loop is closed: the agent writes, the agent runs dotnet test, the rule fails with a message naming the type, the agent fixes the dependency. No human in the cycle, and the architecture still holds at the end of it.
That is the real return. Not catching the violation — catching it without spending a reviewer.
What they do not do
Architecture tests check static references. That is all.
- They see compile-time dependencies, not runtime coupling. Two modules talking through a shared database table, a message topic, or a service locator will pass every rule above.
- They say nothing about correctness or performance.
- They cannot tell a good abstraction from a bad one — only which direction it points.
And one discipline: when a rule fails because the architecture genuinely changed, change the rule. In the same pull request, with the reason in the commit message. A growing list of exemptions is not a maintenance chore, it is the architecture telling you it moved and nobody wrote it down.
Further reading
- NetArchTest.Rules
- Common web application architectures — Microsoft Learn
- Unit testing in .NET — Microsoft Learn
Takeaway: An architectural rule that is not executable is a preference. Write it as a test, make the failure message name the offending type, and it becomes the one convention your agent cannot argue with.
