Verified against .NET 10, xUnit v3, NetArchTest.eNhancedEdition, ArchUnitNET, and current Roslyn analyzer guidance. Last reviewed: July 23, 2026. Package versions remain pinned outside the prose because the testing ecosystem continues to evolve.
NetArchTest.eNhancedEdition: Concise Rules for the Common 80 Percent
NetArchTest.eNhancedEdition is a maintained successor to the familiar NetArchTest-style fluent API. It is well suited to straightforward dependency direction, naming, sealing, immutability, statelessness, and access-modifier rules.
Its strength is readability. A rule can select types from an assembly, describe a condition, and return a result that lists failures. That makes it easy to place architecture checks in the same xUnit project as other tests and fail the CI pipeline with dotnet test.
For a team that wants a small set of clear structural rules without introducing a source-analyzer project, this is often the simplest starting point.
Package warning: Many older articles still install `NetArchTest.Rules`. For new work, verify the current package status and prefer the maintained `NetArchTest.eNhancedEdition` fork or ArchUnitNET.
using NetArchTest.Rules;
public sealed class LayerRules
{
[Fact]
public void Domain_must_not_depend_on_infrastructure()
{
TestResult result = Types
.InAssembly(typeof(DomainAssemblyMarker).Assembly)
.ShouldNot()
.HaveDependencyOn(
"ArchitectureGuard.Infrastructure")
.GetResult();
Assert.True(
result.IsSuccessful,
string.Join(
Environment.NewLine,
result.FailingTypes.Select(
type => type.FullName)));
}
}
ArchUnitNET: A Richer Architecture Model and Familiar ArchUnit Style
ArchUnitNET imports assemblies into an architecture model through ArchLoader, then applies rules created with ArchRuleDefinition. It can express dependencies between classes, members, interfaces, layers, and slices using an API that is familiar to Java teams already using ArchUnit.
The richer model is useful when the solution has complex module rules, repeated slices, or a team that wants to describe architecture in a more explicit vocabulary. Loading the architecture once in a fixture also keeps the suite fast.
The trade-off is a larger conceptual surface than the concise NetArchTest style. Choose it when that model improves clarity for your rules, not simply because it offers more API.
using ArchUnitNET.Domain;
using ArchUnitNET.Fluent;
using static ArchUnitNET.Fluent.ArchRuleDefinition;
public sealed class ArchitectureFixture
{
public Architecture Architecture { get; } =
new ArchLoader()
.LoadAssemblies(
typeof(DomainAssemblyMarker).Assembly,
typeof(InfrastructureAssemblyMarker).Assembly)
.Build();
}
public sealed class ArchUnitLayerRules(
ArchitectureFixture fixture)
{
[Fact]
public void Domain_must_not_depend_on_infrastructure()
{
IArchRule rule = Types()
.That()
.ResideInAssembly(
typeof(DomainAssemblyMarker).Assembly)
.Should()
.NotDependOnAny(
Types().That().ResideInAssembly(
typeof(InfrastructureAssemblyMarker).Assembly))
.Because(
"Domain must remain independent of infrastructure");
rule.Check(fixture.Architecture);
}
}
The Shared Limitation: Both Fluent Libraries See Compiled Code
Both architecture-test libraries work from compiled assemblies or bytecode-level information. That is why they can inspect dependencies without running the application—and why some source constructs are invisible after compilation.
A nameof(SomeType) expression is a common example. The compiler replaces it with a string literal, so the resulting assembly no longer contains a type dependency that an architecture test can detect. Similar problems appear when a rule depends on syntax, comments, source locations, or information erased by the compiler.
Do not assume a green rule means the selector saw what you intended. Prove each important rule can fail by deliberately introducing a violation. If the rule depends on source-level meaning, move it to Roslyn.
Vacuous success is dangerous: A misspelled namespace selector can match zero types and still produce a green test. Add a guard that confirms the selector found the expected population.
Type[] handlers = typeof(ApplicationAssemblyMarker)
.Assembly
.GetTypes()
.Where(type => type.Name.EndsWith(
"Handler",
StringComparison.Ordinal))
.ToArray();
Assert.NotEmpty(handlers);
TestResult result = Types
.InAssembly(typeof(ApplicationAssemblyMarker).Assembly)
.That()
.HaveNameEndingWith("Handler")
.Should()
.BeSealed()
.GetResult();
Assert.True(result.IsSuccessful);
Roslyn Analyzers: Use Them When the Rule Must Fail in the Editor
A Roslyn analyzer inspects C# syntax trees and semantic symbols during development and build. It can underline a violation in the IDE, place a diagnostic in the Error List, and optionally offer a code fix. That is a different developer experience from waiting for the architecture-test project to run.
Use an analyzer when the rule must be enforced on every compile, depends on source syntax, or needs precise symbol information. Examples include banning a particular namespace import, requiring an attribute on a source declaration, or enforcing an API-usage pattern before the project reaches the test stage.
The cost is higher. An analyzer needs its own package, diagnostics, tests, severity configuration, and ongoing compatibility work. Do not build one for a simple layer dependency that a ten-line architecture test already expresses clearly.
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class ForbiddenDependencyAnalyzer
: DiagnosticAnalyzer
{
private static readonly DiagnosticDescriptor Rule = new(
id: "ARCH001",
title: "Forbidden architecture dependency",
messageFormat:
"Domain code must not reference '{0}'",
category: "Architecture",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor>
SupportedDiagnostics => [Rule];
public override void Initialize(
AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(
GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSyntaxNodeAction(
AnalyzeUsing,
SyntaxKind.UsingDirective);
}
private static void AnalyzeUsing(
SyntaxNodeAnalysisContext context)
{
var directive = (UsingDirectiveSyntax)context.Node;
string namespaceName = directive.Name?.ToString() ?? "";
if (namespaceName.StartsWith(
"ArchitectureGuard.Infrastructure",
StringComparison.Ordinal))
{
context.ReportDiagnostic(
Diagnostic.Create(
Rule,
directive.GetLocation(),
namespaceName));
}
}
}
Custom Reflection Tests: The Practical Middle Ground
Some conventions do not need a full analyzer but are awkward in a fluent architecture API. A custom reflection test is often the simplest middle ground. It still runs through xUnit, remains easy to understand, and can inspect runtime type metadata directly.
Good candidates include verifying that every EF Core entity has an IEntityTypeConfiguration<T>, every endpoint class carries a required attribute, or every handler has a public constructor with a specific dependency shape.
Reflection tests are still limited to information available after compilation. Use them for behavioral or metadata conventions, not source syntax.
[Fact]
public void Every_endpoint_requires_authorization()
{
Type[] endpointTypes = typeof(ApiAssemblyMarker)
.Assembly
.GetTypes()
.Where(type =>
type.IsClass &&
type.Name.EndsWith(
"Endpoint",
StringComparison.Ordinal))
.ToArray();
Assert.NotEmpty(endpointTypes);
Type[] missingAuthorization = endpointTypes
.Where(type =>
!type.IsDefined(
typeof(AuthorizeAttribute),
inherit: true))
.ToArray();
Assert.True(
missingAuthorization.Length == 0,
"Endpoints missing [Authorize]:" +
Environment.NewLine +
string.Join(
Environment.NewLine,
missingAuthorization.Select(
type => type.FullName)));
}