Skip to main content

Feature Comparison with NetArchTest and ArchUnitNET

NetArchTest and ArchUnitNET are the two established open-source libraries for architecture tests in .NET. aweXpect.Reflection covers the same ground (dependency rules, naming conventions, cycle detection) as part of a general reflection assertion library. This page maps the three approaches against each other.

The same rule in all three libraries

"The presentation layer must not reference the data layer":

await Expect.That(Types.InNamespace("MyApp.Presentation"))
.DoNotDependOn("MyApp.Data")
.Because("the presentation layer is decoupled from persistence");

No separate loader or result handling: the selection scans the loaded assemblies, and a violation fails the test with a message listing the offending types.

Conceptual model

  • NetArchTest (NetArchTest.Rules) is a deliberately small, type-level rule engine: Types.InAssembly(…).That() predicates → .Should()/.ShouldNot() conditions → .GetResult(). It reads assemblies with Mono.Cecil and evaluates everything against TypeDefinitions. There are no member-level rules and no assertion integration: you check IsSuccessful yourself.
  • ArchUnitNET (TngTech.ArchUnitNET) is the .NET port of Java's ArchUnit. It loads assemblies into its own Architecture model (also via Mono.Cecil), on which rules from ArchRuleDefinition (Types(), Classes(), MethodMembers(), …) are checked through per-framework integration packages (xUnit, NUnit, MSTest, TUnit). It is the most feature-rich of the three for pure architecture testing: slices, cycle rules and PlantUML import/export.
  • aweXpect.Reflection works directly on System.Reflection objects inside the aweXpect assertion model: a "layer" is a reusable Filtered.Types selection, an architecture rule is an ordinary await Expect.That(…) expectation, and the same vocabulary extends to methods, properties, fields, events, constructors and assemblies for convention tests.

Coverage overview

Legend: ✅ built-in · ⚠️ partial or via a general mechanism · ❌ not available.

CapabilityNetArchTestArchUnitNETaweXpect.Reflection
Rule subjectstypes onlytypes + membersassemblies, types + all member kinds
Selection by assembly / namespace
Naming rules (prefix/suffix/regex)✅ (plus wildcard, custom comparer)
Type kinds⚠️ classes, interfaces✅ classes, interfaces, enums, structs, records✅ plus delegates, exceptions, attributes, ref structs, record structs
Access modifiers⚠️ public, nested
Attribute rules✅ presence✅ incl. argument values✅ incl. typed predicate on the attribute instance
Member-level rules (methods, properties, fields)✅ plus events, constructors, parameter modifiers, async, extension methods, operators, required / nullable members
Immutability / nullability rules⚠️ immutability✅ type- and member-level
Dependency rules (on / not on / only on)
Body-level (IL) dependency detection⚠️ signature-level default, IL via custom resolver
Dependency cycle detection✅ via slices✅ via namespaces / slice roots
Failure messages explain the violation❌ failing types only✅ all violations, grouped per rule
Combine rules in one verification✅ policiesAnd/OrExpect.ThatAll
Exemptions to a rule⚠️ custom rule⚠️ predicatesExcept(…) / Except<T>()
Test-framework integration❌ assert manually✅ xUnit / NUnit / MSTest / TUnit packages✅ framework-agnostic (aweXpect core)
PlantUML diagrams (import/export)

Dependency detection depth

The most important technical difference is where dependencies are read from:

Signature-level vs. IL-level

NetArchTest and ArchUnitNET parse assemblies with Mono.Cecil and walk method bodies: an instantiation (new Foo()), a static call or a cast inside a method counts as a dependency. aweXpect.Reflection's default resolver works on reflection metadata and sees only the declared signatures (base type, interfaces, member/parameter/return types, attributes; see type dependencies). Body-only references are invisible to it unless you plug in an IL-level custom dependency resolver, e.g. backed by Mono.Cecil.

In return, the signature-level default needs no assembly file on disk, never diverges from the runtime types, and filters out compiler plumbing (state machines, synthesized records members, nullability attributes, …) so rules only see code you actually wrote.

Cycle detection

// Namespaces under MyApp must be free of (transitive) cycles,
// grouped into one slice per direct sub-namespace of MyApp
await Expect.That(Types.InNamespace("MyApp"))
.HaveNoDependencyCycles("MyApp");

Cycles are reported as namespace chains (e.g. MyApp.Orders -> MyApp.Billing -> MyApp.Orders); see dependency cycles for the namespace-family and slice-root semantics.

Failure messages

What failure of the rule from the top of this page looks like in each library, when OrderService in the presentation layer references OrderRepository in the data layer:

Expected that types within namespace "MyApp.Presentation" in all loaded assemblies
all do not depend on namespace "MyApp.Data", because the presentation layer is decoupled from persistence,
but it contained types with the dependency [
OrderService
]

The selection and the rule describe themselves in the message, including the Because(…) reason, followed by the violating types.

Several aweXpect rules combine into one verification with Expect.ThatAll(…), which numbers each rule and reports all failures together; see layers as type selections for a full example with output.

Beyond architecture rules

NetArchTest stops at types, and ArchUnitNET's member rules cover names, visibility and a few modifiers. Convention tests over member details are where aweXpect.Reflection's filter and assertion vocabulary goes further; the same fluent style covers e.g.:

// All async methods end in "Async"
await Expect.That(In.AssemblyContaining<MyClass>()
.Methods().WhichAreAsync())
.HaveName("Async").AsSuffix();

// Every public method with an [HttpGet] or [HttpPost] attribute accepts a CancellationToken
await Expect.That(In.AssemblyContaining<MyController>()
.Public.Methods().With<HttpGetAttribute>().OrWith<HttpPostAttribute>())
.HaveParameter<CancellationToken>();

// Serializable types have exactly one parameterless constructor
await Expect.That(In.AllLoadedAssemblies()
.Types().With<SerializableAttribute>())
.ContainConstructors(c => c.WithoutParameters()).Exactly(1.Times());