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, which means they are not rules. They are wishes with a code-review lottery attached, and they lose that lottery on the busy weeks, which are the weeks that matter.
A fitness function is the fix, and it is less work than the rule was to agree on.
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.
Behavior breaks loudly and immediately.
A single new using in the wrong file breaks nothing today, ships fine, 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().ResideInNamespace("Shop.Application", useRegularExpressions: false)
.Should().NotDependOnAny(
Types().That().ResideInNamespace("Shop.Infrastructure", useRegularExpressions: false));
rule.Check(ArchitectureFixture.Architecture);
}
}
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, which is the entire reason to use a library instead of reflection by hand.
The Rules Worth Writing First
Four shapes cover most of what teams actually 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().ResideInNamespace("Shop.Domain", useRegularExpressions: false)
.Should().NotDependOnAny(
Types().That().ResideInNamespace("Microsoft.EntityFrameworkCore", useRegularExpressions: false));
This one earns its keep faster than the layering rules, because an EF Core attribute on a domain entity looks harmless in a diff.
Conventions. The things you keep retyping in review comments:
IArchRule handlerConventions = Classes()
.That().ImplementInterface("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 the rot that turns a vertical slice architecture into 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 genuine algorithm with genuine 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 right inside a test: it throws, the runner prints the message, done.
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. They are worth knowing before you start trusting the suite.
An empty rule set always passes.
Classes().That().ImplementInterface("IRequestHandler") matching zero types means "every one of the zero handlers is sealed", which is true.
Rename the interface, and the convention rule goes green forever without a single handler being checked.
Guard the rules that matter:
[Fact]
public void Handler_Convention_Rule_Has_Something_To_Check()
{
IEnumerable<Class> handlers = Classes()
.That().ImplementInterface("IRequestHandler")
.GetObjects(ArchitectureFixture.Architecture);
Assert.NotEmpty(handlers);
}
Namespace strings do not get refactored.
Rename Shop.Persistence to Shop.Infrastructure.Persistence and every rule written against the old string silently matches nothing.
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 faithfully enshrine 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. A fitness function that has never failed has never told you anything.
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"
No database, no HTTP, no containers. The whole suite 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.
The change this makes to a team is smaller than it sounds and matters more than it sounds: an architecture violation stops being a review comment somebody has to write, notice, and win an argument about, and becomes a red build with the offending type name in it.
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.
There is a point where that breaks down: 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 ultimately 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.
Data for the common shapes, the full vocabulary for the exotic ones.
That is not a trade most teams need to make. Do not build it until the rules have actually multiplied.
The Property That Makes This Safe
One implementation detail is worth knowing because it changes what you can point ArchUnitNET at: it never executes the assembly.
ArchLoader reads assemblies with Mono.Cecil, a static IL and metadata parser.
The assembly is never Assembly.Loaded into the runtime, so no module initializer runs, no static constructor fires, and nothing the code wants to do at load time happens.
For most teams that is a pleasant footnote: your fitness functions cannot be slowed down or broken by application startup code.
It stops being a footnote when the code is not yours. 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.
Static structure checking and behavioral testing being genuinely independent is not a grading trick. It is why 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 is the useful one, because it tells you the gap between the architecture you describe and the one you have.
Architecture that is not executable erodes at exactly the speed your team ships. 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 loads assemblies with Mono.Cecil, which parses IL and metadata statically. The assembly is never loaded into the runtime, so no module initializer runs and no static constructor fires. That makes rule evaluation fast, side-effect free, and safe to run over assemblies you do not control.
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.



