An architecture fitness function is an automated, executable check of a structural property of your system: dependency direction, naming conventions, cycle freedom. Where a unit test asserts behavior, a fitness function asserts shape, and running one in CI turns an architecture rule from documentation into a build failure that names the offending type. Here's how to write them in .NET with ArchUnitNET.
Every team has architecture rules.
"Application must not reference Infrastructure."
"Handlers are sealed and end in Handler."
"Modules talk through contracts, never through each other's internals."
Almost no team has them enforced. Each rule depends on a reviewer spotting the violation, and in busy weeks violations get through.
A fitness function is the fix.
What Is an Architecture Fitness Function?
The term comes from evolutionary architecture: an automated check of a structural property of your system, run like any other test.
A unit test asserts behavior. Given this input, the method returns that output.
A fitness function asserts shape. Nothing in the application layer references the infrastructure layer. No two feature folders depend on each other in a loop. Only the persistence assembly knows that EF Core exists.
That distinction matters because shape is what erodes.
Broken behavior shows up right away.
A single new using in the wrong file breaks nothing today and shows up two years later as the reason nobody can extract that module.
I have written before about enforcing architecture with tests, and the general case for shifting that feedback left holds regardless of tool. This article is about the heavier of the two .NET libraries, ArchUnitNET, a port of Java's ArchUnit, and what it can express that lighter tools cannot.
Setting It Up
Two packages: the core library and the adapter for your test framework.
dotnet add package TngTech.ArchUnitNET
dotnet add package TngTech.ArchUnitNET.xUnitV3
Loading the architecture is the expensive step, because ArchUnitNET parses the IL of every assembly you hand it. Do it once for the whole test run:
using ArchUnitNET.Domain;
using ArchUnitNET.Loader;
public static class ArchitectureFixture
{
// Loaded once, shared by every rule. Parsing the assemblies is the slow part;
// evaluating a rule against an already-built Architecture is in-memory work.
public static readonly Architecture Architecture = new ArchLoader()
.LoadAssemblies(
typeof(Shop.Domain.AssemblyReference).Assembly,
typeof(Shop.Application.AssemblyReference).Assembly,
typeof(Shop.Infrastructure.AssemblyReference).Assembly,
typeof(Shop.Api.AssemblyReference).Assembly)
.Build();
}
The AssemblyReference marker is just an empty public class in each project's root, so the test project can name an assembly without hardcoding its string name.
Now the first rule:
using ArchUnitNET.Fluent;
using ArchUnitNET.xUnitV3;
using static ArchUnitNET.Fluent.ArchRuleDefinition;
public class LayeringTests
{
[Fact]
public void Application_Should_Not_Depend_On_Infrastructure()
{
IArchRule rule = Types()
.That().ResideInNamespaceMatching(@"^Shop\.Application(\.|$)")
.Should().NotDependOnAny(
Types().That().ResideInNamespaceMatching(@"^Shop\.Infrastructure(\.|$)"));
rule.Check(ArchitectureFixture.Architecture);
}
}
ResideInNamespace matches one namespace exactly, so it would skip Shop.Application.Orders.
ResideInNamespaceMatching takes a regular expression, and the (\.|$) suffix extends the match to every child namespace.
Check comes from the test-framework adapter package.
It throws when the rule fails, and the message names every offending type and the dependency that violated the rule.
The Rules Worth Writing First
Four shapes cover most of what teams argue about in reviews.
Dependency direction. The rule above, once per boundary you care about. In Clean Architecture that is domain depending on nothing, application depending on domain only. In a modular monolith it is one rule per module pair, allowing the contracts namespace and forbidding everything else.
Library containment. Keep infrastructure concerns from leaking inward:
IArchRule domainIsPersistenceIgnorant = Types()
.That().ResideInNamespaceMatching(@"^Shop\.Domain(\.|$)")
.Should().NotDependOnAny(
Types(includeReferenced: true)
.That().ResideInNamespaceMatching(@"^Microsoft\.EntityFrameworkCore(\.|$)"));
This one earns its keep faster than the layering rules, because an EF Core attribute on a domain entity looks harmless in a diff.
includeReferenced: true matters here.
EF Core is not one of the loaded assemblies, so without it the target set is empty and the rule always passes.
Conventions. The things you keep retyping in review comments:
IArchRule handlerConventions = Classes()
.That().ImplementInterface(typeof(IRequestHandler<,>))
.Should().BeSealed()
.AndShould().HaveNameEndingWith("Handler");
Cycle freedom. Covered next, because it is the one you should not try to write yourself.
I listed my own starting set in 5 architecture tests every .NET project should have.
Cycle Freedom Is the Rule You Cannot Hand-Roll
ArchUnitNET can slice a codebase by a namespace pattern and assert that the slices form no dependency cycles:
using static ArchUnitNET.Fluent.Slices.SliceRuleDefinition;
IArchRule noSliceCycles = Slices()
.Matching("Shop.Features.(*)")
.Should().BeFreeOfCycles();
Matching captures one slice per distinct value of (*), so Shop.Features.Orders, Shop.Features.Billing, and Shop.Features.Shipping each become a node, and the rule fails if the dependency graph between them contains a loop.
This is how a vertical slice architecture degrades into a big ball of mud, and it is close to invisible in review. Orders reaches into Billing for a tax calculation. Months later Billing reaches into Orders for a customer lookup. Neither pull request looked wrong on its own, and now the two features cannot be understood, tested, or extracted separately.
Detecting that means building a dependency graph and running cycle detection over it. You can write that, but it is a graph algorithm with its own edge cases, and it is the main reason I reach for ArchUnitNET over lighter alternatives like NetArchTest, which has no equivalent.
When You Need the Verdict, Not the Exception
Check is the right call inside a test: it throws, and the runner prints the message.
When the result feeds something other than a test runner (a report, a dashboard, a per-rule verdict on a build page), evaluate the rule and read the results yourself:
foreach (EvaluationResult result in noSliceCycles.Evaluate(ArchitectureFixture.Architecture))
{
if (!result.Passed)
{
Console.WriteLine(result.Description); // names the object and why it failed
}
}
Each EvaluationResult carries Passed, a Description, and the object that was evaluated, so you can group violations by rule, count them over time, or render them next to the code that caused them.
Three Ways a Fitness Function Lies to You
All three produce a green build for a codebase that is violating the rule. Check for all three before you start trusting the suite.
An empty target set always passes.
Since version 0.11, ArchUnitNET fails a rule whose That() clause matches zero types, so the handler convention rule goes red if the handlers move to an assembly the fixture does not load.
The types you pass to NotDependOnAny(...) get no such check.
If Shop.Infrastructure matches zero types, every application type depends on none of them, and the layering rule stays green without checking anything.
Guard the rules that matter:
[Fact]
public void Layering_Rule_Has_Something_To_Check()
{
IEnumerable<IType> infrastructureTypes = Types()
.That().ResideInNamespaceMatching(@"^Shop\.Infrastructure(\.|$)")
.GetObjects(ArchitectureFixture.Architecture);
Assert.NotEmpty(infrastructureTypes);
}
Namespace strings do not get refactored.
Rename Shop.Persistence to Shop.Infrastructure.Persistence and every rule that uses the old string as a dependency target matches nothing and still passes.
Prefer assembly-based selection where you can, and when you do use namespaces, keep them in one const per layer so a rename is one edit instead of a search.
A rule can encode an accident. Write the rules by reading the current code and you will lock in whatever shortcut is in there today. Write them from the decision you actually made, watch them fail, then fix the code or consciously change the decision.
Running Them in CI
They are ordinary tests, so there is nothing special to wire up:
- name: Architecture tests
run: dotnet test --filter "FullyQualifiedName~ArchitectureTests"
The suite needs no database, HTTP calls, or containers. It is one assembly parse plus in-memory graph work, so it belongs in the fast job that runs on every pull request, next to your unit tests.
When Rules Outgrow Code
Rules in code are the right default. They live next to the tests, they refactor with the solution, and the full fluent vocabulary is available.
That stops working when the same handful of rule shapes repeat across many targets, when people who do not build the solution need to author rules, or when the rules are content rather than configuration. Then it is worth declaring rules as data and mapping them onto the fluent API:
[
{ "id": "notifier-abstraction", "kind": "interface-must-exist",
"interface": "INotifier" },
{ "id": "service-not-concrete", "kind": "type-must-not-reference",
"type": "OrderService", "target": "SmtpNotifier" },
{ "id": "app-not-infra", "kind": "layer-must-not-depend-on",
"from": "Shop.Application", "to": "Shop.Infrastructure" }
]
public static ArchitectureRule Create(RuleDefinition definition) => definition.Kind switch
{
"interface-must-exist" => ArchRules.InterfaceMustExist(definition.Id, definition.Interface!),
"type-must-not-reference" => ArchRules.TypeMustNotReference(definition.Id, definition.Type!, definition.Target!),
"layer-must-not-depend-on" => ArchRules.LayerMustNotDependOn(definition.Id, definition.From!, definition.To!),
_ => throw new InvalidOperationException($"Unknown rule kind '{definition.Kind}'.")
};
Because an ArchitectureRule is just an id, a description, and an IArchRule, anything the data format cannot express drops down to a raw fluent rule in code, like the slice-cycle check above.
That is not a trade most teams need to make. Do not build it until the rules have multiplied.
The Property That Makes This Safe
ArchUnitNET never executes the assembly, and that changes what you can point it at.
ArchLoader reads assemblies with Mono.Cecil, a static IL and metadata parser.
ArchUnitNET itself never Assembly.Loads what it analyzes, so the analysis runs no module initializer, static constructor, or other load-time code.
For most teams this means your fitness functions cannot be slowed down or broken by application startup code.
When the code is not yours, static loading is what makes the analysis safe to run.
Load untrusted assemblies from disk with LoadFilteredDirectory, because the typeof(...).Assembly calls in the fixture above load each assembly into the test process and run its module initializers.
Katabench, my coding platform, has an architecture track where you refactor a small codebase (extract an abstraction, invert a dependency, break a cycle) and the platform grades the result. Grading a refactoring means answering "is the structure right now?" mechanically, for code a stranger uploaded a second ago. The submission is compiled with Roslyn, handed to Cecil, and evaluated as a set of fitness functions, all without a single line of it running. Behavior gets judged separately, inside a sandbox, and both gates have to pass.
Because static structure checking and behavioral testing are independent, the shape check costs milliseconds and can run on every save while the slow gate runs later.
Key Takeaways
Start with three rules. You do not need a rule catalog to get value out of this.
- Add one test project and load the architecture once in a static field.
- Write the three rules you already state in onboarding: your most important dependency direction, the naming convention you keep repeating in reviews, and a slice-cycle rule over your feature namespaces.
- Run them in the fast CI job.
Then let them fail. The first failure shows you the gap between the architecture you describe and the one you have.
If you want to see rules graded against a codebase in real time, the architecture track on Katabench does it every time you hit Run.
Frequently Asked Questions
What is an architecture fitness function?
An automated, executable check of a structural property of a system: dependency direction, layering, naming conventions, cycle freedom, library usage. The term comes from evolutionary architecture. Where a unit test asserts behavior, a fitness function asserts shape, and running one in CI turns an architectural decision from documentation into an enforced constraint.
What is the difference between ArchUnitNET and NetArchTest?
Both assert architecture rules over compiled assemblies. NetArchTest is smaller and simpler, with a predicate-based API that covers the common dependency and naming checks. ArchUnitNET is a port of Java ArchUnit with a richer vocabulary: member-level rules, slice-based cycle detection, and per-violation evaluation results. Start with NetArchTest and move when you need something it cannot express.
Can ArchUnitNET analyze an assembly without executing it?
Yes. ArchUnitNET reads assemblies with Mono.Cecil, which parses IL and metadata statically, and never loads them into the runtime itself, so no module initializer runs and no static constructor fires. That makes rule evaluation fast and side-effect free. For assemblies you do not control, load them from disk with LoadFilteredDirectory instead of typeof(...).Assembly, which loads the assembly into your process and runs its module initializers.
Do architecture tests slow down the build?
Barely, as long as you load the architecture once. Parsing the assemblies is the expensive step, so put the Architecture object in a static field shared across every test. After that each rule evaluates in memory with no database and no network, and a full suite finishes in seconds.
Should architecture rules live in code or in configuration?
Code is the right default for a single codebase: the rules sit next to the tests and get refactored along with the solution. Declaring rules as data pays off when the same rule shapes repeat across many targets, when non-developers author them, or when rules ship as content. A small factory that maps declarative entries onto a shared rule vocabulary gives you both.



