Architecture rules
An architecture rule restricts which other types a type may reference: its dependencies.
Type dependencies introduces the dependency filters and expectations (including
dependency cycles), and Layers as type selections shows
how to combine them into a full architecture test suite. The examples use a music store application with the
namespaces MusicStore.Domain, MusicStore.Data, MusicStore.Infrastructure and MusicStore.Presentation.
Type dependencies
The dependency filters and expectations follow the familiar filter/expect pairing:
| Filter | Expect (single) | Expect (many) | |
|---|---|---|---|
| depends on namespace | .WhichDependOn("x", …) | .DependsOn("x", …) | .DependOn("x", …) |
| does not depend on | .WhichDoNotDependOn("x", …) | .DoesNotDependOn("x", …) | .DoNotDependOn("x", …) |
| depends only on set | .WhichDependOnlyOn("x", …) | .DependsOnlyOn("x", …) | .DependOnlyOn("x", …) |
| has dependencies outside set | .WhichHaveDependenciesOutside("x", …) | .HasDependenciesOutside("x", …) | .HaveDependenciesOutside("x", …) |
// Presentation must not reference the data layer
await Expect.That(Types.InNamespace("MusicStore.Presentation"))
.DoNotDependOn("MusicStore.Data");
// The API layer may only reference the application and domain layers
await Expect.That(Types.InNamespace("MusicStore.Api"))
.DependOnlyOn("MusicStore.Application", "MusicStore.Domain");
// Filter for the types that depend on a namespace
In.AllLoadedAssemblies().Types().WhichDependOn("System.Data")
A type depends on every type referenced in its declared signature: the base type and directly
implemented interfaces, generic arguments and parameter constraints, field/property/event types, indexer
parameters, method return/parameter/generic-argument types, constructor parameters and the types of attributes
applied to the type, its members, their parameters and return values (including typeof(…) and enum attribute
arguments). Element types of
arrays/pointers/by-ref and generic type arguments are unwrapped (List<Infra.Foo> depends on List<Infra.Foo>,
which also matches a List<> target, and on Infra.Foo; a closed-generic target like List<Infra.Bar>
only matches that exact construction). Purely synthetic references that you never wrote are ignored:
compiler-generated members, the implicit object/ValueType/Enum base type, interfaces inherited from the
base type, records' synthesized IEquatable<T>, delegates' runtime infrastructure (only the Invoke
signature counts), enums' underlying-value plumbing and the attributes the compiler emits onto authored code
(nullability metadata, required members, async/iterator state machines, …), so the compiler's own plumbing
never counts. Should a future compiler version emit a marker attribute this library does not know about yet,
exclude it yourself via Customize.aweXpect.Reflection().ExcludedAttributeTypes() (full attribute type names;
extends the built-in set). Types you write in authored signatures always do count, including primitives and
void return types (namespace System); in practice, almost every type with members does depend on
System.
Dependencies are computed from reflection metadata, so body-level references such
as new Infra.Foo(), static calls and local variables are not detected. Function-pointer signatures
(delegate*<…>) are not decomposed either; the types inside them are invisible to dependency expectations.
Nested types are separate types with their own dependency surface: verifying typeof(Outer) does not
include what Outer.Inner references. The collection-based expectations (e.g. over Types.InNamespace(…))
enumerate nested types as their own items and therefore cover them. For IL/body-level accuracy, plug in
your own resolver via Customize.aweXpect.Reflection().DependencyResolver() (see
Configuration).
Namespace matching is ordinal and case-sensitive and, like WithinNamespace, includes sub-namespaces by
default (so Foo.Bar matches Foo.Bar.Baz but not Foo.BarBaz). A dependency in the global namespace
can be targeted or allowed with an empty string (""). Each result is chainable:
// Widen the set with .OrOn(…)
await Expect.That(Types.InNamespace("MusicStore.Api"))
.DependOnlyOn("MusicStore.Application").OrOn("MusicStore.Domain");
// Opt out of sub-namespace matching for the whole expression
await Expect.That(types).DoNotDependOn("MusicStore.Data").ExcludingSubNamespaces();
For DependsOnlyOn a type's own namespace is always allowed, and by default so are its sub-namespaces. Use
.ExcludingOwnSubNamespaces() (only available on the only-on and outside families) to also forbid
references into a type's own sub-namespaces:
await Expect.That(Types.InNamespace("MusicStore.Domain"))
.DependOnlyOn("MusicStore.Domain").ExcludingSubNamespaces().ExcludingOwnSubNamespaces();
HasDependenciesOutside is the positive counterpart of DependsOnlyOn for finding the violators of an
allowed set, without a double-negated "does not depend only on". The allowed set follows the same rules
(sub-namespaces included, the own namespace and framework assemblies never count as outside, the same
chainable refinements):
// Select the current violators of an architecture rule (e.g. for a baseline)
In.AllLoadedAssemblies().Types().WhichHaveDependenciesOutside("MusicStore.Application", "MusicStore.Domain")
// Verify that a known legacy type still has its external dependency
await Expect.That(typeof(LegacyImportService))
.HasDependenciesOutside("MusicStore.Application", "MusicStore.Domain");
DependsOn and DoesNotDependOn (single types only) also accept a specific type via <T>() or
(Type), with .OrOn<T>() / .OrOn(Type) to widen:
await Expect.That(typeof(Album)).DoesNotDependOn<DbContext>().OrOn<SqlConnection>();
All dependency families additionally accept a reusable Filtered.Types selection as target; see
Layers as type selections.
DependOnlyOn ignores dependencies whose assembly name matches one of the
ExcludedAssemblyPrefixes at a name-segment boundary: System
covers System and System.Text.Json, but not an assembly named SystemsBiology (so you never have to
whitelist System.* and unrelated assemblies are never swallowed by a prefix), while a
type's own namespace is always allowed. DependsOn / DoesNotDependOn / WhichDependOn still match a
framework namespace when you name it explicitly (e.g. DoesNotDependOn("System.Data")).
The default prefixes include Microsoft, so DependOnlyOn also ignores dependencies on e.g.
Microsoft.EntityFrameworkCore, Microsoft.AspNetCore and Microsoft.Extensions.*; a domain entity
inheriting DbContext does not fail DependOnlyOn("MusicStore.Domain"). To forbid such dependencies, name
them explicitly (DoesNotDependOn<DbContext>() or DoNotDependOn("Microsoft.EntityFrameworkCore")) or
customize the ExcludedAssemblyPrefixes. Note that the
customization also affects assembly scanning and assembly-level dependency expectations.
Dependency cycles
The "slices should be free of cycles" architecture rule: verify that the namespaces of a set of types do not (transitively) depend on each other.
// No dependency cycles among the namespaces under MusicStore
await Expect.That(Types.InNamespace("MusicStore"))
.HaveNoDependencyCycles();
A namespace A depends on a namespace B when some type in A references a type in B (in its
signature, read through the same resolver as the other dependency expectations). The
namespaces of the analyzed types form the nodes of a directed graph, and each
strongly-connected component with more than one
node is reported as a cycle, e.g. MusicStore.Billing -> MusicStore.Catalog -> MusicStore.Billing. Only
namespaces present in the analyzed set form nodes, so dependencies on framework or otherwise out-of-set namespaces
never create an edge, and a namespace referencing itself is not a cycle.
By default a namespace and its sub-namespaces collapse into a single node (a family), consistent with how the
other dependency expectations treat a type's own sub-namespaces. So a reference between a namespace and its
ancestor/descendant (e.g. MusicStore.Catalog ↔ MusicStore.Catalog.Domain) never creates an edge and cannot by
itself form a cycle. But because the family is one node (not just a suppressed pair of edges), a cycle that leaves
the family and returns through a different member of it (e.g.
MusicStore.Catalog -> MusicStore.Billing -> MusicStore.Catalog.Domain) is still detected. Use
ExcludingSubNamespaces() to treat every namespace as its own node, so that such a parent/child reference
becomes an edge (and can form a cycle):
// Treat every namespace as its own node (MusicStore.Catalog ↔ MusicStore.Catalog.Domain can now form a cycle)
await Expect.That(Types.InNamespace("MusicStore"))
.HaveNoDependencyCycles().ExcludingSubNamespaces();
Pass a slice root to group all namespaces below it into one slice each (by the namespace segment immediately
following the root), so that, for example, MusicStore.Catalog, MusicStore.Catalog.Domain and
MusicStore.Catalog.Api collapse into the single slice MusicStore.Catalog:
// Group MusicStore.Catalog.* / MusicStore.Billing.* / … into one slice each before looking for cycles
await Expect.That(Types.InNamespace("MusicStore"))
.HaveNoDependencyCycles("MusicStore");
Expected that Types.InNamespace("MusicStore")
have no dependency cycles when grouped into slices under "MusicStore",
but it had a dependency cycle
Dependency cycles:
MusicStore.Billing -> MusicStore.Catalog -> MusicStore.Billing
Because the edges come from the same dependency resolution as the other dependency expectations, configuring a custom dependency resolver (e.g. an IL-level one) also sharpens cycle detection: body-level references it surfaces can complete a cycle that the signature-level default cannot see.
Layers as type selections
There is no separate rule engine: a "layer" is just a reusable Filtered.Types selection (with the full
filter vocabulary at your disposal), and an architecture rule is just an expectation on it.
Filtered.Types domainTypes = Types.InNamespace("MusicStore.Domain");
Filtered.Types infrastructureTypes = Types.InNamespace("MusicStore.Infrastructure");
Filtered.Types repositoryTypes = Types.InNamespace("MusicStore.Data").WithName("Repository").AsSuffix();
The dependency expectations and filters accept such a selection as a target, alongside the namespace and
specific-type forms: DependsOn / DoesNotDependOn / DependsOnlyOn / HasDependenciesOutside (and the
plural DependOn / DoNotDependOn / DependOnlyOn / HaveDependenciesOutside and the WhichDependOn /
WhichDoNotDependOn / WhichDependOnlyOn / WhichHaveDependenciesOutside
filters) take one or more Filtered.Types arguments. Each target selection is resolved once per expectation;
a dependency matches when it is a member of the union of the resolved selections. Matching is by type
identity, where a generic type definition in the selection (e.g. a scanned Repository<>) matches any of
its constructions.
Multiple targets and .OrOn(…) mean any of; for the only-on and outside families the union is the
allowed set, while the own-namespace and framework rules apply unchanged, including the
.ExcludingOwnSubNamespaces() opt-out (an empty selection thus allows only the own namespace
and framework dependencies). A selection is an explicit target, so framework types contained in it are
matched normally by DependsOn / DoesNotDependOn.
// Outgoing rule with a selection as target:
await Expect.That(domainTypes).DoNotDependOn(infrastructureTypes);
// Incoming rules are written explicitly from the other side:
await Expect.That(infrastructureTypes).DoNotDependOn(domainTypes);
// Allowed set as union of selections (own namespace + framework stay allowed):
await Expect.That(domainTypes).DependOnlyOn(repositoryTypes).OrOn(infrastructureTypes);
Combine several rules into a single verification with aweXpect's Expect.ThatAll(…) (see
combining expectations): every rule is
evaluated and all failures are reported together. Any expectation works on a selection, not just the
dependency ones, so naming conventions or sealing rules live in the same check:
await Expect.ThatAll(
Expect.That(domainTypes).DoNotDependOn(infrastructureTypes),
Expect.That(domainTypes).DependOnlyOn(repositoryTypes).OrOn(infrastructureTypes),
Expect.That(domainTypes).AreSealed());
A failing rule reports all violations, numbered per expectation. Here PlaylistService references the
infrastructure layer, and Album and Track are not sealed:
Expected all of the following to succeed:
[01] Expected that types within namespace "MusicStore.Domain" in all loaded assemblies all do not depend on types within namespace "MusicStore.Infrastructure" in all loaded assemblies
[02] Expected that types within namespace "MusicStore.Domain" in all loaded assemblies all depend only on types within namespace "MusicStore.Data" with name ending with "Repository" in all loaded assemblies or types within namespace "MusicStore.Infrastructure" in all loaded assemblies
[03] Expected that types within namespace "MusicStore.Domain" in all loaded assemblies are all sealed
but
[01] it contained types with the dependency
[03] it contained non-sealed types
[01] Not matching items:
[
PlaylistService
]
[03] Not matching items:
[
Album,
Track
]
Exemptions to a rule use the Except filter on the subject selection:
await Expect.That(domainTypes.Except<LegacyImportService>()).DoNotDependOn(infrastructureTypes);
await Expect.That(domainTypes.Except(type => type.Name.StartsWith("Generated"))).AreSealed();
A layer spanning several namespaces is built by widening a dependency target with additional selections
(or .OrOn(…)); for a subject spanning several namespaces, verify each namespace selection as its own
rule inside the same Expect.ThatAll(…).