RonSijm.AnaalIJzer 0.3.6

Prefix Reserved
dotnet add package RonSijm.AnaalIJzer --version 0.3.6
                    
NuGet\Install-Package RonSijm.AnaalIJzer -Version 0.3.6
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="RonSijm.AnaalIJzer" Version="0.3.6">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="RonSijm.AnaalIJzer" Version="0.3.6" />
                    
Directory.Packages.props
<PackageReference Include="RonSijm.AnaalIJzer">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add RonSijm.AnaalIJzer --version 0.3.6
                    
#r "nuget: RonSijm.AnaalIJzer, 0.3.6"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package RonSijm.AnaalIJzer@0.3.6
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=RonSijm.AnaalIJzer&version=0.3.6
                    
Install as a Cake Addin
#tool nuget:?package=RonSijm.AnaalIJzer&version=0.3.6
                    
Install as a Cake Tool

IJzer

An Analyzer for N-dimensional Advanced Architectural Layering.

NuGet NuGet Downloads codecov

Introduction

I built Anaal IJzer to turn architecture rules from review comments into compiler diagnostics. You define named layers and explicit allowed dependency edges in an XML file, and the analyzer checks that types only depend on permitted layers. That is mostly it. The rest of the project is what happened after "just check a few layers" acquired tooling, diagrams, fixers, and quite a lot more XML.

How this README is built

I keep the documentation as standalone notes in docs/ and assemble them into this README. That gives me:

  • one place to edit each subject;
  • the same document on GitHub, NuGet, and the Visual Studio landing page;
  • no three-way contest over which almost-identical copy is currently the real one.

The compose order is defined in docs/_readme-order.txt. After changing the individual notes, run docs/build-readme.ps1 to regenerate this readme.

Legend

Standalone note Use it for
docs/introduction.md Project overview, naming, restaurant example domain, and Roslyn background.
docs/setup.md NuGet setup, .anl settings files, inline settings, and shared project configuration.
docs/configuration/ide-code-fixes.md Which diagnostics have IDE fixers, what they edit, and where the analyzer tests live.
docs/components/visual-studio-addon.md Visual Studio companion extension behavior, options, graph editor, and CodeLens UI.
docs/tools/arse.md Arse command/TUI usage, reports, generated config, documentation, and file associations.
docs/tools/anaaltomy.md Anaaltomy compiled-code statistics, SQLite storage, and Git-history trend analysis.
docs/components/wpf-graph-editor.md Standalone WPF graph editor usage and graph image export.
docs/configuration/mental-model.md Beginner-friendly rule precedence and the "four questions" model.
docs/configuration/*.md Detailed settings reference for layers, dependency rules, type policies, exceptions, name rules, reports, and generated documentation.
docs/diagnostics/index.md Diagnostic overview and links to the ARCH_DEP_001 through ARCH_NAME_008 pages.
docs/q-and-a.md Common questions such as framework types, nested boundaries, and same-project interfaces.
docs/suppressing-violations.md Local suppression guidance.
docs/violation-report.md Generated violation report output.
docs/architecture-health.md Architecture health inspection output.
docs/architecture-documentation.md Generated architecture documentation output.
docs/no-config-source.md What happens when no settings source is configured.
docs/getting-started-help.md First-step guidance when starting from an existing codebase.
docs/design-generated-files.md Generated file expectations and maintenance notes.

Naming

"IJzer" is the Dutch word for iron. I - Ron, the creator of this project - have therefore decided to name it "IJzer".


The problem it solves

Meta - The Examples - Why a restaurant?

Before explaining the problem, let me explain how I'm explaining the problems. In a lot of cases I'm using 'A restaurant' as an example.

This is because architecture terms such as Controller, ViewModel, Handler, or Slice come with prior knowledge and expectations about MVC, MVVM, vertical slices, and other specific styles. Using them in the introductory examples could make an incidental name look like a rule or imply that Anaal IJzer prefers one of those architectures.

I use a restaurant as the deliberately opinionated example domain, not as a prescribed software architecture.

  • The roles are familiar without requiring MVC, MVVM, or vertical-slice knowledge.
  • Customer, Waiter, Chef, and Pantry are only layer names.
  • An arrow always means “may depend on.” It does not describe runtime request or data flow.
  • Your own configuration can use whichever layers and architectural style fit your application.

Imagine a restaurant with four roles:

  • A Customer may ask a Waiter for service, but should not direct a Chef or enter the Pantry
  • A Waiter may ask a Chef to prepare an order
  • A Chef may use the Pantry
  • Peers in the same role should not command each other unless that role explicitly allows it

Without tooling, these rules live only in code-review comments and tribal knowledge. Tribal knowledge has a habit of accepting an offer elsewhere and leaving with all of the reasoning and none of the documentation. This analyzer turns the rules into compile errors.

Architecture test projects can verify some of these concerns after a test run. Anaal IJzer reports configured violations in the editor and during compilation. Architecture tests remain useful for checks over built assemblies and external binaries.


How it works

You define named layers and the edges between them in an XML file. The analyzer then checks the places where a class, record, struct, or interface can introduce another type:

  • Declarations
    • inheritance, interface implementation, and attributes;
  • Signatures
    • constructors, method parameters, and method returns;
  • Stored or temporary values
    • fields, properties, and local variables;
  • Operations
    • object creation, static member access, generic arguments, and generic service-locator calls.

When layer A introduces a dependency that its rules do not permit, the error appears on that syntax. You do not have to reconstruct it from a failed architecture test in another project.

Customer ──► Waiter    ✅ allowed
Waiter ──► Chef        ✅ allowed
Chef ──► Pantry        ✅ allowed

Customer ──► Chef      ❌ ARCH_DEP_001 - no AllowedDependency edge configured
Pantry ──► Chef        ❌ ARCH_DEP_004 - wrong direction (reverse of the allowed edge)
Chef ──► Chef          ❌ ARCH_DEP_005 - same layer

Where it hooks into Roslyn

Roslyn is the .NET compiler platform behind C# and Visual Basic. Instead of exposing only a command that turns source files into assemblies, Roslyn exposes the compiler pipeline as APIs: syntax trees represent parsed source, semantic models bind syntax to symbols and types, and a Compilation is an immutable snapshot of the complete program being compiled.

Anaal IJzer is a C# DiagnosticAnalyzer. It runs inside that compiler pipeline in Visual Studio, Rider, dotnet build, and CI; it is not a post-build reflection scan and does not execute application code.

flowchart LR
    Source["C# source"] --> Compilation["Roslyn Compilation"]
    Settings["AdditionalFiles or AssemblyMetadata"] --> Config["Architecture configuration"]
    Compilation --> Start["CompilationStartAction"]
    Start --> Syntax["Targeted SyntaxNodeAction callbacks"]
    Syntax --> Semantics["SemanticModel and ITypeSymbol resolution"]
    Config --> Rules["Layer and dependency graph"]
    Semantics --> Rules
    Rules --> Diagnostics["ARCH_* diagnostics at source locations"]

The integration points are:

  1. ArchitecturalLevelAnalyzer is marked with [DiagnosticAnalyzer(LanguageNames.CSharp)], which makes it discoverable as a C# analyzer.
  2. For each compilation snapshot, its CompilationStartAction reads Architecture.anl from Roslyn's AdditionalFiles, or reads inline AssemblyMetadata("AnaalIJzerSettings", ...). The parsed configuration is then reused by every callback registered for that compilation.
  3. It registers SyntaxNodeAction callbacks only for syntax that can introduce an architectural dependency: type and constructor declarations, methods, fields, properties, locals, object creation, invocations, attributes, inheritance, and static member access. Generated code is ignored, and callbacks may run concurrently.
  4. LayerDependencyAnalyzer uses the callback's SemanticModel to resolve syntax to real Roslyn symbols such as ITypeSymbol. This is why aliases, inferred local types, generic type arguments, implemented interfaces, and referenced types can be evaluated by their actual type identity instead of by source text alone.
  5. The resolved caller and dependency symbols are matched to configured layer paths. The dependency graph evaluates the relevant boundary gates, blocked rules, site filters, recognized-dependency requirements, and forbidden patterns. A failure is returned to Roslyn with ReportDiagnostic, including the source location and diagnostic properties such as Site.
  6. Configuration failures and configured cycles are reported at the end of the compilation as ARCH_CONF_003 or ARCH_CONF_006. If there is no configuration source, no dependency callbacks are registered and the analyzer remains silent.

Because the same analyzer participates in design-time and command-line compilations, the red squiggle in the editor and the error in CI come from the same rule evaluation.


Why compiler-level enforcement matters

Anaal IJzer is a compile-time architecture and structural-policy guard for .NET. It overlaps with test-runner architecture checks such as NetArchTest and ArchUnitNET, static-analysis platforms such as NDepend, and the old Visual Studio layer-diagram validation. Its compiler integration also supports policies that ordinary runtime tests do not inspect.

Architecture tests are useful, but solve a different problem

A common approach is to write a dedicated test project using a library such as NetArchTest or ArchUnitNET:

// In a test project — ArchitectureTests.cs
[Fact]
public void Presentation_Should_Not_Depend_On_Persistence()
{
    var result = Types.InAssembly(typeof(OrderEndpoint).Assembly)
        .That().ResideInNamespace("MyApp.Presentation")
        .ShouldNot().HaveDependencyOn("MyApp.Persistence")
        .GetResult();

    Assert.True(result.IsSuccessful);
}

That is valuable for broad assertions about an assembly or a set of published types. It is not equivalent to compiler-level enforcement:

  1. Feedback and location are different. A test reports from the test project when the test suite runs. Anaal IJzer reports on the exact source construct during design-time analysis and compilation, so the editor squiggle and CI error point to the same dependency, return expression, or declaration.

  2. Behavioural tests only see executed paths. A return null, a sentinel return value, or a throw deep in a branch can remain invisible until a test happens to execute that path. Static type-level architecture tests can assert a relationship between types, but they do not automatically inspect every method body and every relevant syntax site.

  3. Complete source inspection needs a compiler host. A test suite could add custom Roslyn or IL inspection for every return, invocation, generic argument, inheritance site, or declaration it cares about. That is effectively building a compiler inspection in a test runner. Anaal IJzer is already hosted at that point: Roslyn visits every configured matching site in the compilation, including code that no test executes.

  4. Policy is distinct from behaviour. A failing behaviour test says a scenario no longer works. A failing architectural policy says the code shape itself is not permitted, even when the scenario still works perfectly. Both are important, but they should be visible and owned separately.

  5. Configuration is the policy surface. With Architecture.anl, layer relationships, type policies, site restrictions, and structural observations are explicit configuration. Changing the policy does not require inventing another test method or burying the rule in test code.

What Anaal IJzer adds

Anaal IJzer uses Roslyn's semantic model to resolve the symbols behind the source. That gives the rules a few useful properties:

  • aliases and fully qualified names resolve to the same symbol;
  • inferred locals still have a real type;
  • generic arguments, implemented interfaces, and attributes are inspected semantically;
  • nested boundaries are evaluated from their actual configured layer paths;
  • method-body policies apply even when no test happens to execute that branch.

For example:

  • A ReturnValuePolicy can reject a direct return null, an empty string, an enum-zero sentinel, or the unchanged result of a method annotated as optional.
  • A ForbiddenOperations policy can reject DateTime.UtcNow or Task.Wait() while leaving other members of the same framework type available.

Runtime coverage cannot prove a source-site rule unless it executes every relevant path. Equivalent coverage requires static inspection. Compiler analysis therefore addresses a different class of policy from architecture tests over built assemblies.

Complementary tools

Architecture tests still have a place for broad checks over shipped assemblies, external binaries, or intentional test-suite-level assertions. Behavioural and integration tests remain essential for proving that the application works. Anaal IJzer complements them by making configured structural and semantic policies part of ordinary compilation, with immediate feedback at the offending line.

Setup

1. Reference the analyzer

Add the analyzer package to the project you want to validate:

dotnet add package RonSijm.AnaalIJzer

Or add the package reference directly to your .csproj:

<ItemGroup>
    <PackageReference Include="RonSijm.AnaalIJzer" Version="0.3.5" PrivateAssets="all" />
</ItemGroup>

PrivateAssets="all" keeps the analyzer as a development-time dependency and prevents it from flowing transitively to projects that reference yours. Leave it out and everyone downstream inherits your layering opinions, which is a conversation best not started inside somebody else's build log.

2. Create the configuration file

Add a file called Architecture.anl to the root of the project you want to analyze:

<ArchitecturalLevels>

  <Layer name="Presentation">
    <Class endsWith="Endpoint" />
  </Layer>

  <Layer name="Application">
    <Class endsWith="Service" />
    <Class endsWith="Manager" />
    <Class endsWith="Coordinator" />
  </Layer>

  <Layer name="Persistence">
    <Class endsWith="Repository" />
  </Layer>

  <AllowedDependency from="Presentation" to="Application" />
  <AllowedDependency from="Application" to="Persistence" />

</ArchitecturalLevels>

Why .anl instead of .xml?

Architecture.anl is an XML document. I originally used the ordinary .xml extension and later changed only the extension, not the format. Settings still use the <ArchitecturalLevels> root, standard XML tooling, and the AnaalIJzer XSD schema.

I chose .anl for a couple of practical reasons:

  • It gives the settings file an architectural identity instead of making it look like unrelated application data.
    • A generic Architecture.xml tends to get filed under "legacy config of uncertain ownership" and removed during a tidy-up sprint.
  • It gives the tools a stable file type to recognize.
    • Arse and the standalone graph editor can register themselves as .anl handlers.
    • The Visual Studio companion can open an .anl file in its dependency-graph editor.

Add an XSD schema hint when you want XML-aware editors to validate element and attribute names while you edit. The schema is AnaalIJzer.xsd; generated configurations can place a copy beside Architecture.anl:

<ArchitecturalLevels xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                     xsi:noNamespaceSchemaLocation="AnaalIJzer.xsd">
  
</ArchitecturalLevels>

3. Register the file as an AdditionalFile

Tell MSBuild to pass the file to Roslyn:

<ItemGroup>
    <AdditionalFiles Include="Architecture.anl" />
</ItemGroup>

If the config uses <Include>, register the included settings files too:

<ItemGroup>
    <AdditionalFiles Include="*.anl" />
</ItemGroup>

The analyzer uses Architecture.anl as the explicit top-level settings file convention; other settings files are only read when referenced through <Include> or passed directly to Arse.

Skipping this step is the single most common setup mistake. Roslyn never receives the file, the analyzer finds no configuration, and it reports nothing - which looks exactly like a perfectly layered codebase until someone checks.

4. Share the same config with Directory.Build.props

If several projects should use the same Architecture.anl, put the XML next to a solution-level Directory.Build.props and register it there instead of copying the file into every project:

<Project>
  <ItemGroup>
    <PackageReference Include="RonSijm.AnaalIJzer" Version="0.3.5" PrivateAssets="all" />
    <AdditionalFiles Include="$(MSBuildThisFileDirectory)Architecture.anl" Link="Architecture.anl" />
  </ItemGroup>
</Project>

Directory.Build.props is imported by every project below its folder. $(MSBuildThisFileDirectory) keeps the path anchored to the props file, so every project receives the same config file regardless of where its .csproj lives. Per-project copies drift, and the copy that ends up being authoritative is never the one you edited.

If the analyzer is already referenced somewhere else, keep that reference and centralize only the config file:

<Project>
  <ItemGroup>
    <AdditionalFiles Include="$(MSBuildThisFileDirectory)Architecture.anl" Link="Architecture.anl" />
  </ItemGroup>
</Project>

5. Optional: inline settings with AssemblyMetadata

For small examples and throwaway projects, you can put the XML directly in code with the built-in AssemblyMetadataAttribute. Because the value is C#, exact type matches can use nameof(...) instead of fragile string literals:

using System.Reflection;

[assembly: AssemblyMetadata("AnaalIJzerSettings", $"""
<ArchitecturalLevels>
  <Layer name="Presentation">
    <Class endsWith="Endpoint" />
  </Layer>

  <Layer name="Application">
    <Class endsWith="Service" />
  </Layer>

  <Layer name="Persistence">
    <Class typeName="{nameof(OrderRepository)}" />
  </Layer>

  <AllowedDependency from="Presentation" to="Application" />
  <AllowedDependency from="Application" to="Persistence" />
</ArchitecturalLevels>
""")]

public sealed class OrderRepository { }

The analyzer recognizes AssemblyMetadata("AnaalIJzerSettings", "...") and reads the second constructor argument as XML. No custom helper attribute or extra package reference is needed.

If both config sources exist, Architecture.anl wins and the inline metadata value is ignored without comment. If carefully crafted inline rules suddenly stop applying, look for a file someone added last week.

The practical split is:

  • One-file examples and very small projects use AssemblyMetadata("AnaalIJzerSettings", ...).
    • Exact type-name rules can use nameof(...), so a refactor breaks at compile time instead of quietly breaking the config.
  • Broader examples and real rule sets use .anl files.
    • XML is easier to read once the settings need includes, nested layers, or several policy families.
  • Code fixes can edit either source.
    • When a rule comes from <Include>, the fixer targets the included file that actually owns it.

See IDE code fixes for the supported fixer matrix.

Example project: Example.InlineXml

That's it. The analyzer now runs for every .cs file in the project.

Examples use one vocabulary at a time. Explanatory diagnostics use the restaurant roles Customer, Waiter, Chef, and Pantry. Setup and reference examples use the technical layers Presentation, Application, and Persistence. A diagram, code block, or explanation never maps one vocabulary onto the other, because a Waiter in the Persistence layer helps nobody.

flowchart LR
    Customer --> Waiter --> Chef --> Pantry

The self-contained projects under Examples/ are referenced inline where their feature is documented. Most intentionally fail with documented ARCH_<CONCERN>_<REASON> errors; a few demonstrate clean wildcard config or generated report/documentation output. Scenario examples, such as Example.RepositoryQuerySurface, show larger usage patterns rather than a single analyzer feature.


Visual Studio 2026 companion extension

I made the Visual Studio add-on because an architecture rule is easier to understand when its layer is visible next to the code instead of being reconstructed from XML in your head. It is a VSIX companion, not a second analyzer: the analyzer remains the authority for ARCH_<CONCERN>_<REASON> diagnostics. The extension cannot bless a dependency the analyzer rejects, however tidy the graph looks.

It adds four visual workflows to Visual Studio 2026:

  • Layer information: badges, CodeLens-style summaries, gutter glyphs, highlights, and QuickInfo explain configured layers.
  • Sites Diagnostics: optional inline labels identify architectural sites such as constructors, fields, locals, inheritance, and generic arguments.
  • Dependency graphs: a dockable sidebar shows the configured layer graph, follows the active file, and can focus the graph that affects it.
  • Configuration fixes: the graph can preview and apply the same configuration-fix proposals that appear through analyzer light bulbs and Arse.

The screenshots below isolate a setting or tightly related set of settings so a reader can tell exactly what each control changes.

Install

Build the VSIX from the repository root:

build\Scripts\Addon\build-vs-extension.cmd

The script writes RonSijm.AnaalIJzer.VisualStudio.vsix to build\Artifacts\VisualStudio. Install that VSIX into Visual Studio 2026 to enable the editor companion. Each build stamps a timestamp-based extension version so Visual Studio recognizes it as newer than the previous local build.

The GitHub build-vsix.yml workflow builds and uploads the VSIX artifact on Windows. On pushes to main, it also submits the VSIX to Visual Studio Marketplace when the repository secret VS_MARKETPLACE_TOKEN is configured. Marketplace metadata lives in src\Extensions\RonSijm.AnaalIJzer.VisualStudio\marketplace-publish.json.

The companion reads the same Architecture.anl or AssemblyMetadata("AnaalIJzerSettings", ...) configuration as the analyzer through Visual Studio's Roslyn workspace. If no AnaalIJzer config exists, it renders nothing - an empty editor means "nothing is configured", not "everything is in order". If the config is invalid, the companion stays quiet and leaves the existing ARCH_CONF_003 analyzer diagnostic as the source of truth.

Layer information on declarations

Layer indicators are controlled from Visual Studio 2026 Settings under AnaalIJzer > Editor:

Option Default Meaning
Show layer badges On Shows the resolved canonical layer path after a type declaration identifier.
Show layer metadata above declarations On Shows a clickable CodeLens-style AnaalIJzer summary above a type declaration.
Show layer badges when not in layer Off Shows a neutral not in layer badge for a type that does not match any configured layer.
Show global layer rules in badge hover Off Includes wildcard rules such as * (any layer) in badge hover details.
Show mini call graph in badge hover On Shows a compact one-to-one dependency chain in badge hover details when the graph is linear.
Gutter glyphs On Shows a small layer marker beside a layered type declaration.
Highlight code in layer On Shows a region-like block highlight around a layered type declaration.
Tint layer declaration text Off Applies the older line-background tint to a layered type declaration.

Use this settings page to choose how much architectural context appears in the editor. Glyphs and badges support scanning; hover and CodeLens content provide additional detail on demand.

AnaalIJzer editor settings

Layer badge. A badge gives a type its configured architectural role directly beside its declaration. It is the quickest way to answer “where does this type belong?” without leaving the file.

Layer badge

Layer metadata above a declaration. The CodeLens-style summary shows the layer's immediate relationship to the rest of the graph before you open a hover card.

Layer metadata above a declaration

Not in layer. This optional neutral badge distinguishes an unclassified type from a classified type with no dependency violation.

Not in layer badge

Gutter glyph. The glyph keeps layer information visible in the editor margin when the declaration is horizontally out of view or collapsed.

Layer gutter glyph

Block highlight. Highlighting frames the complete declaration instead of tinting one line, making the type boundary visible in a dense file.

Layer block highlight

Hovering a layered type or dependency site also shows native Visual Studio QuickInfo. Layer QuickInfo shows the canonical path, ancestry, palette slot, description when configured, which layers may call the current layer, and which layers the current layer may call.

The hover adds incoming and outgoing layer relationships to the badge information, including a compact call chain when the relationship is linear.

Layer CodeLens and QuickInfo

Layer information and Sites Diagnostics at dependency sites

Layer information and Sites Diagnostics use the same supported dependency sites but answer different questions. A layer-information label says which configured layer the referenced type belongs to. A Sites Diagnostics label says where the dependency appears in C#.

Show all layer information enables every layer-information label. Show all site diagnostics enables every site label. The controls below can also be enabled independently:

Site Layer-information control Sites Diagnostics control
Constructor Show Constructor Layer Information Show Constructor Site Diagnostics
Method Show Method Layer Information Show Method Site Diagnostics
Method return Show MethodReturn Layer Information Show MethodReturn Site Diagnostics
Field Show Field Layer Information Show Field Site Diagnostics
Property Show Property Layer Information Show Property Site Diagnostics
Local Show Local Layer Information Show Local Site Diagnostics
Object creation Show New Layer Information Show New Site Diagnostics
Generic invocation Show GenericInvocation Layer Information Show GenericInvocation Site Diagnostics
Generic argument Show GenericArgument Layer Information Show GenericArgument Site Diagnostics
Base class Show Inheritance Layer Information Show Inheritance Site Diagnostics
Implemented interface Show InterfaceImplementation Layer Information Show InterfaceImplementation Site Diagnostics
Attribute Show Attribute Layer Information Show Attribute Site Diagnostics
Static member access Show StaticMember Layer Information Show StaticMember Site Diagnostics

The labels are editor information, not analyzer switches:

  • They show the syntactic site and resolved layer.
  • Turning one off hides the annotation, not the rule.
  • Allowed, warning, unclassified, and error states use different colors.

The analyzer still owns compile and build diagnostics. A hidden badge is not an architectural pardon.

For a clean demonstration of every site in one editor tab, open Example.VisualStudioSiteDiagnostics. It deliberately has no analyzer violations, so the layer and site labels remain easy to inspect.

A focused site explanation. The constructor label identifies where the dependency is introduced. The analyzer diagnostic states whether that use is permitted.

Constructor Site Diagnostics

A whole-file view. The all-sites example shows the difference between a referenced type's layer and the C# site that introduces the dependency. Open the example and enable the relevant controls to compare the labels.

All Layer Information sites

Dependency graphs

Use Extensions > IJzer > Show Dependency Graphs or command search to open the dockable graph sidebar. It uses the same WPF editor as the standalone graph tool and supports:

  • connected-graph grouping, with wildcard and global rules kept separately;
  • nested-boundary visualization;
  • user-controlled layout;
  • connector-based dependency creation and right-click editing;
  • PNG export.

When the solution has <SolutionTopology>, the sidebar adds a separate read-only module graph with observed direct project-reference evidence. Edit the .anl source when the module policy itself needs to change.

Start with the configured structure. With code evidence off, the graph contains the named layers and configured paths between them. Use this mode when reviewing or editing rules.

Dependency graph without code evidence

Option Default Meaning
Graph focus mode Highlight current Chooses Show all graphs, Highlight current graph, or Filter to current graph for the active editor.
Open .anl files in diagram editor On Opens or selects an .anl settings file in the graph editor automatically.
Include code evidence Off Includes matching project types and observed violations in graph snapshots.

Add evidence when investigating a project. Enabling code evidence adds matching-type counts and observed violations to the same graph. A dashed red connection identifies the observed dependency that violated a rule.

Dependency graph with code evidence

Configuration fixes from the graph

When the graph comes from an active C# document in a loaded Visual Studio project, it exposes Configuration fixes in both the root inspector and the selected layer or connection inspector. The same shared configuration-fix proposal catalog is used by Roslyn light bulbs, arse fixes, and the standalone graph editor. It can:

  • scan the active project for fixable AnaalIJzer diagnostics;
  • show the target file, risk level, and preview diff for each proposal;
  • apply one selected proposal and immediately refresh the graph.

You can also right-click a layer or dependency connection and open the scoped fixer view for that selection. The proposal list is filtered to the selected layer or dependency pair.

Detached .anl files still open in the graph editor, but they do not automatically have enough Roslyn project context to offer analyzer-backed configuration fixes.

Status and troubleshooting

Use Extensions > IJzer > Show Status if the editor appears quiet. It analyzes the active document and reports whether the file is part of Visual Studio's Roslyn workspace, whether settings were found, how many layer/site indicators were produced, and whether configuration issues are suppressing visual adornments. It is faster than the traditional method of restarting Visual Studio three times and hoping.

The companion writes logs to Visual Studio's Activity Log and to an Output pane named AnaalIJzer.

If settings, commands, or editor visuals do not appear:

  1. Run Extensions > IJzer > Show Status.
  2. Start Visual Studio with logging enabled.
  3. Reproduce the issue.
  4. Search the Activity Log and AnaalIJzer Output pane.

The first missing event usually tells you which half is broken:

  • No AnaalIJzer entries at all means the VSIX package is not loading.
  • Package initialization without tagger entries means the editor MEF component is not being created for the active C# view.

For local validation, use the Visual Studio companion manual acceptance checklist.

The extension looks for settings in this order:

  • analyzer AdditionalFiles;
  • inline AssemblyMetadata("AnaalIJzerSettings", ...);
  • as an editor-only convenience, the nearest Architecture.anl above the active document.

An invalid config intentionally produces no adornments. ARCH_CONF_003 remains the source of truth instead of decorating the editor with guesses from a half-parsed file.

Technical notes

The implementation uses:

  • MEF taggers, glyphs, and inline adornments;
  • option pages and Fonts & Colors format definitions;
  • shared snapshot logic from RonSijm.AnaalIJzer.Editor.

The last part matters: the extension does not maintain its own slightly different interpretation of config parsing and layer matching.

IDE code fixes

Visual Studio and Rider can now apply AnaalIJzer fixes against both:

  • file-based Architecture.anl;
  • inline AssemblyMetadata("AnaalIJzerSettings", ...).

The analyzer still owns the diagnostics. The code-fix layer only proposes deterministic edits that are local, previewable, and unlikely to invent architecture by accident. Silently widening a boundary because a build went red is how a layering rule turns into a layering suggestion.

There are two families of fixes:

  • source fixes change C# code directly;
  • configuration fixes change Architecture.anl or inline AssemblyMetadata.

Arse reuses the same configuration-fix catalog headlessly through arse fixes and arse apply-fix. That means the light-bulb suggestions you see in the IDE and the proposal list you can review in CI or a terminal come from the same underlying fix implementations.

The Visual Studio dependency-graph tool window now reuses that same shared catalog as well. From Extensions > IJzer > Show Dependency Graphs, the graph can load fix proposals for the active project, preview the config diff, and apply one proposal without leaving the graph view. The root inspector shows the full project list, while selecting or right-clicking a layer or dependency connection switches to a filtered selection-scoped view.

For the broader mental model, ownership rules, and risk labels, see Configuration fixers.

Support matrix

Diagnostic IDE fix support Covered by tests
ARCH_DEP_001 add missing <AllowedDependency>; extend allowedSites; relax blockedSites; add exception DependencyRuleCodeFixTests.cs, AddToExceptionsCodeFixTests.cs
ARCH_DEP_002 classify the unknown dependency into an existing layer; remove the current site from requireRecognizedDependencies globally or for the current caller layer RecognizedDependencyCodeFixTests.cs
ARCH_TYPE_001 forbidden rule match: rename via <Fix Rename="..."> or add exception; allow-list failure: add exact <Class typeName="..."/> to every applicable <Allowed> list RenameCodeFixTests.cs, AllowedTypePolicyCodeFixTests.cs, AddToExceptionsCodeFixTests.cs
ARCH_DEP_004 add the forward <AllowedDependency>; flip the exact configured reverse <AllowedDependency> when one concrete reverse rule exists; repair site filters; add exception DependencyRuleCodeFixTests.cs, AddToExceptionsCodeFixTests.cs
ARCH_DEP_005 add a same-layer self-edge, optionally site-scoped; add exception DependencyRuleCodeFixTests.cs, AddToExceptionsCodeFixTests.cs
ARCH_CONF_006 for each concrete allowed edge in a configured cycle: add a matching blocking edge, or remove that allowed edge; the user chooses the edge CycleDependencyCodeFixTests.cs
ARCH_NAME_008 rename the declaration when the rule compares declaration name to semantic type; add <Allow from="..." to="..."/> mappings, including a site-scoped variant for RequireMatchingNames DeclarationNameCodeFixTests.cs, NameRuleAllowMappingCodeFixTests.cs
ARCH_API_001 add or widen <ApiSurface><AllowedLayer ... /></ApiSurface>; relax blockedSites; disable requireRecognizedTypes when that is the denial ApiSurfacePolicyCodeFixTests.cs
ARCH_PROJ_001 add a missing group-level <AllowedProjectReference>; add a narrow exact-project rule with <From> and <To> selectors; add an explicit same-group self-edge; remove the matching blocking <BlockedProjectReference> rule ProjectArchitectureCodeFixTests.cs
ARCH_PKG_001 append an exact <Package exactName="..."/> matcher to the matched allowed package list PackagePolicyCodeFixTests.cs
ARCH_VIS_001 add the reported visibility to allowedAccessibilities; remove it from blockedAccessibilities; remove a single-value blocking policy entirely VisibilityPolicyCodeFixTests.cs
ARCH_CONT_008 remove a disallowed property setter when the violation is exactly that accessor ContractPurityCodeFixTests.cs
ARCH_API_010 the same ApiSurface configuration fixes as ARCH_API_001 ApiSurfacePolicyCodeFixTests.cs
ARCH_SRC_007 add an exact <Source exactName="..."/> rule to the owning layer SourceLocationCodeFixTests.cs
ARCH_BOUND_007 add a boundary <EntryPoint>; add a required site to allowedSites; remove the current site from blockedSites BoundaryEntryPointCodeFixTests.cs
ARCH_DEP_006 no configuration fix: this reports an observed source-code cycle, which configuration editing cannot honestly repair ExampleConfigurationFixIntegrationTests.cs
ARCH_INH_001 add a single required base type or a single required interface when the change is unambiguous InheritancePolicyCodeFixTests.cs
ARCH_RET_001 no automatic fix: a rejected return expression does not tell the analyzer which domain result, named hand-off, or fallback should replace it ReturnValuePolicyAnalyzerTests.cs
ARCH_OPER_001 no automatic fix: a forbidden selected operation does not identify the intended adapter, async flow, or composition-boundary change ForbiddenOperationPolicyAnalyzerTests.cs
ARCH_OPER_002, ARCH_OPER_011, ARCH_OPER_012 no automatic fix: adding, moving, or removing an operation requires an explicit workflow decision BehavioralOperationPolicyAnalyzerTests.cs
ARCH_OPCT_001, ARCH_OPCT_002, ARCH_OPCT_008 no automatic fix: choosing an operation owner, entry-point delegation, or contract shape requires an explicit workflow decision OperationContractAnalyzerTests.cs
ARCH_ASSM_001 no automatic fix: changing emitted assembly metadata or widening its allow/deny policy requires an explicit ownership decision AssemblyAttributePolicyCodeFixTests.cs
ARCH_NS_007 no automatic fix: moving namespace ownership, introducing a contract, or changing a namespace relationship requires an explicit architectural decision NamespaceHierarchyPolicyAnalyzerTests.cs

Deliberate limits

  • ARCH_PROJ_001 and ARCH_PKG_001 are compilation-end diagnostics. The config edits exist and are covered by analyzer tests, but whether an IDE host shows them as ordinary editor light bulbs depends on how that host surfaces Location.None diagnostics.
  • ARCH_CONT_008, ARCH_INH_001, ARCH_RET_001, ARCH_OPER_*, ARCH_OPCT_*, ARCH_ASSM_001, and ARCH_NS_007 stay intentionally narrow. If the analyzer cannot tell which one deterministic edit is the right one, it does not guess. A confidently wrong automatic fix is harder to spot in review than no fix at all.
  • Configuration fixers preserve the owning source where possible:
    • if a rule came from an included .anl, that included file is edited;
    • if the config came from inline AssemblyMetadata, the source file containing the assembly attribute is rewritten.

For a light-bulb-friendly baseline, start with the analyzer tests in src/Tests/RonSijm.AnaalIJzer.Analyzer.Tests/Diagnostics/.

For the end-to-end headless path, see src/Tests/RonSijm.AnaalIJzer.Application.Tests/ApplicationOperations/ApplicationOperationsTests.ConfigurationFixes.cs.

For real example-project coverage, including expected proposal titles for included .anl, inline AssemblyMetadata, site filters, name rules, source locations, and project architecture scenarios, see src/Tests/RonSijm.AnaalIJzer.IntegrationTests/ExampleConfigurationFixIntegrationTests.cs.

For Visual Studio graph-window state coverage, including preserving the active project context across graph refreshes, see src/Tests/RonSijm.AnaalIJzer.VisualStudio.Tests/Graphs/ArchitectureGraphToolWindowStateTests.cs.

Configuration fixers

Configuration fixers are the part of AnaalIJzer that edit the architecture settings instead of editing your C# code.

For a configured cycle (ARCH_CONF_006), the fixer presents the exact allowed edges in the cycle and lets you choose one to block or remove. It does not choose an architectural direction on your behalf. An observed source-code cycle (ARCH_DEP_006) has no configuration fixer: changing a rule would not remove the code dependency that created it.

That distinction matters:

  • a source fix changes code, such as renaming a declaration;
  • a configuration fix changes Architecture.anl or inline AssemblyMetadata("AnaalIJzerSettings", ...).

Use configuration fixers when the code is acceptable but the rule set needs a narrow, explicit update.

What they are for

The fixers are designed for maintenance work that is repetitive but still deterministic:

  • add one missing <AllowedDependency>;
  • append one missing allowedSites token;
  • remove one blocking blockedSites token;
  • classify one unknown dependency into an existing layer;
  • add one exception entry;
  • add one allow-list or boundary entry-point item.

They are intentionally conservative. They do not try to redesign the architecture for you. A light bulb that restructures your boundaries on your behalf would be memorable in all the wrong ways. When there is more than one plausible architecture edit, they should present named choices and let you pick one.

Where they work

The same shared fixer catalog is reused by three hosts:

Host What you can do
Visual Studio / Rider light bulbs apply source fixes and configuration fixes from diagnostics
Visual Studio dependency graph preview and apply configuration fixes from the active project, including layer- and dependency-scoped graph selections
Arse list proposals with arse fixes and apply one with arse apply-fix
WPF graph editor load, preview, filter, and apply configuration fixes from project or solution input

The host UI changes, but the proposal generation is shared. That keeps the terminal, graph editor, and IDE from disagreeing about what a safe config change looks like.

Ownership rules

Every proposal targets the real owning source:

  • if the rule came from the root Architecture.anl, that file is edited;
  • if the rule came from an included .anl, that included file is edited;
  • if the rule came from inline AssemblyMetadata, the source file that contains that assembly attribute is edited.

That way the fix does not silently flatten includes or move rules into the wrong file.

Risk labels

Each proposal is ranked as one of these:

  • Safe: a narrow edit with one obvious meaning;
  • Guided: still deterministic, but it changes policy more directly;
  • High risk: technically valid, but broad enough that you should read it carefully first.

In practice:

  • adding a single missing site token is Safe;
  • adding a new <AllowedDependency> is usually Guided;
  • flipping one exact reverse <AllowedDependency> for ARCH_DEP_004 is Guided;
  • widening API-surface policy is High risk.

Typical flow

  1. AnaalIJzer reports a diagnostic.
  2. The fixer catalog checks whether a deterministic config edit exists.
  3. A proposal is created with title, reason, target file, and preview diff.
  4. The host shows that proposal.
  5. You choose whether to apply it.
  6. The project or config is analyzed again.

The important part is that the UI never edits XML text directly. It applies the same structured edit model the other hosts use.

Selection-scoped graph fixes

The graph editors support two views of the same proposal list:

  • a root view with every proposal found for the current project or solution;
  • a selection-scoped view filtered to the chosen layer or dependency pair.

In the Visual Studio graph and the standalone WPF graph editor, right-clicking a layer or dependency connection and choosing Show configuration fixes switches directly to that filtered view.

That is useful when a large project has many proposals but you are only investigating one boundary.

Current diagnostic coverage

The current configuration-fix coverage is documented in IDE code fixes. That page is the support matrix; this page is the mental model.

Where to verify it

The feature is intentionally covered at several levels:

  • analyzer fixer tests: src/Tests/RonSijm.AnaalIJzer.Analyzer.Tests/Diagnostics/
  • application-level project and solution flows: src/Tests/RonSijm.AnaalIJzer.Application.Tests/ApplicationOperations/ApplicationOperationsTests.ConfigurationFixes.cs
  • real example-project expectations: src/Tests/RonSijm.AnaalIJzer.IntegrationTests/ExampleConfigurationFixIntegrationTests.cs
  • WPF graph-editor selection filtering: src/Tests/RonSijm.AnaalIJzer.GraphEditor.Wpf.Tests/Controls/ArchitectureGraphEditorControlPersistenceTests.ConfigurationFixSelection.cs
  • Visual Studio graph context preservation: src/Tests/RonSijm.AnaalIJzer.VisualStudio.Tests/Graphs/ArchitectureGraphToolWindowStateTests.cs
  • Arse command-line support: src/Tests/RonSijm.AnaalIJzer.Arse.Tests/

When not to use one

Sometimes the right fix is still a code change, not a config change:

  • renaming a declaration so it matches a naming rule;
  • moving a type into the correct file or folder;
  • extracting an interface or projection to the right layer;
  • deleting a bad dependency instead of allowing it.

AnaalIJzer should help with both kinds of repair, but it should not use a config escape hatch when the code is simply wrong. A rule relaxed to make one build green tends to be quoted six months later as deliberate design.

Arse TUI

Arse means Architecture Rule Standalone Executable. I wanted one standalone host for inspecting projects, generating settings, writing reports, and doing the other jobs that should not happen inside a compiler analyzer. The name followed from there, I suppose. It loads a real project or solution with MSBuildWorkspace, so it sees the same compiled AnaalIJzerSettings metadata value as the analyzer. It can also generate documentation directly from a specific XML settings file.

dotnet tool install --global RonSijm.AnaalIJzer.Arse

Interactive mode

Run arse without arguments for the interactive terminal interface built with RazorConsole.

  • Path fields show matching directories and relevant files while you type.
    • Use Up/Down to select a suggestion.
    • Use Right Arrow to complete it without leaving the field.
    • Use Tab to apply the selected or shared-prefix completion before moving on.
  • Architecture inspection shows its report before writing anything.
    • Choose Save afterward if you want the report on disk.

Headless mode

Supply a command to use the same executable without the TUI:

arse generate-config --project src\MyApp\MyApp.csproj --output Architecture.anl
arse generate-config --solution src\MyApp.slnx --strategy helpful --output Architecture.anl
arse generate-config --project src\MyApp\MyApp.csproj --strategy conventions --minimum-confidence 0.95 --minimum-support 10 --generate-documentation --include-input
arse export-config --project src\MyApp\MyApp.csproj --output Architecture.anl
arse documentation --project src\MyApp\MyApp.csproj --output docs\architecture-documentation.md --force
arse documentation --config Architecture.anl --output docs\architecture-documentation.md --force
arse report        --project src\MyApp\MyApp.csproj --output docs\architectural-violations.md --force
arse report        --solution src\MyApp.slnx --output docs\architectural-violations.md --force
arse inspect       --project src\MyApp\MyApp.csproj --output docs\architecture-health.md --force
arse inspect       --solution src\MyApp.slnx --output docs\architecture-health.md --force
arse inspect       --solution src\MyApp.slnx --enforce-topology --output build\Artifacts\architecture-health.json --force
arse fixes         --project src\MyApp\MyApp.csproj
arse fixes         --solution src\MyApp.slnx --output docs\architecture-fixes.md --force
arse apply-fix     --project src\MyApp\MyApp.csproj --fix-id fix-abc123
arse merge-config  --config Shared.anl --config Project.anl --output Architecture.anl --force
arse split-config  --config Architecture.anl --output ArchitectureRules --force
arse format-config --config Architecture.anl
arse explain-config --config Architecture.anl --output docs\architecture-explanation.md --force

Generate a baseline

generate-config inspects source-defined types and the dependency sites already present in the project. It infers layers from the first namespace segment below the project's common namespace, falling back to familiar type suffixes such as Controller, Service, Repository, Handler and Projection. The command writes both Architecture.anl and a local AnaalIJzer.xsd, then runs the analyzer against the generated XML before accepting the result - a generator that emits configuration its own analyzer rejects would not be much of a favour.

The generation strategy controls how observed dependencies become rules:

Strategy Behavior
snapshot The default. Every observed layer edge and dependency site becomes an AllowedDependency, producing a passing description of the current structure.
helpful A gentle baseline. For projects it behaves like a current-structure snapshot with softer wording. For solutions it creates one layer per C# project assembly using <Assembly exactName="..."> and permits observed project-to-project dependency sites.
conventions Infers dominant edges and writes minority caller types into <Exceptions>, producing a passing ratchet that blocks new callers from following those outliers.
Solution-wide baselines

For a solution-wide baseline, generate Architecture.anl beside the solution or in an ancestor directory:

arse generate-config --solution src\MyApp.slnx --strategy helpful --output Architecture.anl
arse inspect --solution src\MyApp.slnx --output build\Artifacts\architecture-health.md --force

Solution inspection still respects project-specific Architecture.anl and inline AssemblyMetadata("AnaalIJzerSettings", ...) first. If a project has no local config, Arse applies the nearest Architecture.anl found from the solution directory upward. Shared solution configs are inspected against the combined solution evidence, so assembly matchers and dependency edges are not reported as unused just because they do not apply to every project individually.

AnaalIJzer dogfoods this flow with build\Scripts\Arse\inspect-self-architecture.cmd, using a helpful solution baseline instead of a strict hand-authored policy.

Convention thresholds

Convention inference is configurable:

Option Default Meaning
--minimum-confidence 0.90 Minimum share of active caller types in a layer that must use an edge. The generator counts distinct caller types, not raw syntax occurrences.
--minimum-support 5 Minimum number of distinct callers that must use an edge before it can be treated as a convention.

Think of confidence as “is this edge popular enough?” and support as “have we seen enough callers to trust that percentage?” An edge must pass both thresholds.

One project, different thresholds

Suppose Arse observes ten distinct active caller types in the Presentation layer:

Existing callers Observed dependency Confidence Support
8 endpoints Presentation --> Application 8 / 10 = 0.80 8
2 endpoints Presentation --> Persistence 2 / 10 = 0.20 2

An active caller is a distinct type in the source layer that has at least one observed outgoing dependency. An endpoint that mentions Application ten times still contributes one caller, not ten. The confidence and support comparisons are inclusive: an edge with confidence 0.80 and support 8 passes thresholds of exactly 0.80 and 8.

Run convention generation several times against that same project, changing only the thresholds:

arse generate-config --project Shop.csproj --strategy conventions --minimum-confidence 0.75 --minimum-support 5 --output Architecture.75-5.anl
arse generate-config --project Shop.csproj --strategy conventions --minimum-confidence 0.90 --minimum-support 5 --output Architecture.90-5.anl
arse generate-config --project Shop.csproj --strategy conventions --minimum-confidence 0.75 --minimum-support 9 --output Architecture.75-9.anl
arse generate-config --project Shop.csproj --strategy conventions --minimum-confidence 0.20 --minimum-support 2 --output Architecture.20-2.anl

The same evidence now produces four different results:

Confidence Support Edges that qualify as conventions Generated result
0.75 5 Presentation --> Application Writes only the Application edge. The two Persistence callers become exact-name exceptions under the generated Presentation matcher.
0.90 5 None: 0.80 is below 0.90 Evidence is ambiguous, so both observed edges are preserved as a snapshot and no exceptions are added.
0.75 9 None: support 8 is below 9 Evidence is ambiguous, so both observed edges are preserved as a snapshot and no exceptions are added.
0.20 2 Both edges Writes both edges as conventions. There are no rejected edges, so no exceptions are needed.

The first invocation treats Presentation --> Application as the dominant convention. Its generated output is conceptually:

<Layer name="Presentation">
  <Namespace regex="^Shop\.Presentation(?:\.|$)">
    <Exceptions>
      <Class exactFullName="Shop.Presentation.LegacyAdminEndpoint" />
      <Class exactFullName="Shop.Presentation.ImportEndpoint" />
    </Exceptions>
  </Namespace>
</Layer>

<AllowedDependency from="Presentation" to="Application" />

The two outlier endpoint names are illustrative; Arse writes the actual fully qualified caller names it found. If no edge from a source layer reaches both thresholds, Arse does not guess: it preserves every observed edge from that layer as an ambiguous snapshot.

The executable counterpart lives in src/Tests/RonSijm.AnaalIJzer.Application.Tests/ApplicationOperations. The theory cases there run this same 8/2 setup with the four threshold combinations above and verify the generated edges, ambiguity fallback, and exceptions.

Generated <Exceptions> use the analyzer's existing ratchet semantics: the caller is exempt from that layer matcher, so all of that caller's dependencies are grandfathered. Review these entries before adopting the file. Convention mode identifies statistically dominant structure; it cannot prove architectural intent. Eight classes doing the same thing is evidence of a habit, which is not automatically evidence of a decision.

Document the generated baseline

Add --generate-documentation to write architecture-documentation.md beside the generated XML. The generated document includes:

  • evidence counts behind inferred edges;
  • project types resolved by each matcher;
  • concrete code usages permitted by each allowed dependency;
  • generated exceptions as unclassified types;
  • current analyzer violations.

Add --include-input when the document should also contain a fenced copy of the generated XML.

Export, document, report, and inspect

The related commands have deliberately different jobs:

  • export-config writes evaluated inline XML.
    • typeName="{nameof(OrderRepository)}" becomes typeName="OrderRepository" in the persisted file.
  • documentation accepts a project-backed config or one specific .anl file.
  • report accepts a project or solution.
    • Solution mode opens every C# project and combines the diagnostics into one Markdown report.
  • documentation and report use documentationPath / reportPath when --output is omitted.
    • A solution report uses the first configured project as its representative settings source.
    • Without a configured reportPath, it writes architectural-violations.md beside the solution.

inspect (aliases: validate, doctor, health, self-check) accepts a project, solution, or .anl file and writes architecture-health.md by default.

  • Config inspection reports malformed settings, missing includes, invalid matchers, unknown layer references, and configured cycles.
  • Project inspection also reports unclassified or ambiguously classified types, unmatched matchers, stale exceptions, unused allowed edges, observed dependency cycles, and current analyzer violations.
  • Solution inspection runs the project checks for every C# project and combines the findings.
    • Add --enforce-topology to evaluate <SolutionTopology> as ARCH_SOL_001 / ARCH_SOL_006 findings without changing normal project builds.
  • JSON output contains the same ordered findings as machine-readable evidence.

Headless Arse exits with code 3 when findings require review. That gives CI something concrete to fail on instead of producing a report everybody agrees to read later.

Merge, split, format, and explain settings

merge-config recursively replaces <Include> elements with their referenced rules and writes one self-contained XML file. Repeated references resolving to the same path are included once. Root settings such as requireRecognizedDependencies, report paths, documentation paths and the XSD location are preserved and rebased relative to the merged output.

split-config treats AllowedDependency and BlockedDependency entries as an undirected graph for grouping purposes. When the configuration contains disconnected graphs, it writes:

  • Architecture.anl as the new manifest.
  • One Graph.XX.<layers>.anl file per disconnected dependency graph.
  • Shared.anl for global rules such as <Forbidden>, when needed.

The manifest includes every generated file, so it remains a complete replacement for the original configuration. Wildcard dependencies connect every named layer and therefore prevent those layers from being split into separate graphs. In Arse's interactive mode, enter multiple merge inputs separated by semicolons.

format-config normalizes the XML formatting of an .anl file. Without --output, it formats the input file in place. Use --output when you want to preview the normalized version beside the original.

explain-config writes a compact Markdown walkthrough of a settings file in XML order: root settings, includes, layers, matchers, dependency rules, type policies and name rules. It is intentionally shorter than generated architecture documentation and useful during review when you want to understand what a ruleset says before loading a project.

Configuration fixes

fixes lists configuration-backed proposals from the same Roslyn fixer catalog used by the IDE. One diagnostic may offer several proposals, for example adding a missing AllowedDependency, widening allowedSites, or relaxing blockedSites. Arse shows the proposals with stable ids, a risk label, the target file, and a preview diff so you can review them before applying anything.

apply-fix applies one of those proposal ids back to the owning settings source. If the rule came from an included .anl, Arse edits that included file. If the project uses inline AssemblyMetadata("AnaalIJzerSettings", ...), Arse rewrites only the metadata string in the owning source file. After applying a fix, Arse reruns the proposal collection so you can immediately see what remains.

The executable coverage lives in src/Tests/RonSijm.AnaalIJzer.Application.Tests/ApplicationOperations/ApplicationOperationsTests.ConfigurationFixes.cs. Those tests exercise both file-based and inline settings projects end to end: list proposals, apply one fix, and verify that the proposal list becomes empty afterward.

One operation catalog

Arse's interactive and headless modes share RonSijm.AnaalIJzer.Application. Its ToolOperationCatalog, ToolRequest and ToolRunner own the available operations, supported inputs, validation and execution behavior, keeping both modes in feature parity.

Anaaltomy Statistics

Anaaltomy is a standalone compiled-code statistics tool. It measures the shape of C# projects with Roslyn and keeps the results in SQLite so the same measurements can be compared across the current tree and Git history.

It is deliberately separate from AnaalIJzer's architecture rules:

  • It does not need Architecture.anl.
  • It does not run ARCHxxx diagnostics or classify code into layers.
  • It reuses the same definitions of C# type kinds and dependency sites, so its measurements mean the same thing as the analyzer's terminology.

Install

dotnet tool install --global RonSijm.Anaaltomy
anaaltomy --help

The global command is anaaltomy.

To build the tool and its NuGet package locally from this repository, run:

build\Scripts\Anaaltomy\build-anaaltomy.bat

The compiled tool is written to build\Artifacts\Anaaltomy. The global-tool packages are written to build\Artifacts\Anaaltomy\Packages, ready for local installation or manual upload to NuGet.org.

Scan The Current Tree

Choose exactly one input shape and an explicit SQLite database path. A sensible database location is under build\Artifacts, rather than beside source files.

anaaltomy scan --project .\src\Pizza\Pizza.csproj --database .\build\Artifacts\statistics.db
anaaltomy scan --solution .\Pizza.slnx --database .\build\Artifacts\statistics.db
anaaltomy scan --directory .\src --database .\build\Artifacts\statistics.db

scan loads real C# project compilations through MSBuildWorkspace. It understands project references, conditional compilation, language versions, linked files, and target frameworks instead of guessing from raw text.

Useful scan options:

Option Default Meaning
--configuration Release Release MSBuild configuration used while loading projects.
--framework net10.0 project-selected Select a target framework for a multi-target project.
--include-generated off Include generated source files.
--restore-mode auto\|never\|always auto Control restore before project evaluation.
--allow-partial off Return exit code 0 even when a project could only be partially scanned.

Compiler errors do not discard usable observations. Anaaltomy stores the available measurements and marks the affected project and scan as partial. It exits with code 3 unless --allow-partial is supplied.

What It Measures

Each scan records aggregate counts, not a database row for every source location:

Dimension Examples Unit
TypeKind Class, Record, RecordStruct, Interface, Enum logical source type
DependencySite Constructor, MethodReturn, Local, Inheritance, Attribute recognized source occurrence
TypeAccessibility Public, Internal, File logical source type
MemberAccessibility Public, Protected, Private declared source member
MemberKind Constructor, Method, Property, Field, Event logical source member

Partial types are counted once as logical types. Dependency sites are counted per source occurrence, including both a generic use and its nested generic arguments when applicable. Directory and solution totals are per compilation: a linked file compiled by two projects appears once in each project compilation.

Generated code is excluded by default. Metadata/framework types are not counted as project declarations, and unresolved compiler observations are recorded as partial-scan metadata instead of being forced into an invented bucket.

Store And Query Results

SQLite is the canonical store. JSON and CSV are export formats, not the source of truth.

anaaltomy summary --database .\build\Artifacts\statistics.db
anaaltomy trend --database .\build\Artifacts\statistics.db --dimension DependencySite --bucket Local
anaaltomy compare --database .\build\Artifacts\statistics.db --from v0.2.0 --to HEAD
anaaltomy commits --database .\build\Artifacts\statistics.db --dimension TypeKind --bucket Record
anaaltomy export --database .\build\Artifacts\statistics.db --format json --output .\build\Artifacts\statistics.json
anaaltomy export --database .\build\Artifacts\statistics.db --format csv --output .\build\Artifacts\statistics.csv
anaaltomy export --database .\build\Artifacts\statistics.db --format markdown --output .\build\Artifacts\statistics.md
anaaltomy export-database --database .\build\Artifacts\statistics.db --format json --output-directory .\build\Artifacts\statistics-json
anaaltomy export-database --database .\build\Artifacts\statistics.db --format csv --output-directory .\build\Artifacts\statistics-csv
anaaltomy export-database --database .\build\Artifacts\statistics.db --format markdown --output-directory .\build\Artifacts\statistics-md
anaaltomy chart --database .\build\Artifacts\statistics.db --output-directory .\build\Artifacts\charts
anaaltomy chart --database .\build\Artifacts\statistics.db --output-directory .\build\Artifacts\charts --dimension DependencySite
anaaltomy chart --database .\build\Artifacts\statistics.db --output-directory .\build\Artifacts\charts --group --dimension MemberAccessibility --group-by MemberKind
anaaltomy chart --database .\build\Artifacts\statistics.db --output-directory .\build\Artifacts\charts --trend --dimension DependencySite --bucket Local

The query commands answer different questions:

  • trend: how did one dimension/bucket change over commit time?
  • commits: at which commits did that stored count actually change?
  • compare: what is the bucket-by-bucket difference between two stored commit scans?

They use the most recently completed repository/scan-definition history in the database. Measurements collected with different compiler options are never quietly combined into one very confident graph.

The export commands are deliberately separate:

  • export writes the latest summary as JSON, CSV, or Markdown.
  • export-database writes the full SQLite-shaped store as one file per table:
    • SchemaVersion, Repository, GitCommit, and GitCommitParent;
    • ScanDefinition, CommitScan, and ProjectScan;
    • Measurement, GroupedMeasurement, and ScanFailure.

SQLite remains the source of truth. The exported files are portable snapshots for reporting, inspection, or downstream tooling.

chart creates deterministic PNG reports:

  • Without --trend, it uses the latest scan.
  • Without --dimension, it writes one horizontal bar chart for every populated dimension.
    • Type kinds, dependency sites, type accessibility, member accessibility, and member kinds each get their own chart.
  • Every title names the scanned project, solution, or folder.
    • For example: Anaaltomy Dependency Site breakdown of 'Azure.Storage.Blobs'.
  • --group creates a grouped breakdown from the latest scan.
    • Use --group-by <dimension> to choose the grouping dimension explicitly.
  • --trend --dimension <dimension> --bucket <bucket> creates a line chart across stored Git-history points.

The database remains authoritative. PNG files are the part you can put in a report without asking its readers to query SQLite first.

An exported JSON summary looks like this in principle:

{
  "projectCount": 3,
  "failureCount": 0,
  "measurements": [
    { "dimension": "TypeKind", "bucket": "Class", "count": 42 },
    { "dimension": "DependencySite", "bucket": "Local", "count": 107 }
  ],
  "groupedMeasurements": [
    { "dimension": "MemberAccessibility", "bucket": "Public", "groupDimension": "MemberKind", "groupBucket": "Method", "count": 30 }
  ]
}

The database starts with an explicit schema-version table and keeps repositories, commits, every parent edge, scan definitions, project scans, measurements, grouped measurements, and failures. This preserves merge-parent relationships rather than flattening Git history into a guessed linear sequence.

Scan Git History Safely

Historical scans use a temporary detached Git worktree outside your active checkout. Anaaltomy never checks out a historical revision in the worktree you are using to write code.

Scanning all reachable history must be explicit:

anaaltomy history --repository . --from-root --database .\build\Artifacts\statistics.db
anaaltomy history --repository . --from v0.2.0 --to HEAD --database .\build\Artifacts\statistics.db
anaaltomy history --repository . --from-root --first-parent --max-commits 50 --database .\build\Artifacts\statistics.db

History selection is explicit:

  • Use either --from-root or --from <revision>.
    • The tool rejects an unbounded accidental history scan.
  • Add --first-parent to follow the primary integration path.
  • Without it, Anaaltomy scans every reachable commit in topological order.
  • Merge commits are scanned as their resulting source tree and retain both parent links in SQLite.

Historical scans default to --restore-mode always, because an isolated worktree may not have restored assets. They can be resumed:

anaaltomy history --repository . --from-root --resume --database .\build\Artifacts\statistics.db

Completed commit scans with the same scan definition are skipped; incomplete or failed commits are retried. Anaaltomy records a failure and continues when it can, returning exit code 3 unless --allow-partial was requested.

Safety And Exit Codes

MSBuildWorkspace project evaluation and dotnet restore can execute repository-controlled build logic. Only scan repositories you trust, or run untrusted repositories inside a suitable sandbox or disposable environment.

Exit code Meaning
0 Complete scan, successful query, or successful export.
2 Invalid command-line input.
3 Partial scan, unresolved project, or failed historical commit.
4 SQLite migration or persistence failure.

Use anaaltomy --help for the complete option list.

WPF graph editor component

The WPF graph editor is the reusable visual editor behind the standalone graph editor harness and the Visual Studio dependency-graph tool window. I keep it outside the Visual Studio project because the graph is useful without Visual Studio too, and debugging WPF inside a VSIX every time would be an unnecessarily specific hobby.

Project Purpose
src/Main/RonSijm.AnaalIJzer.Graphing Shared graph view models and layout grouping.
src/Main/RonSijm.AnaalIJzer.Graphing.Wpf WPF/Nodify controls for viewing and editing architecture graphs.
src/Tools/RonSijm.AnaalIJzer.GraphEditor.Standalone Small executable harness for testing the WPF component outside Visual Studio.
src/Extensions/RonSijm.AnaalIJzer.VisualStudio Hosts the same WPF component inside the Visual Studio companion extension.

The central controls are ArchitectureGraphEditorControl and ArchitectureGraphCanvas. Given an ArchitectureGraphSnapshot, they:

  • render connected layer graphs from left to right;
  • keep wildcard and global rules separate from the concrete graphs;
  • preserve the user's layout in graph-editor user settings;
  • add a separate read-only topology graph when a solution contains <SolutionTopology>.
    • That graph shows configured modules beside observed direct project references.
    • It is for investigation; topology edits still belong in the authoritative Architecture.anl file.

The editor is source-aware. It can edit XML settings files and inline AssemblyMetadata("AnaalIJzerSettings", ...) settings, then reload through the same configuration-reading path used by the analyzer tooling. The graph supports:

  • dragging and resizing nested layer groups;
  • collapsing graph groups;
  • moving individual nodes without losing positions on refresh;
  • creating root and child layers from context menus;
  • drawing new dependencies from output connectors to input connectors;
  • removing layers and dependencies;
  • editing allowed/blocked dependency kind, site filters, descriptions and descendant cascading;
  • editing layer matchers, scoped type policies, includes and root settings from the inspector;
  • exporting the currently rendered graph surface to a PNG image.

When the standalone harness is opened from a .csproj, .sln, or .slnx, the graph exposes Configuration fixes in both the root inspector and the selected layer or connection inspector. That panel uses the same shared configuration-fix catalog as the Roslyn light bulbs and arse fixes, shows preview diffs, and can apply one proposal and immediately reload the diagram.

Right-clicking a layer or connection also offers a direct Show configuration fixes entry point. The resulting inspector view filters the loaded proposal list to the selected layer or dependency pair.

The component itself is not a Roslyn analyzer; dragging a box changes configuration, not code. The responsibilities are split like this:

  • RonSijm.AnaalIJzer.ConfigurationEditing edits the configuration model.
  • Visual Studio builds graph snapshots from the active Roslyn workspace.
  • The standalone harness can open Architecture.anl, a project, a solution, or a legacy .xml input.
    • Project and solution inputs use the shared MSBuildWorkspace host.
    • The first configured project supplies the editable settings source.
    • Solution-wide code evidence is overlaid on the same diagram.

The Export PNG button is part of the shared WPF control, so it is available in both the standalone graph editor and the Visual Studio dependency-graph tool window. Tests can also call ArchitectureGraphEditorControl.ExportGraphsAsPng(...) directly for quick render smoke checks.

To regenerate a graph image for every example project, run:

build\Scripts\GraphEditor\export-example-graph-images.cmd

By default, the script writes flat PNG artifacts to build\Artifacts\ExampleGraphImages and copies each image next to its example project as <ExampleProjectName>-Graph.png. Intentionally invalid diagnostic examples get a placeholder image instead of stopping the whole export run; one deliberately broken example should not take the rest of the catalog down with it.

Use -Placement to choose where the generated images go:

build\Scripts\GraphEditor\export-example-graph-images.cmd -Placement Flat -OutputDirectory build\Artifacts\ExampleGraphImages
build\Scripts\GraphEditor\export-example-graph-images.cmd -Placement PreserveStructure -OutputDirectory build\Artifacts\ExampleGraphImages
build\Scripts\GraphEditor\export-example-graph-images.cmd -Placement SideBySide
build\Scripts\GraphEditor\export-example-graph-images.cmd -Placement All

The placement modes are:

  • Flat: one export folder containing files such as Example.IncludeSettings-Graph.png.
  • PreserveStructure: one export folder that keeps the Examples directory structure.
  • SideBySide: each image is written next to its example project.
  • FlatAndSideBySide: the default.
  • All: writes all three shapes.

Build the standalone harness locally from the repository root:

build\Scripts\GraphEditor\build-graph-editor-standalone.bat

The script writes the runnable output to build\Artifacts\GraphEditor.Standalone. You can also run the project output directly:

src\Tools\RonSijm.AnaalIJzer.GraphEditor.Standalone\bin\Release\net10.0-windows\RonSijm.AnaalIJzer.GraphEditor.Standalone.exe path\to\Architecture.anl
src\Tools\RonSijm.AnaalIJzer.GraphEditor.Standalone\bin\Release\net10.0-windows\RonSijm.AnaalIJzer.GraphEditor.Standalone.exe path\to\MySolution.slnx

Use Tools > Associate .anl files in the standalone editor to make Windows open .anl files with the graph editor. The same operation is available from the executable:

RonSijm.AnaalIJzer.GraphEditor.Standalone.exe --associate-anl
RonSijm.AnaalIJzer.GraphEditor.Standalone.exe --unassociate-anl

The GitHub build_main.yml workflow:

  • builds the Windows-only editor;
  • uploads build\Artifacts\GraphEditor.Standalone as a workflow artifact;
  • publishes AnaalIJzer-GraphEditor-Standalone-<version>.zip as a release asset;
  • replaces an existing graph-editor-v<version> release and tag before publishing the same version again.

The standalone graph editor is not shipped as a dotnet tool install package. The .NET SDK does not support PackAsTool for WPF or WindowsDesktop projects, so the packaging decision was made for us: Arse remains the command-line .NET tool while the graph editor is distributed as a Windows executable artifact and hosted inside the Visual Studio extension.

RonSijm.AnaalIJzer.GraphEditor.Wpf.Tests covers:

  • persistence from visual and inline-settings edits;
  • context menus and connector-created dependencies;
  • layout preservation and group collapse;
  • theme behavior;
  • configuration-fix previews, application, and selection-scoped filtering.

Configuration mental model

The settings are not one large list of competing rules. They answer seven different questions:

  1. What role does this type have?
  2. Is this kind of type permitted?
  3. Is this declaration visible to the right audience?
  4. Which roles may depend on which?
  5. Does namespace ownership permit this reference?
  6. Where may the dependency appear?
  7. Do important value names still mean the same thing?

The restaurant model gives those questions something concrete to talk about: namespace ownership controls which room may reach which other room, layers provide job badges, type and visibility policies check who is permitted, dependency rules say which jobs may rely on each other, and name rules stop customerId quietly turning into animalId somewhere along the way.

1. What role does this type have?

A <Layer> assigns the job badge. A type might be classified as a Customer, Waiter, Chef, or Pantry type.

Nested layers make the badge more specific. A type in Restaurant/Kitchen/Chef must obey the broad Restaurant and Kitchen boundary rules as well as the specific Chef rules. An inner boundary can add restrictions; it cannot cancel a restriction imposed by an outer boundary.

An <Exceptions> block tells one matcher to ignore a particular type. It does not grant permission to break a dependency rule.

  • Excepting TemporaryChef from <Class endsWith="Chef"> means that matcher no longer gives it the Chef badge.
  • Another matcher may still classify it.
  • If no other matcher does, the type is outside the layer graph.

This is the most common misreading in the whole configuration: an exception says "this type is not a Chef", never "this Chef is excused from the rules".

requireRecognizedDependencies lists the code sites where a dependency must receive a configured badge. Put it on the root to apply everywhere, or on a <Layer> to apply only to callers in that layer and its descendants. For example, requireRecognizedDependencies="Constructor, Local" reports ARCH_DEP_002 for unknown constructor and local-variable types. At sites not listed, unknown types remain outside the layer graph without producing ARCH_DEP_002.

2. Is this kind of type permitted?

<Allowed> and <Forbidden> are type policies. They inspect the dependency type itself, not the relationship between two layers.

  • <Allowed> is a guest list: when an allowlist applies, the dependency type must match at least one entry.
  • <Forbidden> is a deny list: a matching dependency type is rejected, even if it also appears on an allowlist.

These policies can be global or scoped to a layer. Scoped policies are inherited by nested layers, so a Restaurant/Kitchen policy also applies to Restaurant/Kitchen/Chef.

3. Is this declaration visible to the right audience?

<VisibilityPolicy> restricts whether types and members in a layer may be public, internal, private, and so on. It checks the declaration itself, not a dependency relationship. For example, a repository query surface can be required to remain internal even when the repository is allowed to use it.

<Allowed> and <VisibilityPolicy allowedAccessibilities="..."> are different allowlists: the first permits dependency types, while the second permits declared accessibilities.

4. Which roles may depend on which?

<AllowedDependency> permits one layer to depend on another. In the restaurant model, Waiter --> Chef means a Waiter type may hold or introduce a reference to a Chef type. It describes a permitted code dependency, not the runtime order in which people speak or data moves.

<BlockedDependency> explicitly denies a matching relationship. It wins over a matching allowed edge at the same boundary.

Wildcards are only shorthand for “any layer.” For example, from="*" means any source layer. A wildcard does not bypass a <Forbidden> type policy, a <BlockedDependency>, or a denial at a parent boundary. * is an abbreviation, not diplomatic immunity.

5. Does namespace ownership permit this reference?

<NamespaceHierarchyPolicy> protects a source ownership tree independently of layers. It compares source namespaces, not runtime conversation flow: Restaurant.Orders can be forbidden from reaching up into Restaurant, sideways into Restaurant.Payments, or both.

<BlockedRelation> has the same allowedSites and blockedSites attribute names as dependency edges, but its meaning is inverted because it filters a block. allowedSites="Constructor" means “block this namespace relationship only at constructors”; blockedSites="Field" means “block it everywhere except fields.”

When a namespace policy blocks a resolved reference, it produces ARCH_NS_007 before ordinary layer rules run. A namespace policy that does not block leaves the reference for normal layer analysis.

6. Where may the dependency appear?

An allowed relationship can be narrowed to particular dependency sites - the different ways one type can keep, receive, create, or expose another type.

  • Constructor means the type receives the dependency when it is created.
  • Field or Property means it keeps the dependency.
  • Local means it handles the dependency temporarily inside a method.
  • MethodReturn means it exposes the dependency to its caller.

allowedSites is a site allowlist: only the named sites are permitted. blockedSites is a site denylist: every site except the named sites is permitted. They are mutually exclusive on one dependency edge.

7. Do important value names still mean the same thing?

<NameRules> are layer-scoped semantic-name policies. They can protect primitive value movement such as customerId versus orderId, or require a declaration such as PatientId patientId to agree with its semantic type.

A NameRules policy can require names to match at selected sites, then allow narrow translations where they are intentional. For example, a Waiter layer might allow reservationCustomerId to become customerId only while constructing an order ticket, but still reject passing animalId into a customerId parameter.

Similar names, different jobs

Pair Difference
<Allowed> / <AllowedDependency> A whitelist of dependency types versus permission between layers
<Forbidden> / <BlockedDependency> A rejected dependency type versus a rejected layer relationship
<Exceptions> / allowed dependencies A matcher that ignores a type versus architectural permission to depend on a layer
allowedSites / blockedSites Only these code locations are permitted versus every code location except these
Nested layers / nested exceptions Cumulative architectural boundaries versus alternating exclusion and re-inclusion for one matcher
<AllowedDependency> / <NameRules><Allow> Permission between layers versus permission for one intentional value-name translation
<Allowed> / <VisibilityPolicy> Permitted dependency types versus permitted declaration accessibilities
<NamespaceHierarchyPolicy> / <Layer> Source-namespace ownership versus a type's architectural role
<BlockedRelation allowedSites="..."> / <AllowedDependency allowedSites="..."> Sites where a namespace block applies versus sites where a layer dependency is permitted

Rule precedence

The analyzer evaluates dependency-related rules through this pipeline. Visibility policies independently evaluate declarations after their layer is known. The numbered boxes are evaluation stages; the connector lines are deliberately not architecture dependency arrows.

flowchart TD
    NamespaceOwnership["1. Check namespace ownership<br/>NamespaceHierarchyPolicy"]
    Classify["2. Assign layer badges<br/>Apply matcher exceptions"]
    TypePolicy["3. Check type policies<br/>Forbidden, then Allowed"]
    Boundaries["4. Check every boundary<br/>Outermost to innermost"]
    Edges["5. Check dependency rules<br/>Blocked, then AllowedDependency"]
    Sites["6. Check the dependency site"]
    Names["7. Check NameRules<br/>For named value movements"]
    Result["8. Permit the code<br/>or report ARCH_*"]

    NamespaceOwnership --- Classify
    Classify --- TypePolicy
    TypePolicy --- Boundaries
    Boundaries --- Edges
    Edges --- Sites
    Sites --- Names
    Names --- Result

More precisely:

  1. Evaluate root-level <NamespaceHierarchyPolicy> rules for the resolved caller/dependency namespace relationship. The first matching block reports ARCH_NS_007 and stops ordinary layer dependency analysis for that reference.
  2. Match the caller and dependency layers, applying matcher exceptions while each rule is considered.
  3. Apply global and inherited <Forbidden> policies. A match reports ARCH_TYPE_001.
  4. Require the dependency type to pass every applicable global and inherited <Allowed> whitelist. A failure reports ARCH_TYPE_001.
  5. Evaluate hierarchical boundary gates from outermost to innermost. The first denied boundary stops evaluation; a child boundary cannot override it.
  6. At each boundary, an applicable <BlockedDependency> wins over matching allowed edges.
  7. At least one matching <AllowedDependency> must permit the current dependency site. Wildcards participate as ordinary matching edges; they receive no special power over blocks or type policies.
  8. If a dependency type does not match a layer and its current site is listed by root-level or caller-layer requireRecognizedDependencies, report ARCH_DEP_002.
  9. For named value movements inside the caller layer, apply inherited <NameRules>. A mismatch without a matching <Allow> mapping reports ARCH_NAME_008.

The important distinction is that <Allowed> cannot create an architecture edge, <AllowedDependency> cannot approve a forbidden type, <NamespaceHierarchyPolicy> does not classify a type into a layer, <Exceptions> does not create a narrow allowed edge, and <NameRules><Allow> does not permit a type dependency - it only permits one value-name translation. Each feature answers a different question. Most reports of "the analyzer ignores my rule" turn out to be a rule answering a question nobody asked.


Configuration reference

The XML root element is <ArchitecturalLevels>. It supports the child elements and attributes documented in the feature pages below. Everything here is optional: a file that declares layers and a handful of allowed dependencies is already a complete and useful configuration, and most repositories never need the rest.

Feature Doc file
Configuration fixers config-fixers.md
Include files include.md
Layers and matchers layers.md
Layer membership and physical layout layer-membership-and-layout.md
Allowed dependencies allowed-dependency.md
Blocked dependencies blocked-dependency.md
Allowed type policies allowed-type-policy.md
Forbidden type policies forbidden-type-policy.md
Matcher exceptions exceptions.md
Exception review policy exception-policy.md
Name rules name-rules.md
Visibility policies visibility-policies.md
Inheritance policies inheritance-policies.md
Contract purity contract-policies.md
Return-value policies return-value-policies.md
Namespace hierarchy policies namespace-hierarchy-policies.md
Forbidden operation policies forbidden-operation-policies.md
Behavioral operation policies behavioral-operation-policies.md
Assembly attribute policies assembly-attribute-policies.md
Generated code analysis generated-code.md
Project architecture project-architecture.md
Assembly reference policies assembly-reference-policies.md
Solution topology solution-topology.md
API surface policies api-surface.md
Transitive API exposure transitive-api-exposure.md
Boundary entry points boundary-entry-points.md
Source locations source-locations.md
Observed dependency cycles enforce-observed-acyclic.md
Required recognized dependency sites require-recognized-dependencies.md
Report output settings report-attributes.md
Documentation output settings documentation-attributes.md
Descriptions description-attributes.md

<Include>

Merges another architecture settings file into the current config. Use this when a project has a small local config but shares layer definitions or common edges from another file. The top-level config can be either Architecture.anl or AssemblyMetadata("AnaalIJzerSettings", ...); included settings files must still be passed to Roslyn as AdditionalFiles.

Example projects: Example.IncludeSettings, Example.IncludeWildcardSettings

<details> <summary>Dependency graph</summary>

<img src="Examples/Features/Example.IncludeSettings/Example.IncludeSettings-Graph.png" alt="Example.IncludeSettings dependency graph">

</details>

Rule: The project file can keep project-specific edges while the included file owns shared layers and shared edges. The included settings file must also be passed to Roslyn as an AdditionalFile.

flowchart LR
    ProjectConfig["Architecture.anl<br/>Presentation -> Application"] --> SharedConfig["SharedApplicationLayers.anl<br/>layers + Application -> Persistence"]
    Presentation --> Application --> Persistence
    Presentation -. "bad: skips Application" .-> Persistence

<ArchitecturalLevels>
  <Include path="SharedApplicationLayers.anl" />

  <AllowedDependency from="Presentation" to="Application" />
</ArchitecturalLevels>

<ArchitecturalLevels>
  <Layer name="Presentation">
    <Class endsWith="Endpoint" />
  </Layer>

  <Layer name="Application">
    <Class endsWith="Service" />
  </Layer>

  <Layer name="Persistence">
    <Class endsWith="Repository" />
  </Layer>

  <AllowedDependency from="Application" to="Persistence" />
</ArchitecturalLevels>
// Presentation -> Application is declared by the project settings.
public class OrderEndpoint(IOrderService service) { }

// Application -> Persistence comes from the included shared settings.
public class OrderService(IOrderRepository repository) { }

// ARCH_DEP_001: Presentation -> Persistence has no AllowedDependency edge.
public class AdminEndpoint(IOrderRepository repository) { }

path is resolved relative to the settings file that declares the include. Included files can include other files; files already seen during the current parse are skipped so accidental cycles do not loop forever. Two rule files that include each other is a rite of passage, not a reason for the build to hang.

Wildcard patterns are also supported. A bare file-name wildcard such as <Include path="*.anl" /> loads every visible .anl file that was passed to the analyzer as an AdditionalFile, so a project can keep drop-in rule packs in a local folder. A path wildcard such as <Include path="RulePlugins/*.anl" /> is resolved relative to the declaring config file. The wildcard only sees what MSBuild handed to Roslyn, so an unregistered rule pack is invisible rather than merely ignored.

<ArchitecturalLevels>
  <Include path="*.anl" />
</ArchitecturalLevels>

By default, a wildcard that matches nothing reports ARCH_CONF_003. That catches misspelled paths and forgotten MSBuild registration. For a deliberately optional drop-in folder, use allowNoMatches="true":

<ArchitecturalLevels>
  <Include path="OptionalRules/*.anl" allowNoMatches="true" />
</ArchitecturalLevels>

This opt-out belongs to wildcard includes only. <Include path="MissingRules.anl" allowNoMatches="true" /> still reports the missing exact file, because silently accepting a misspelled explicit filename would hide a broken configuration.

Boolean values follow XML Schema rules, so true, false, 1, and 0 are valid. The default is false.

That is most useful when the project or solution registers a rule-pack folder, for example:

<ItemGroup>
  <AdditionalFiles Include="RulePlugins\**\*.anl" />
</ItemGroup>

Root attributes such as requireRecognizedDependencies, enforceAcyclic, enableReport and enableDocumentation are honored from included files. Root site lists from included files are combined. Layer-scoped requireRecognizedDependencies attributes remain on the layer elements that declare them. Report and documentation paths are resolved relative to the file that enables them.

<Layer>

Defines a named group of types. The name attribute is referenced by <AllowedDependency> edges.

Layers are logical roles, not project or folder labels. Architecture.anl deliberately owns layer membership; Layer membership and physical layout explains the boundary and points to the focused features for source folders and project references.

<Layer name="Application">
  <Class endsWith="Manager" />
  <Class startsWith="App" />
  <Class contains="Service" />
  <Namespace endsWith="Application" />
  <Assembly exactName="MyCompany.Application" />
</Layer>

Each <Class>, <Namespace>, or <Assembly> child is a matcher:

  • attributes on one element are combined with AND;
  • separate matcher elements are alternatives combined with OR;
  • a type enters the layer when every condition on any one matcher succeeds;
  • exact class-name matchers take precedence;
  • remaining matchers are evaluated in configuration order.

That last point means the order of your alternatives is a decision whether or not you meant to make one.

For <Class>, you can also add inner declaration matchers when the type itself is not enough and you want to describe a recognizable shape:

<Layer name="PizzaProviderRequests">
  <Class endsWith="Request">
    <Property exactName="PizzaId" typeName="PizzaId" />
    <Field exactName="_tenantId" typeName="TenantId" />
  </Class>
</Layer>

That means:

  • the outer <Class> still matches the type itself;
  • each inner declaration element must be satisfied by at least one owned declaration of that kind;
  • sibling declaration elements are combined with AND.

A <Layer> may also set requireRecognizedDependencies. That requirement applies only to callers classified into that layer or one of its nested layers:

<Layer name="AuditedKitchen" requireRecognizedDependencies="Constructor">
  <Class endsWith="AuditedChef" />
</Layer>

Use this when a legacy codebase is partly undefined, but one module or boundary should already require every constructor dependency to be classified.

Hierarchical layer boundaries

A layer can contain nested layers and dependency rules. Parent matchers define the scope in which child matchers are evaluated:

<Layer name="Ordering">
  <Namespace startsWith="ExampleCompany.Ordering" />

  <Layer name="Application">
    <Class endsWith="Service" />
  </Layer>

  <Layer name="Repository">
    <Class endsWith="Repository" />
  </Layer>

  <AllowedDependency from="Application" to="Repository" />
</Layer>

ExampleCompany.Ordering.PlaceOrderService belongs to Ordering/Application: it must match both the parent namespace and the child class matcher. A type inside ExampleCompany.Ordering that matches no child belongs directly to Ordering. A parent with nested layers may omit its own matcher; in that case its membership is the union of its descendants.

Names are local to their parent, so Ordering/Application and Billing/Application can coexist. Sibling names must be unique, and an individual name cannot contain /. Rules inside a boundary use local child names. Root-qualified paths start with /:

<Layer name="Ordering">
  
  <AllowedDependency from="Application" to="/Billing/Contracts" />
</Layer>

<Layer name="Billing">
  
  <AllowedDependency from="/Ordering/Application" to="Contracts" />
</Layer>


<AllowedDependency from="Ordering" to="Billing" />

A cross-boundary dependency must pass every applicable gate. In this example, Ordering/Application -> Billing/Contracts requires all three rules: the root Ordering -> Billing relationship, the Ordering egress rule, and the Billing ingress rule. Inner rules may narrow outer permissions but cannot bypass them: a nested boundary does not get to vote itself out of its parent's rules. Site filters are evaluated independently at every gate.

For framework-like or crosscutting layers, mark a higher-level edge with appliesToDescendants="true" when that one rule should satisfy descendant boundary gates too:

<AllowedDependency from="*" to="Framework" appliesToDescendants="true" />

Use this for intentionally ambient dependencies. Keep local egress and ingress rules for business boundaries where each parent module should decide what its children may reach. Declaring everything ambient is the fastest route to a single enormous layer named after the company.

References to a parent select its entire subtree. Shared ancestry is containment rather than a same-layer dependency: Ordering/Application -> Ordering/Repository is checked by the rule inside Ordering and does not produce ARCH_DEP_005 merely because both types also belong to Ordering. ARCH_DEP_005 applies when both types have the same deepest effective layer.

Example project: Example.NestedLayers

<details> <summary>Dependency graph</summary>

<img src="Examples/Features/Example.NestedLayers/Example.NestedLayers-Graph.png" alt="Example.NestedLayers dependency graph">

</details>

Matcher types

Name-based matchers (case-sensitive, no compilation required):

Element Attribute Description
<Class> typeName Type name equals the given string (synonym: exactName)
<Class> exactName Type name equals the given string (synonym: typeName)
<Class> exactFullName Fully-qualified type name (Namespace.TypeName) equals the given string
<Class> endsWith Type name ends with the given string
<Class> startsWith Type name starts with the given string
<Class> contains Type name contains the given string
<Class> regex Type name matches the given .NET regular expression
<Namespace> exactName Namespace equals the given string
<Namespace> endsWith Namespace ends with the given string
<Namespace> startsWith Namespace starts with the given string
<Namespace> contains Namespace contains the given string
<Namespace> regex Namespace string matches the given .NET regular expression
<Assembly> exactName Containing assembly name equals the given string
<Assembly> endsWith Containing assembly name ends with the given string
<Assembly> startsWith Containing assembly name starts with the given string
<Assembly> contains Containing assembly name contains the given string
<Assembly> regex Containing assembly name matches the given .NET regular expression

Semantic matchers (<Class> only, evaluated against the resolved type symbol):

Attribute Description
inherits Type whose base-type chain contains a type with the given simple or full name (e.g. inherits="ControllerBase")
implements Type that implements (transitively) an interface with the given simple or full name
withAttribute Type decorated with the given attribute. The Attribute suffix is optional (withAttribute="ApiController" ≡ "ApiControllerAttribute")
withAccessModifier Type declared with the given modifier(s). Supported tokens (case-insensitive): public, internal, private, protected, sealed, abstract, static, record. Multiple space-separated tokens require all to match (e.g. withAccessModifier="public sealed")
typeKind Type has the given declared kind. Supported case-insensitive values are listed below. May be used alone.

typeKind uses declaration-oriented values:

Value Matches
Class Ordinary classes, excluding record classes
Interface Interfaces
Struct Ordinary structs, excluding record structs
Record Record classes
RecordStruct Record structs
Enum Enums
Delegate Delegates

One or more matcher attributes are allowed per element. Every attribute on that element must match:

<Class startsWith="I"
       endsWith="Repository"
       typeKind="Interface" />

<Namespace startsWith="MyCompany."
           endsWith=".Persistence" />

<Assembly startsWith="MyCompany."
          endsWith=".Contracts" />

The first rule matches only interfaces whose names start with I and end with Repository. To express alternatives, add another <Class> element. Missing matchers, unsupported attributes, unknown typeKind values, and invalid regular expressions report ARCH_CONF_003.

Structural declaration matchers

Inner declaration elements on <Class> reuse the same matcher attributes, but they split the meaning slightly:

  • name-style attributes such as exactName, startsWith, endsWith, contains, and regex apply to the declaration name;
  • semantic type attributes such as typeName, exactFullName, inherits, implements, and typeKind apply to that declaration's associated type;
  • withAttribute and withAccessModifier apply to the declaration symbol itself.

Supported declaration matcher elements are:

Element Matches
<Type> The type declaration itself
<NestedType> A nested class, interface, struct, record, enum, or delegate
<Constructor> An explicit instance or static constructor
<Method> An ordinary method or explicit interface implementation
<Property> A property or indexer
<Field> A field
<Event> An event
<Operator> A user-defined operator
<Conversion> An implicit or explicit conversion operator

Nested declaration matchers support structural "shape" rules with the same matcher vocabulary:

<Class endsWith="Request">
  <Property exactName="PizzaId" typeName="PizzaId" />
</Class>

That matches request types that own a PizzaId property of type PizzaId. It does not match requests that only have DrinkId, and it does not match requests that expose PizzaId through a differently named property.

String matches are case-sensitive and applied to the full declared name (so IOrderRepository matches endsWith="Repository"). A matcher written as endsWith="repository" matches nothing and complains about nothing, which costs a lively half hour to discover. regex uses Regex.IsMatch semantics, so it matches anywhere in the subject unless the pattern is anchored with ^ / $; invalid patterns report ARCH_CONF_003. Patterns are compiled once and cached, so the cost is paid only on first use.

Example projects: Example.AssemblyMatcher, Example.CombinedMatchers, Example.StructuralDeclarationMatchers

<Layer name="Controllers">
  <Class inherits="ControllerBase" />
  <Class withAttribute="ApiController" />
</Layer>

<Layer name="DomainEvents">
  <Class implements="IDomainEvent" />
</Layer>

<Layer name="PublicApi">
  <Class withAccessModifier="public sealed" />
</Layer>

<Layer name="Handlers">
  
  <Class regex="^I[A-Z][A-Za-z0-9]*Handler$" />
</Layer>

<Forbidden>
  <Class exactFullName="System.Console" comment="Use ILogger." />
  <Namespace regex="\.Internal(\.|$)" comment="Don't reach into *.Internal namespaces." />
</Forbidden>

Matchers are also applied to the generic type arguments of a parameter, recursively. A parameter typed Lazy<IChef> is therefore evaluated as both Lazy and IChef. If the Customer layer may depend on Waiter but not Chef, the wrapper does not hide the Chef dependency. This works for arbitrary wrappers (Lazy<>, Func<>, IEnumerable<>, Task<>, ...) and any user-defined generic.

Example project: Example.Arch_DEP_001.GenericTypeArgument

Rule: Generic type arguments are inspected. Wrapping a forbidden dependency in Lazy<>, IEnumerable<>, Func<>, … does not hide it from the analyzer.

flowchart LR
    Customer --> Waiter --> Chef
    Customer -. "bad: wrapped Chef is still Chef" .-> Chef
<AllowedDependency from="Customer" to="Waiter" />
<AllowedDependency from="Waiter" to="Chef" />

// Customer -> Waiter is allowed.
public class HungryCustomer(IWaiter waiter) { }

// ARCH_DEP_001: Lazy<IChef> still contains an IChef dependency.
// Asking for a chef later is still asking for a chef.
public class PatientCustomer(Lazy<IChef> chef) { }

// ARCH_DEP_001: IEnumerable<IChef> still contains IChef dependencies.
// A group of chefs is not a waiter.
public class GroupCustomer(IEnumerable<IChef> chefs) { }

// ARCH_DEP_001: Func<IChef> still contains an IChef dependency.
// A promise to find a chef later does not change the boundary.
public class FutureCustomer(Func<IChef> chefFactory) { }

Layer Membership and Physical Layout

Layers describe a type's architectural role. They are deliberately defined by Architecture.anl, using logical matchers such as <Class>, <Namespace>, and <Assembly>.

<Layer name="Application">
  <Class endsWith="Service" />
  <Namespace endsWith=".Application" />
</Layer>

That rule says what a type is. It does not say where the type happens to live today.

The design boundary

AnaalIJzer intentionally does not assign a layer from:

  • a .csproj name or project path;
  • a source folder, including its child folders;
  • a solution-folder entry;
  • a project property such as AnaalIJzerProjectLayer;
  • an attribute, .editorconfig entry, package reference, or observed call graph.

The .anl configuration is the single source of truth for layer definitions and membership. A type has one canonical layer path, such as Ordering/Application; it is not a collection of unrelated physical labels.

This keeps dependency diagnostics understandable. A Chef remains a Chef after a project rename or a source-file move. The architecture should not silently change because someone reorganized folders during spring cleaning.

What was considered

Project and folder membership selectors

One possible design was to allow rules such as these:


<Layer name="Tools">
  <Project exactName="MyCompany.Tools" />
</Layer>

<Layer name="Shared">
  <Folder path="src/Shared" includeDescendants="true" />
</Layer>

This is convenient when a repository currently mirrors its architecture in projects or folders. It becomes misleading when one project contains contracts, application code, infrastructure, and several features, or when a folder is reorganized without intending to rewrite dependency policy. It also makes physical build layout compete with logical type matching for ownership of a layer.

A project-declared layer property

Another option was a project-side declaration:


<PropertyGroup>
  <AnaalIJzerProjectLayer>/Ordering</AnaalIJzerProjectLayer>
</PropertyGroup>

That can be attractive for reusable rule packs and Directory.Build.props inheritance. It was rejected because it lets a project participate in defining its own classification, splits the architecture across .anl and MSBuild files, and needs precedence rules when the property disagrees with the configuration. A project property is useful build metadata, but it is not the authority on whether a type is a waiter, chef, or pantry worker.

Solution folders and inferred membership

Solution folders are IDE organization rather than compiler input, so they are not reliable during command-line or design-time compilation. Package references, inheritance, call graphs, and method bodies are also poor membership sources: they can change as an implementation detail and would make a type's layer move unexpectedly.

Use the focused feature instead

Need Use
Define a type's architectural role <Layer> matchers such as <Class>, <Namespace>, and <Assembly>
Add a broader logical boundary with more specific child roles Nested <Layer> elements
Require an already-classified type to live in a project or folder <SourceLocations>
Govern project-to-project references Project architecture
Share one configuration across a directory of projects Directory.Build.props plus one Architecture.anl
Add or remove a rule pack <Include> and explicit AdditionalFiles registration

For example, use a namespace matcher to decide that a type is part of Ordering, then use <SourceLocations> to require Ordering code to remain in the Ordering/ folder. The first answers "what role does this type have?" The second answers "is that role stored in the right place?"

Reconsidering the boundary

This is an intentional constraint, not a claim that physical structure never matters. Revisit it only when a concrete architecture cannot be expressed with logical matchers, nested layers, source-location policies, and project-architecture policies together. Any future proposal should preserve one canonical layer path, make its source visible in diagnostics and tooling, and avoid silently changing architecture when files or projects move.

<AllowedDependency>

Declares that types in layer from are permitted to depend on types in layer to. Any dependency not covered by an explicit edge or the * wildcard is a layering violation. See ARCH_DEP_001/ARCH_DEP_004/ARCH_DEP_005 for how the three reasons are distinguished. The default answer is "no"; permission has to be written down somewhere other than a team's collective memory.

<AllowedDependency from="Presentation" to="Application" />
<AllowedDependency from="Application" to="Persistence" />

Use from="*" to allow a layer to be depended on from any other layer (useful for cross-cutting concerns):

<Layer name="Crosscutting">
  <Class typeName="IIdentityContext" />
</Layer>

<AllowedDependency from="*" to="Crosscutting" />

With nested layers, a root wildcard still respects inner boundary gates by default. Add appliesToDescendants="true" when the edge is intentionally ambient, such as framework primitives or a crosscutting abstraction that every nested boundary may use:

<Layer name="Framework">
  <Class typeName="Task" />
  <Class typeName="Nullable" />
  <Class typeName="CancellationToken" />
</Layer>

<AllowedDependency from="*" to="Framework" appliesToDescendants="true" />

Example project: Example.CascadingDependencyRules

<details> <summary>Dependency graph</summary>

<img src="Examples/Features/Example.CascadingDependencyRules/Example.CascadingDependencyRules-Graph.png" alt="Example.CascadingDependencyRules dependency graph">

</details>

Use to="*" for the symmetric case - a single layer that is allowed to depend on every other configured layer. Typical example: a diagnostics / health-check layer that needs to read state from every part of the system:

<Layer name="Diagnostics">
  <Class endsWith="Diagnostics" />
</Layer>

<AllowedDependency from="Diagnostics" to="*" />

from="*" to="*" is also accepted and means "every configured layer may depend on every other configured layer". Nested boundary gates still require local rules unless the edge sets appliesToDescendants="true". <Forbidden> types are still rejected, and unknown types at sites required by root-level or caller-layer requireRecognizedDependencies still report ARCH_DEP_002. It is a legal configuration; it has simply stopped describing an architecture and started describing a pile.

<BlockedDependency>

Explicitly denies an edge even when a broader wildcard allowance would otherwise permit it. Blocked rules take precedence over every matching <AllowedDependency>, which lets a wildcard stay a broad convenience with named exceptions instead of becoming a loophole.

<AllowedDependency from="*" to="Persistence" />
<BlockedDependency from="Presentation" to="Persistence"
                   description="Presentation types must go through Application services." />

Both dependency elements support appliesToDescendants and the same site filters. On a blocked rule, appliesToDescendants="true" cascades the denial into descendant boundary gates, and the filter scopes where the block applies:

<BlockedDependency from="Application" to="QuerySurface"
                   allowedSites="Field, Property, MethodReturn" />

Example project: Example.BlockedDependency

Site filters

By default, a dependency rule applies to every dependency site. Add allowedSites to scope it to specific sites, or blockedSites to apply it everywhere except the listed sites:

<AllowedDependency from="Waiter" to="PreparedDish" allowedSites="MethodReturn, Local" />
<AllowedDependency from="Chef" to="Ingredient" blockedSites="MethodReturn" />

The attributes are mutually exclusive. Site names are comma-separated, trimmed, and case-insensitive. Unknown site names or a rule that declares both attributes report ARCH_CONF_003 and are ignored fail-closed - a typo in a site list should never widen a rule by accident.

Site filters also apply to wildcard edges such as from="*" and to="*".

Arrows still mean "may depend on"; the edge label narrows where that dependency may appear. Here a Waiter may briefly hold or return a PreparedDish, while a Chef may use an Ingredient everywhere except as a method return type. That prevents a Chef API from handing raw ingredients to callers without forbidding ingredients inside the kitchen.

flowchart LR
    Waiter -->|"MethodReturn, Local only"| PreparedDish
    Chef -->|"all except MethodReturn"| Ingredient
    Customer -. "no dependency edge" .-> Ingredient
Site What it means Example shape
Constructor Constructor parameter, including primary constructors public Caller(DependencyType dependency) { }
Method Non-constructor method parameter public void Run(DependencyType dependency) { }
MethodReturn Non-constructor method return type public DependencyType Get() => ...;
Field Field declaration private readonly DependencyType _dependency;
Property Property declaration public DependencyType Dependency { get; set; }
Local Local variable declaration DependencyType dependency = ...;
New Object creation expression new DependencyType() or target-typed new()
GenericInvocation Generic method invocation type argument services.GetRequiredService<DependencyType>()
GenericArgument Generic type argument inside another referenced type Lazy<DependencyType> or IEnumerable<DependencyType>
Inheritance Base class inheritance, or interface-to-interface inheritance class Caller : DependencyBase
InterfaceImplementation Class, record, or struct implements an interface class Caller : IDependency
Attribute Attribute applied within a layered type [DependencyMarker] class Caller
StaticMember Static method, property, field, or event access DependencyType.Load()

GenericArgument is reported for the inner type rather than the outer wrapper. For example, Lazy<DependencyType> in a constructor is reported as Site=GenericArgument, because the architectural dependency is DependencyType, not Lazy<T>.

Example project: Example.AllowedSites

Repository query surfaces

Site filters are useful when one layer owns a type that other layers may touch only as a short-lived access point. A repository query surface is a good example: OrderRepository may create and return OrderQuery, and OrderQuery may project itself to OrderProjection, but the Application layer should not expose OrderQuery in its own API or keep it around for application logic.

<ArchitecturalLevels>
  <Layer name="Application"><Class endsWith="Service" /></Layer>
  <Layer name="Persistence"><Class endsWith="Repository" /></Layer>
  <Layer name="QuerySurface"><Class endsWith="Query" /></Layer>
  <Layer name="Projection"><Class endsWith="Projection" /></Layer>

  <AllowedDependency from="Application" to="Persistence" />
  <AllowedDependency from="Application" to="Projection" />
  <AllowedDependency from="Persistence" to="QuerySurface" allowedSites="MethodReturn, New" />
  <AllowedDependency from="QuerySurface" to="Projection" />
</ArchitecturalLevels>
// The service never names OrderQuery; it immediately projects the repository-owned chain.
public OrderProjection GetOrder()
    => repository.QueryOrders().ForCurrentCustomer().Project();

// ARCH_DEP_001, Site=Local: application logic now retains a raw query surface.
public OrderProjection GetOrderThroughLocalQuery()
{
    OrderQuery query = repository.QueryOrders();
    return query.Project();
}

// ARCH_DEP_001, Site=MethodReturn: the raw query surface leaks outside the service API.
public OrderQuery LeakQuery() => repository.QueryOrders();

The point is not that OrderQuery is forbidden everywhere. Persistence owns it, and the query surface can expose projection methods. The rule is that higher layers should carry projected objects, such as OrderProjection, instead of carrying persistence internals across method boundaries. The service that only holds a query surface briefly is usually the same service that returns one two releases later.

Example project: Example.RepositoryQuerySurface

<details> <summary>Dependency graph</summary>

<img src="Examples/Scenarios/Example.RepositoryQuerySurface/Example.RepositoryQuerySurface-Graph.png" alt="Example.RepositoryQuerySurface dependency graph">

</details>

Example project: Example.WildcardTo

Rule: <AllowedDependency from="Diagnostics" to="*" /> lets the Diagnostics layer depend on every other configured layer without listing each edge explicitly. The project builds clean - it demonstrates the absence of diagnostics that would otherwise fire.

flowchart LR
    Application --> Persistence
    Diagnostics --> Application
    Diagnostics --> Persistence
<AllowedDependency from="Application" to="Persistence" />
<AllowedDependency from="Diagnostics"  to="*" />
// Diagnostics -> Application and Diagnostics -> Persistence are allowed by to="*".
public class ArchitectureDiagnostics(IOrderService service, IOrderRepository repository) { }

<Allowed> type policy

<Allowed> is a whitelist for dependency types. A dependency assigned to a configured layer must match at least one <Class> or <Namespace> matcher in every applicable allow-list; otherwise the analyzer reports ARCH_TYPE_001.

At the root, the allow-list applies to every dependency that belongs to a configured layer:

<Allowed>
  <Class startsWith="Create" />
  <Class startsWith="Cancel" />
</Allowed>

This is useful when an architecture permits only a small vocabulary, such as command verbs. Matchers within one scope are alternatives, so the example accepts both CreateOrderCommand and CancelOrderCommand but rejects ProcessOrderCommand. Be reasonably sure the vocabulary is closed before switching this on, because every future verb has to be negotiated through this list.

public class CreateOrderCommand { }
public class CancelOrderCommand { }

// ARCH_TYPE_001: Process is not in the approved global verb list.
public class ProcessOrderCommand { }
public class WorkflowService(ProcessOrderCommand command) { }

The policy is checked when a layered type uses the dependency. It does not report on an otherwise unused type declaration.

Example project: Example.AllowedTypes

Layer-scoped type policies

Place <Allowed> or <Forbidden> inside a <Layer> to restrict the policy to dependencies classified into that layer and its descendants:

<Layer name="Command">
  <Class endsWith="Command" />
  <Allowed>
    <Class startsWith="Create" />
    <Class startsWith="Cancel" />
  </Allowed>
</Layer>

<Layer name="Query">
  <Class endsWith="Query" />
  <Forbidden>
    <Class startsWith="Delete" />
  </Forbidden>
</Layer>

ProcessOrderCommand fails the Command allow-list, while DeleteOrderQuery matches the Query block-list. A type named DeleteOrderAuditRecord in an Audit layer is unaffected: the Query policy does not leak into sibling layers.

Nested policies are cumulative. A dependency in Ordering/Command must satisfy allow-lists declared on both Ordering and Ordering/Command. Any matching forbidden rule denies the dependency, even when an allow-list also matches it: denial wins, and there is no appeals procedure.

Example project: Example.ScopedTypePolicies

<Forbidden>

Marks type patterns as explicitly disallowed. A root <Forbidden> policy applies globally; one nested inside a layer applies only to that layer and its descendants. When a dependency type matches an applicable forbidden pattern the analyzer reports ARCH_TYPE_001 regardless of which layer the caller belongs to. An optional <Fix Rename="…"> child element provides an automatic rename code-fix in Visual Studio / Rider.

Fill in the comment attribute. A rule that records why Store lost to Repository gets re-litigated far less often than one that simply refuses.

<Forbidden>
  <Class endsWith="Store" comment="Persistence types must use the Repository suffix.">
    <Fix Rename="Repository" />
  </Class>
</Forbidden>

Example project: Example.Arch_TYPE_001.ForbiddenType

Rule: Types ending in Store are explicitly forbidden. The <Fix Rename="Repository"> element offers an automatic rename code-fix in Visual Studio.

flowchart LR
    Application --> Persistence["Persistence<br/>OrderRepository"]
    Application -. "bad: forbidden Store name" .-> Store["Forbidden<br/>OrderStore"]
<Forbidden>
  <Class endsWith="Store" comment="Persistence types must use the Repository suffix.">
    <Fix Rename="Repository" />
  </Class>
</Forbidden>
// Repository is the required persistence suffix.
public class OrderService(OrderRepository repository) { }

// ARCH_TYPE_001: Store is forbidden; use Repository instead.
public class OrderStore { }
public class OrderManager(OrderStore store) { }

<Exceptions>

Every <Class> and <Namespace> matcher can have a nested <Exceptions> block. That includes matchers inside <Layer>, <Allowed>, and <Forbidden>.

An exception can use the same matcher vocabulary as the rule it narrows:

  • several attributes on one matcher, combined with AND;
  • typeKind;
  • inherits, implements, withAttribute, and withAccessModifier;
  • regex and the normal text matchers.

When a dependency matches a rule and matches any of that rule's exceptions, the rule is skipped and evaluation continues with the next rule in document order. The rename code fix is also suppressed for excepted types - if a type is allowed, the IDE will not nag it with a rename suggestion.

<Forbidden>
  <Class endsWith="Store" comment="Persistence types must use the Repository suffix.">
    <Fix Rename="Repository" />
    <Exceptions>
      
      <Class startsWith="Legacy" />
      <Class typeName="ThirdPartyOrderStore" />
    </Exceptions>
  </Class>
</Forbidden>

<Layer name="Repository">
  <Class endsWith="Repository">
    <Exceptions>
      
      <Class typeName="InMemoryFakeOrderRepository" />
    </Exceptions>
  </Class>
</Layer>

The intent is the ratchet pattern:

  • lock current violations into a named baseline;
  • block new offenders immediately;
  • remove the old exceptions at whatever pace the codebase permits.

Plain exceptions do not track when they were added, expire themselves, or report reminders. If you need that, use <ExceptionPolicy>. A carve-out can be simple or accountable; it should not pretend to be both.

Example project: Example.Exceptions

Rule: <Exceptions> grandfathers pre-existing offenders into the baseline. The exception in <Forbidden> exempts every type starting with Legacy; the exception inside <Layer name="Repository"> exempts InMemoryFakeOrderRepository by exact name.

flowchart LR
    Application --> Repository["Repository<br/>OrderRepository"]
    Application --> Legacy["LegacyOrderStore<br/>excepted old name"]
    Application -. "bad: new Store is blocked" .-> Store["OrderStore<br/>forbidden name"]
<Forbidden>
  <Class endsWith="Store" comment="Persistence types must use the Repository suffix.">
    <Fix Rename="Repository" />
    <Exceptions>
      <Class startsWith="Legacy" />
    </Exceptions>
  </Class>
</Forbidden>

<Layer name="Repository">
  <Class endsWith="Repository">
    <Exceptions>
      <Class typeName="InMemoryFakeOrderRepository" />
    </Exceptions>
  </Class>
</Layer>
// Legacy Store is exempted by <Class startsWith="Legacy">.
public class LegacyOrderStore { }
public class OrderHistoryManager(LegacyOrderStore store) { }

// ARCH_TYPE_001: OrderStore still triggers the rule; the carve-out is scoped.
public class OrderStore { }
public class OrderManager(OrderStore store) { }
When to reach for <Exceptions>
  • Introducing the analyzer to an existing codebase. Enable the complete rules and add current offenders to <Exceptions> with the IDE code fix. The build stays green, but every new violation fails CI. Burn the list down at whatever pace fits the team. There is no migration milestone you have to hit, although a list that has not shrunk in a year is making a statement about priorities all by itself.
  • Intentional architectural carve-outs. One diagnostics or bootstrap module legitimately needs to see a type the rest of the codebase shouldn't. Excepting it scoped to that one type keeps the rule active everywhere else.
  • Types that only look like a match. This includes third-party types you cannot rename, generated code, framework conventions, and test doubles (InMemoryFakeOrderRepository looks like a Repository but is not one).
Why <Exceptions> and not something like <Baseline>?

<Baseline> would imply that every exemption represents legacy debt. In practice exceptions are also used for vendor types, framework conventions, generated code, and intentional carve-outs. <Exceptions> is neutral about why something is excepted and leaves the policy to the team. Use an XML comment to record the reason, or <ExceptionPolicy> when ownership and expiry must be enforced.

Code fix

Forbidden-rule diagnostics with an originating matcher register an "Add 'TypeName' to exceptions" code action that appends the offending type to that matcher's <Exceptions> block, creating the block if needed. This works for both Architecture.anl and inline AssemblyMetadata("AnaalIJzerSettings", ...), and if the matcher came from an included file the fix edits that owning file instead of the top-level one.

Allow-list failures are different: there is no single matcher to except, so the IDE offers an allow-list fixer instead that adds an exact <Class typeName="..."/> matcher to every applicable <Allowed> list. ARCH_DEP_002 also has no exception action; it offers layer classification or requireRecognizedDependencies relaxation because that is what actually resolves the finding.

Nesting

Exceptions can be nested. Each deeper matching exception level flips the previous result: depth 1 excludes the type from the rule, depth 2 includes it again, depth 3 excludes it again, and so on. The algorithm finds the deepest level at which the type matches and uses that depth's parity to decide the outcome — so inner exceptions should use patterns that are logical subsets of their parent, making it clear which types each level applies to.

Example project: Example.NestedExceptions

Rule: four overlapping patterns form a specificity hierarchy, each a logical subset of its parent. The deepest matching depth for each type determines its membership (odd = excluded, even = included):

Type Deepest match Depth Result
InMemoryOrderRepository startsWith="InMemory" 1 (odd) Not in Persistence
InMemoryCachedOrderRepository startsWith="InMemoryCached" 2 (even) In Persistence, ARCH_DEP_001
InMemoryCachedTestOrderRepository exact type name 3 (odd) Not in Persistence
LegacyInMemoryCachedOrderRepository exact type name 4 (even) In Persistence, ARCH_DEP_001
<Layer name="Persistence">
  <Class endsWith="Repository">
    <Exceptions>
      <Class startsWith="InMemory">
        <Exceptions>
          <Class startsWith="InMemoryCached">
            <Exceptions>
              <Class typeName="InMemoryCachedTestOrderRepository">
                <Exceptions>
                  <Class typeName="LegacyInMemoryCachedOrderRepository" />
                </Exceptions>
              </Class>
            </Exceptions>
          </Class>
        </Exceptions>
      </Class>
    </Exceptions>
  </Class>
</Layer>
// Depth 1 (odd): not in Persistence.
public class OrderEndpoint(InMemoryOrderRepository repository) { }

// ARCH_DEP_001: Depth 2 (even): in Persistence.
public class AdminEndpoint(InMemoryCachedOrderRepository repository) { }

// Depth 3 (odd): not in Persistence again.
public class TestEndpoint(InMemoryCachedTestOrderRepository repository) { }

// ARCH_DEP_001: Depth 4 (even): in Persistence again.
public class LegacyEndpoint(LegacyInMemoryCachedOrderRepository repository) { }

ExceptionPolicy

<ExceptionPolicy> makes matcher exceptions temporary and reviewable. A carve-out that nothing ever asks about again is not temporary; it is permanent with better marketing.

Without it, <Exceptions> keep their existing behavior:

  • no metadata is required;
  • no warning is reported;
  • exceptions stay active until someone edits the config.

With it, you can require metadata on every matcher directly inside an <Exceptions> block:

<ArchitecturalLevels>
  <ExceptionPolicy requireReason="true"
                   requireOwner="true"
                   requireExpiresOn="true"
                   warnBeforeDays="14" />

  <Layer name="Application">
    <Class endsWith="Manager">
      <Exceptions>
        <Class typeName="LegacyManager"
               reason="Migration tracked in ORDERING-142"
               owner="Ordering Team"
               expiresOn="2026-10-31" />
      </Exceptions>
    </Class>
  </Layer>
</ArchitecturalLevels>

Supported attributes:

Attribute Default Meaning
requireReason false Require a non-empty reason attribute on exception matchers
requireOwner false Require a non-empty owner attribute on exception matchers
requireExpiresOn false Require an expiresOn="yyyy-MM-dd" attribute on exception matchers
warnBeforeDays 14 Emit ARCH_EXC_009 when an exception expires within this many days

Behavior:

  • Missing required metadata reports ARCH_EXC_009.
  • Invalid expiresOn reports ARCH_EXC_009.
  • Expired exceptions report ARCH_EXC_009 and fail closed.
  • Expiring-soon exceptions report ARCH_EXC_009 but remain active.
  • Stale exceptions are reported by Arse health inspection, not by normal project compilation.

See also:

<NameRules>

NameRules are layer-scoped semantic-name policies. They do not create layer dependencies. They can check either a named value moving into a differently named target or a declaration identifier that disagrees with its own semantic type.

Use this when primitive values are still necessary, but you want some of the protection people often get from "honest types". To the compiler one int is exactly as meaningful as any other int, which is why swapped id arguments pass review so comfortably and reappear later as a production incident:

<Layer name="Application">
  <Class endsWith="Service" />

  <NameRules>
    <RequireMatchingNames>
      <Name endsWith="Id" />
      <Allow from="legacyCustomerId" to="customerId" allowedSites="Constructor" />
    </RequireMatchingNames>
  </NameRules>
</Layer>

RequireMatchingNames above says:

Element Meaning
<Name endsWith="Id" /> Check source or target names ending with Id.
<Allow from="legacyCustomerId" to="customerId" /> This intentional rename is allowed.
allowedSites="Constructor" The rename is allowed only when calling a constructor.

The analyzer normalizes names before comparing them. For example, customerId and Customer.Id are treated as the same meaning. fruitId and animalId are not.

// Valid: customerId normalizes to Customer.Id.
customer.Id = customerId;

// ARCH_NAME_008: animalId does not mean Customer.Id.
customer.Id = animalId;

// ARCH_NAME_008: arguments are swapped.
Log(animalId, fruitId);

void Log(int fruitId, int animalId) { }
Matchers

Name, Source, and Target use the same matcher attributes and AND/OR behavior as layer <Class> matchers:

<RequireMatchingNames>
  <Name startsWith="customer" endsWith="Id" />
</RequireMatchingNames>

<RequireMatchingNames>
  <Source endsWith="RowId" />
  <Target endsWith="Id" />
  <Allow>
    <Source exactName="customerRowId" />
    <Target exactName="Customer.Id" />
  </Allow>
</RequireMatchingNames>

Multiple attributes on one matcher are combined with AND semantics. Multiple matcher elements are alternatives.

Sites

RequireMatchingNames and nested Allow mappings support the same allowedSites and blockedSites attributes as dependency edges. The first implementation reports value-name movements at these sites:

Site Example
Constructor new Customer(legacyCustomerId) compared with constructor parameter customerId
Method Save(animalId) compared with method parameter fruitId
MethodReturn return animalId; compared with the containing method name
Field _fruitId = animalId or field initializer assignment
Property customer.Id = animalId or property initializer assignment
Local var fruitId = animalId or fruitId = animalId

Other site names remain valid in filters because the site vocabulary is shared across the analyzer, but NameRules only produce diagnostics for value movements that have both a source name and a target name.

Direct language forms

RequireMatchingNames checks direct value movements by default. Those include:

  • ordinary and compound assignments;
  • component-wise tuple and deconstruction assignments;
  • constructor and method arguments;
    • including named, optional, and in arguments;
  • out values flowing back to the caller;
  • direct returns;
  • expression-bodied methods, properties, indexers, and local functions.

Parentheses, conversions, as, null-forgiving operators, conditional branches, coalesce expressions, and tuple expressions are unwrapped to their direct named sources.

Anonymous-lambda returns intentionally have no named return target, so the rule does not compare them with the containing method. Calls and assignments inside the lambda are still analysed at their own sites. This prevents a lambda from being accidentally reported as though it returned from its outer method.

Direct-form examples: Example.NameRules and Example.NameRuleLanguageForms.

valueTracking: direct versus local provenance

valueTracking belongs only on <RequireMatchingNames>:

Value Default What it compares
Direct Yes The value written directly at the current site. It does not follow local aliases.
IntraProcedural No The direct value plus an unambiguous local alias within the same method, accessor, constructor, local function, or lambda body.

Use Direct for the least surprising and fastest rule. Enable IntraProcedural only when a neutral local name can conceal a meaningful value name before it reaches another meaningful target:

<RequireMatchingNames valueTracking="IntraProcedural">
  <Source endsWith="Id" />
  <Target endsWith="Id" />
</RequireMatchingNames>
var pending = customerId;
Save(pending); // ARCH_NAME_008 when Save accepts orderId.

Method-like bodies use Roslyn control-flow graphs, so a branch join keeps provenance only when every path agrees.

Lambda bodies are deliberately more conservative:

  • Roslyn does not expose a standalone control-flow graph root for one lambda body.
  • The analyzer therefore uses an ordered scan.
  • It discards local provenance before a conditional, loop, switch, or try block.
  • A captured parameter can remain the direct source, but a local alias never crosses a callback boundary.

Tracking also stops at method calls, virtual dispatch, collections, delegate invocation, reflection, and method boundaries. This is bounded local provenance, not a whole-program taint-analysis promise wearing a smaller hat.

Tracking example: Example.NameRuleIntraProceduralTracking.

Declaration names and semantic types

RequireDeclarationNameMatchesType checks the declaration itself. Use it when serializers, model binders, dependency injection, or readers rely on an identifier to describe a strongly typed value:

<Layer name="AspEndpoints">
  <Class endsWith="Endpoint" />
  <NameRules>
    <RequireDeclarationNameMatchesType allowedSites="Method, Property">
      <Type implements="IHonestType" />
    </RequireDeclarationNameMatchesType>
  </NameRules>
</Layer>
public void GetPatient(PatientId patientId) { } // Allowed
public void GetPatient(DoctorId patientId) { }  // ARCH_NAME_008

public PatientId PatientId { get; set; } // Allowed
public DoctorId PatientId { get; set; }  // ARCH_NAME_008

Type selects semantic declared types. Name optionally selects declaration identifiers. Both use the same conjunctive matcher attributes as Class, and multiple sibling matchers are alternatives:

<RequireDeclarationNameMatchesType allowedSites="Method">
  <Type implements="IHonestType" endsWith="Id" />
  <Name endsWith="Id" />
  <Allow from="LegacyPatientIdentifier" to="patientId" />
</RequireDeclarationNameMatchesType>

The supported declaration sites are:

Site Declaration compared with its semantic type
Constructor Constructor or primary-constructor parameter
Method Ordinary method parameter
MethodReturn Method name and return type
Field Each declared field variable
Property Property name and property type
Local Explicit or var local variable

These two rules answer different questions:

Code Responsible rule
DoctorId patientId RequireDeclarationNameMatchesType: the declaration name disagrees with its type
PatientId patientId = doctorId RequireMatchingNames: a differently named value moves into the declaration
DoctorId GetPatientId() RequireDeclarationNameMatchesType at MethodReturn
return doctorId; from GetPatientId RequireMatchingNames at MethodReturn

Declaration rules use the semantic type, so aliases and var are resolved by Roslyn. Nullable value types are unwrapped. Arrays, collections, Task<T>, and arbitrary generic wrappers are not implicitly projected to an inner type.

Examples: Example.DeclarationNameMatchesType covers all six declaration sites. Example.HonestTypeEndpointNames shows the convention-based endpoint binding use case.

Visibility policies

<VisibilityPolicy> restricts the declared accessibility of types and members owned by a layer. It is opt-in and does not create or block a dependency edge. public is the reflex default in most codebases, and this is how a layer states otherwise without relying on a reviewer spotting the modifier.

Use an allowlist when only a small set is acceptable:

<Layer name="RepositoryQuerySurface">
  <Class endsWith="Queryable" />

  <VisibilityPolicy
    targets="Type"
    allowedAccessibilities="Internal, File"
    description="Repository query surfaces are implementation details." />
</Layer>

Use a blocklist when most accessibilities are acceptable:

<VisibilityPolicy
  targets="Field, Property"
  blockedAccessibilities="Public, Protected, ProtectedInternal" />

Exactly one of allowedAccessibilities and blockedAccessibilities is required.

Declaration targets

targets is a required, comma-separated list. Tokens are case-insensitive.

Target Declaration
Type Top-level class, interface, struct, record, enum, or delegate
NestedType A type declared inside another type
Constructor Instance or static constructor
Method Ordinary method, including an explicit interface implementation
Property Property or indexer
Field Field or field-like enum member
Event Field-like or explicit event
Operator User-defined operator
Conversion Implicit or explicit conversion operator

Implicit compiler-generated declarations are ignored. Partial symbols are evaluated once.

Accessibility values

Accessibility lists support:

Value C# form
Public public
Internal internal or the default for a top-level type
Protected protected
ProtectedInternal protected internal
PrivateProtected private protected
Private private or the default for a class member
File file type

The analyzer uses Roslyn symbols, not modifier text. Interface members therefore have their semantic public accessibility, and explicit interface implementations have their semantic private accessibility.

Nested layers

Policies apply to the owning layer and all descendants. Parent and child policies are cumulative, and every applicable policy must pass:

<Layer name="Application">
  <Assembly exactName="Restaurant.Application" />
  <VisibilityPolicy targets="Field" blockedAccessibilities="Public" />

  <Layer name="Contracts">
    <Class endsWith="Contract" />
    <VisibilityPolicy targets="Type" allowedAccessibilities="Public" />
  </Layer>
</Layer>

The child policy cannot override a parent failure. The first failing policy is reported from outermost to innermost.

What this rule does not mean
  • Visibility policies check a declaration's own accessibility. A public nested class inside an internal parent is still declared Public.
  • Whether a declaration is effectively visible outside all its containing types is exposed to documentation and editor tooling for context, but it does not change ARCH_VIS_001.
  • Whether a public signature exposes a forbidden layer is a separate API-surface concern (ARCH_API_001).
  • Whether an interface or contract contains an allowed kind of member is a separate contract-purity concern (ARCH_CONT_008).

Arse includes visibility findings in inspect, report, generated documentation, and code evidence. The standalone WPF editor and Visual Studio graph inspector provide checkable target/accessibility controls and autosave the same .anl or inline metadata source.

Example project: Example.Arch_VIS_001.VisibilityPolicy

Inheritance policies

<InheritancePolicy> requires declarations in a layer to inherit a specific base type or implement specific interfaces. It is opt-in and separate from dependency permission, visibility, and contract purity.

Use it when a layer has a semantic base contract that every declaration must follow. The usual alternative is a base type everyone remembers to inherit, except in the one entity that was added the week before a release:

<Layer name="PersistenceEntities">
  <Namespace startsWith="Shop.Persistence" />

  <InheritancePolicy
    typeKinds="Class, Record"
    requiredBaseTypes="Entity, AggregateRoot"
    requiredInterfaces="IAuditedEntity"
    description="Persistence entities inherit the shared entity base and auditing contract." />
</Layer>
Type-kind values

typeKinds is required. Tokens are case-insensitive.

Value Meaning
Class Class declarations, excluding records
Interface Interface declarations
Struct Struct declarations, excluding record structs
Record Record class declarations
RecordStruct Record struct declarations
Enum Enum declarations
Delegate Delegate declarations
Required contracts

At least one of requiredBaseTypes and requiredInterfaces is required.

  • requiredBaseTypes passes when the declaration inherits any listed base type.
  • requiredInterfaces passes when the declaration implements all listed interfaces.

Both attributes accept comma-separated simple or fully qualified type names.

<InheritancePolicy typeKinds="Class" requiredBaseTypes="Entity" />
<InheritancePolicy typeKinds="Class, Record" requiredInterfaces="IAuditedEntity, ISoftDelete" />
<InheritancePolicy typeKinds="Class" requiredBaseTypes="AggregateRoot" requiredInterfaces="IAuditedEntity" />
Combining with structural class matchers

<InheritancePolicy> becomes especially useful when the layer itself is defined by a structural class matcher:

<Layer name="PizzaProviderRequests">
  <Class endsWith="Request">
    <Property exactName="PizzaId" typeName="PizzaId" />
  </Class>

  <InheritancePolicy
    typeKinds="Class"
    requiredInterfaces="IPizzaProvider" />
</Layer>

That reads as: request types that own a PizzaId property must implement IPizzaProvider. A DrinkRequest does not match the layer just because it ends with Request, so the inheritance rule never applies to it. This is useful for drop-in rule packs where a recognizable request shape should imply another contract.

Nested layers

Inheritance policies apply to the owning layer and all descendants. Parent and child policies are cumulative:

<Layer name="Domain">
  <Namespace startsWith="Shop.Domain" />
  <InheritancePolicy typeKinds="Class" requiredInterfaces="IDomainType" />

  <Layer name="Entities">
    <Class endsWith="Entity" />
    <InheritancePolicy typeKinds="Class" requiredBaseTypes="Entity" />
  </Layer>
</Layer>

The child policy cannot override an outer denial. The first failure is reported from outermost to innermost.

What this rule does not mean
  • Inheritance policies do not grant or deny dependency edges. That is still controlled by <AllowedDependency> and <BlockedDependency>.
  • Inheritance policies do not decide whether a declaration may be public or internal. That is a visibility-policy concern (ARCH_VIS_001).
  • Inheritance policies do not replace contract purity. A type can inherit the right base class and still violate <ContractPolicy>.
  • Inheritance policies check declarations that already exist. They do not classify a type into a layer by themselves; the layer matchers still do that.

Arse includes inheritance-policy findings in inspect, report, generated documentation, and code evidence. The standalone WPF editor and Visual Studio graph inspector expose the same settings at layer scope.

Example projects: Example.Arch_INH_001.InheritancePolicy, Example.StructuralDeclarationMatchers

Contract purity

<ContractPolicy> restricts which declaration shapes are acceptable for contract types owned by a layer. It is opt-in and separate from dependency permission, visibility, and API exposure.

Use it when a layer should stay abstract and message-like:

<Layer name="Contracts">
  <Class endsWith="Contract" typeKind="Interface" />

  <ContractPolicy
    allowedTypeKinds="Interface"
    allowedMemberKinds="Method, Property"
    allowedPropertyAccessors="Get, Init"
    allowMethodBodies="false"
    allowStaticMembers="false"
    allowNestedTypes="false"
    description="Contracts stay abstract and expose immutable state only." />
</Layer>
Type-kind values

allowedTypeKinds is required. Tokens are case-insensitive.

Value Meaning
Class Class declarations, excluding records
Interface Interface declarations
Struct Struct declarations, excluding record structs
Record Record class declarations
RecordStruct Record struct declarations
Enum Enum declarations
Delegate Delegate declarations
Member-kind values

allowedMemberKinds is required. Tokens are case-insensitive.

Value Declaration
Constructor Instance or static constructor
Method Ordinary method
Property Property or indexer
Event Event
Field Field
Operator User-defined operator
Conversion Implicit or explicit conversion operator
Property accessors

allowedPropertyAccessors is optional. When omitted, accessors are not restricted.

Value Meaning
Get Getter accessor
Set Setter accessor
Init Init accessor
Boolean settings

All booleans default to false when omitted.

Attribute Meaning when false
allowMethodBodies Methods, accessors, and default interface members must stay body-free
allowStaticMembers Source-declared static members are rejected
allowNestedTypes Source-declared nested types are rejected
Nested layers

Contract policies apply to the owning layer and all descendants. Parent and child policies are cumulative:

<Layer name="Application">
  <Assembly exactName="Restaurant.Application" />
  <ContractPolicy
    allowedTypeKinds="Interface, Record"
    allowedMemberKinds="Method, Property" />

  <Layer name="Contracts">
    <Class endsWith="Contract" />
    <ContractPolicy
      allowedTypeKinds="Interface"
      allowedMemberKinds="Method, Property"
      allowedPropertyAccessors="Get" />
  </Layer>
</Layer>

The child policy cannot override an outer denial. The first failure is reported from outermost to innermost.

What this rule does not mean
  • Contract purity does not grant or deny dependency edges. That is still controlled by <AllowedDependency> and <BlockedDependency>.
  • Contract purity does not decide whether a declaration may be public or internal. That is a visibility-policy concern (ARCH_VIS_001).
  • Contract purity does not decide whether a public signature leaks a forbidden layer. That is an API-surface concern (ARCH_API_001 / ARCH_API_010).
  • Contract purity is not inferred from a layer name such as Contracts; it only runs when <ContractPolicy> is present. A folder named Contracts is a naming convention, not a guarantee, no matter how firmly it is stated in a design review.

Arse includes contract-purity findings in inspect, report, generated documentation, and code evidence. The standalone WPF editor and Visual Studio graph inspector expose the same settings as token checklists and booleans.

Focused example projects:

Return-value policies

<ReturnValuePolicy> rejects configured direct return expressions. Inside a <Layer>, it applies to methods in that layer and descendants. Directly inside <ArchitecturalLevels>, it applies globally to every analyzed method, including code that belongs to no layer. It is useful when a return value is a sentinel that hides a decision the method should make explicitly. return null is such a decision: it delegates the hard part to whichever caller dereferences it first, usually in production.

It does not impose a universal “never return null” opinion. You decide which returned expressions are unacceptable:

<Layer name="Kitchen">
  <Class endsWith="Kitchen" />

  <ReturnValuePolicy description="The kitchen makes serving decisions before returning to the waiter.">
    <Literal value="null" description="No invisible empty plate." />
    <Literal value="" description="No empty menu name." />
    <Literal value="42" description="No magic slice-count fallback." />
    <Literal value="0" description="No unnamed enum-zero status." />
    <Invocation withAttribute="JetBrains.Annotations.CanBeNullAttribute"
                description="Optional lookup results get a real fallback." />
  </ReturnValuePolicy>
</Layer>

Direct matcher children are forbidden expressions: returning a value matching any one produces ARCH_RET_001. Attributes on one matcher are combined, just like layer matchers.

Use a return-shape allow-list carefully

Use one <AllowedReturn> block only when a layer must return nothing except the selected direct expression shapes. Its child matchers are alternatives, so a return must match at least one of them. This can make a strict "only return a named expression" convention explicit:

<Layer name="Kitchen">
  <Class endsWith="Kitchen" />

  <ReturnValuePolicy description="The kitchen makes its serving decision before it hands a pizza to the waiter.">
    <AllowedReturn description="A prepared pizza is returned through a named hand-off point.">
      <Identifier />
    </AllowedReturn>
  </ReturnValuePolicy>
</Layer>
// ARCH_RET_001: the kitchen hands the waiter an unfinished oven call.
public Pizza PreparePizzaTheHardToInspectWay()
{
    return oven.BakePizza();
}

// Valid: there is an intentional named hand-off point for inspection, logging, or handling.
public Pizza PreparePizzaWithAResult()
{
    var result = oven.BakePizza();

    return result;
}

<Identifier /> means a bare named expression such as return result;. It deliberately does not prove that the name is a local: a parameter, an unqualified field, a property, or a constant is also an identifier expression. This is a direct return-shape rule, not a variable-provenance or data-flow rule. Add <MemberAccess /> to the same <AllowedReturn> block when direct member access should also be allowed.

That allow-list is broader than "do not return a method invocation directly." It also rejects return false;, return null;, return 42;, return new Pizza();, and every other shape not listed. When the actual rule is only about direct calls, configure the unwanted shape instead:

<ReturnValuePolicy description="Do not return method invocations directly.">
  <Invocation description="Assign the invocation result before returning it." />
</ReturnValuePolicy>

Now return oven.BakePizza(); fails, while unrelated literal and object-creation returns remain alone. This is usually the clearer drop-in rule.

Only one <AllowedReturn> block is valid for a policy. It may be combined with forbidden direct matcher children; forbidden matches win, so a policy can permit named returns generally while still rejecting one specifically named sentinel.

Apply a drop-in policy to every project

Place <ReturnValuePolicy> directly under <ArchitecturalLevels> when a rule should apply to the whole configuration rather than one layer. This is a real global policy, not a synthetic Global layer: it does not change dependency graphs, layer badges, or same-layer checks.

For a reusable rule folder, keep a small root configuration and import the rule files:


<ArchitecturalLevels>
  <Include path="Rules/*.anl" />
</ArchitecturalLevels>

<ArchitecturalLevels>
  <ReturnValuePolicy description="The kitchen does not serve an oven call directly.">
    <Invocation description="Assign the oven result before serving it." />
  </ReturnValuePolicy>
</ArchitecturalLevels>

Register the root configuration from a Directory.Build.props at the solution root so each project hands it to Roslyn:

<Project>
  <ItemGroup>
    <AdditionalFiles Include="$(MSBuildThisFileDirectory)Architecture.anl" />
  </ItemGroup>
</Project>

Roslyn analyzers receive project inputs, not permission to search the solution filesystem. Each project therefore needs this shared AdditionalFiles registration. Global policies run before layer policies, so a layer policy may add a stricter rule but cannot relax a global one.

Supported direct return matchers
Child element Matches Typical use
<Literal> A direct literal, including null, "", numeric values, booleans, and enum casts <Literal value="null" />, <Literal value="0" />
<Invocation> A direct method invocation <Invocation withAttribute="JetBrains.Annotations.CanBeNullAttribute" />
<New> A direct new / target-typed new() result Forbid returning a raw mutable implementation
<Identifier> A bare named expression Forbid a known sentinel name, or allow only named return hand-offs inside <AllowedReturn>
<MemberAccess> A directly returned property or field access Forbid a static None / Empty member where appropriate

Literal has a dedicated value attribute. It deliberately supports an empty value, so <Literal value="" /> means an empty string. Numeric enum casts are unwrapped before matching, so <Literal value="0" /> also catches return (PizzaStatus)0;.

The usual matcher attributes also work where Roslyn can resolve the expression: typeName, exactName, exactFullName, endsWith, startsWith, contains, regex, inherits, implements, withAttribute, withAccessModifier, and typeKind. For example, the annotation matcher above uses the invoked method symbol’s attribute name. That remains configuration-driven: AnaalIJzer does not reference JetBrains.Annotations.

The analyzer only rejects values returned unchanged. A handling expression such as lookup.FindPizza() ?? Pizza.Margherita is not a direct Invocation return, because the kitchen has made an explicit fallback decision.

Return-value policies are cumulative through nested layers. An outer policy applies to a child layer, and a child cannot cancel an outer forbidden expression.

There is intentionally no code fix for ARCH_RET_001: the configuration identifies an unacceptable result, but only the application can decide the correct replacement.

Focused examples:

Namespace hierarchy policies

<NamespaceHierarchyPolicy> protects ownership implied by a namespace tree. It is a root-level policy, not a layer: it works whether or not either type belongs to a <Layer>, and it does not add nodes or edges to the layer dependency graph.

The restaurant version is simple: Restaurant.Orders owns its order details. A type in that namespace should not reach back up and grab a root-level chef implementation unless the policy deliberately permits it.

<ArchitecturalLevels>
  <NamespaceHierarchyPolicy rootNamespace="Restaurant"
                            description="Feature namespaces own their implementation details.">
    <BlockedRelation relation="DescendantToAncestor" />
  </NamespaceHierarchyPolicy>
</ArchitecturalLevels>

With that configuration, Restaurant.Orders.OrderTicket may depend on another type under Restaurant.Orders, but it cannot reference Restaurant.HeadChef directly. The policy checks semantic references, not using directives by themselves.

Relationships

rootNamespace is compared as dot-separated namespace segments. Restaurant.Orders is below Restaurant; Restaurant.OrdersArchive is not. Both the caller and dependency must be inside the configured root.

relation Caller to dependency Restaurant reading
DescendantToAncestor Restaurant.Orders → Restaurant An order detail reaches back up to a root-level chef.
AncestorToDescendant Restaurant → Restaurant.Orders A root-level chef reaches down into order details.
SiblingToSibling Restaurant.Orders → Restaurant.Menu One feature namespace reaches sideways into another.
SameNamespace Restaurant.Orders → Restaurant.Orders Two types in the same namespace reference each other.

Add one or more <BlockedRelation> children. Rules are read in XML order: the first rule that matches both the relationship and the site explains the block.

<NamespaceHierarchyPolicy rootNamespace="Restaurant">
  <BlockedRelation relation="DescendantToAncestor" />
  <BlockedRelation relation="SiblingToSibling"
                   description="Feature kitchens share a contract, not each other&#39;s internals." />
</NamespaceHierarchyPolicy>

This policy has no implicit exception for a parent namespace. If Restaurant.Orders needs a root-level shared contract, place that contract in an intentional namespace and choose the relationship rules accordingly.

Scope a block to dependency sites

allowedSites and blockedSites are filters on a blocked relation. They are not permission grants and they are not the same as <AllowedDependency allowedSites="...">.

<NamespaceHierarchyPolicy rootNamespace="Restaurant">
  
  <BlockedRelation relation="DescendantToAncestor" allowedSites="Constructor" />

  
  <BlockedRelation relation="SiblingToSibling" blockedSites="Field" />
</NamespaceHierarchyPolicy>
  • allowedSites="Constructor" means this block applies at constructors and nowhere else.
  • blockedSites="Field" means this block applies everywhere except fields.
  • The attributes are mutually exclusive.

All architectural dependency sites are supported: Constructor, Method, MethodReturn, Field, Property, Local, New, GenericInvocation, GenericArgument, Inheritance, InterfaceImplementation, Attribute, and StaticMember.

Interaction with layers

Namespace hierarchy policies run before layer dependency rules. When they block a reference, AnaalIJzer reports ARCH_NS_007 instead of also reporting an ARCH_DEP_001, ARCH_DEP_004, or ARCH_DEP_005 for that same reference. When no hierarchy rule blocks it, ordinary layer analysis continues unchanged.

That separation is intentional:

  • layers express architectural roles and permitted role-to-role dependencies;
  • namespace hierarchy policies express source ownership inside a namespace tree.

Neither mechanism overrides the other. A namespace rule can stop a dependency early; a layer rule can still reject a dependency that the namespace policy leaves alone.

There is no automatic code fix for ARCH_NS_007. The analyzer can identify the forbidden direction, but only the application can decide whether to move a type, introduce a contract, or change the ownership boundary.

Focused examples:

Forbidden operation policies

<ForbiddenOperations> rejects one selected resolved API operation inside an owning layer and its descendants. It is intentionally narrower than <Forbidden>: you can forbid DateTime.UtcNow without forbidding DateTime, or forbid Environment.MachineName while still allowing Environment.NewLine.

This is a semantic policy. AnaalIJzer compares Roslyn symbols, so an alias and a fully qualified spelling resolve to the same member. It does not need, and does not take, a dependency on the assembly that defines the selected member or attribute.

<Layer name="Kitchen">
  <Class endsWith="Kitchen" />

  <ForbiddenOperations description="Kitchens obtain time through the restaurant clock.">
    <ForbiddenOperation allowedSites="StaticMember"
                        description="A direct system-clock read hides a dependency.">
      <OperationMatcher kind="PropertyRead" staticAccess="true">
        <ContainingType exactFullName="System.DateTime" />
        <Member exactName="UtcNow" memberKind="Property" />
      </OperationMatcher>
    </ForbiddenOperation>
  </ForbiddenOperations>
</Layer>

That produces ARCH_OPER_001 for DateTime.UtcNow in Kitchen code. An injected PizzaClock.UtcNow property is unaffected because it is a different resolved symbol.

How matching works

  • Every <ForbiddenOperation> is a separate forbidden rule.
  • Sibling <OperationMatcher> children in one rule are alternatives: matching any one reports ARCH_OPER_001.
  • ContainingType and Member inside one matcher are both required.
  • Multiple matcher attributes on either child are also combined, using the normal AND-within / OR-between matcher model.
  • staticAccess="true" or staticAccess="false" narrows the matcher. Omit it when both forms are meaningful.
  • allowedSites and blockedSites use the same site filters as dependency rules. A static access is reported as StaticMember even if the surrounding expression assigns it to a local or returns it.
  • An outer layer policy applies to nested child layers. A child layer cannot cancel a selected operation forbidden by its parent.

Operation kinds

kind value Resolved operation
Invocation Method or reduced extension-method call
PropertyRead, PropertyWrite Property access or assignment
FieldRead, FieldWrite Field access or assignment
EventAccess Event subscription or access
ObjectCreation A resolved constructor call
Conversion A user-defined conversion operator
Assignment A resolved property, field, or event assignment
Return A return operation, without a selected member
Argument An argument operation, without a selected member

<Member> is available only for the operation kinds that select a member. Its optional memberKind is one of Method, Constructor, Property, Field, or Event; incompatible combinations are configuration errors (ARCH_CONF_003).

Common patterns


<ForbiddenOperation allowedSites="Method">
  <OperationMatcher kind="Invocation" staticAccess="false">
    <ContainingType typeName="Task" />
    <Member exactName="Wait" memberKind="Method" />
  </OperationMatcher>
</ForbiddenOperation>


<ForbiddenOperation allowedSites="MethodReturn">
  <OperationMatcher kind="Invocation" staticAccess="false">
    <ContainingType exactFullName="System.IServiceProvider" />
    <Member exactName="GetService" memberKind="Method" />
  </OperationMatcher>
</ForbiddenOperation>

There is no automatic code fix for ARCH_OPER_001. A selected operation tells the analyzer what is not permitted, but it cannot decide whether your replacement should be an injected adapter, await, an explicit result type, or a different composition boundary.

When Sites Diagnostics is enabled in the Visual Studio companion, a matching operation is shown with its regular site label and an ARCH_OPER_001 policy-status explanation in QuickInfo. This remains opt-in with the rest of the site indicators, so a policy does not add editor adornments by default.

Focused examples:

Behavioral operation policies

<BehavioralOperations> adds narrow, mechanically provable rules about the resolved operations inside a selected declaration body. It belongs to a layer, applies to that layer and its descendants, and reports ARCH_OPER_002, ARCH_OPER_011, or ARCH_OPER_012 according to whether a required operation is missing, a count is exceeded, or ordering is invalid.

This is deliberately more precise than an ordinary dependency rule and less ambitious than a business-process proof.

AnaalIJzer can prove that:

  • a configured PizzaSafetyCheck.Validate() call exists;
  • it dominates a configured PizzaOven.Bake() call in C# control flow;
  • a configured operation occurs no more than the permitted number of times.

It cannot prove that:

  • the validator accepted the pizza;
  • the oven completed at runtime;
  • another service did not mutate the order elsewhere.

That boundary is intentional. This feature inspects source operations; it has not secretly become a theorem prover for lunch.

<Layer name="Kitchen">
  <Class endsWith="Kitchen" />

  <BehavioralOperations description="A kitchen checks a pizza before the oven changes it.">
    <RequiredOperationBefore description="The safety check happens before baking.">
      <DeclarationMatcher>
        <Member endsWith="Pizza" memberKind="Method" />
      </DeclarationMatcher>
      <OperationMatcher kind="Invocation">
        <ContainingType typeName="PizzaSafetyCheck" />
        <Member exactName="Validate" memberKind="Method" />
      </OperationMatcher>
      <BeforeOperation>
        <OperationMatcher kind="Invocation">
          <ContainingType typeName="PizzaOven" />
          <Member exactName="Bake" memberKind="Method" />
        </OperationMatcher>
      </BeforeOperation>
    </RequiredOperationBefore>
  </BehavioralOperations>
</Layer>

The four rule families

Element What it proves Diagnostic
<RequiredOperation> At least one selected operation occurs in the selected declaration. With the default Dominance ordering, one match must execute on every path to exit. ARCH_OPER_002 when no selected operation exists or none dominates every exit.
<RequiredOperationBefore> A selected required operation occurs before every selected BeforeOperation target. ARCH_OPER_012 when a target has no matching required operation before it.
<ForbiddenOperationAfter> A selected operation must not occur after a selected AfterOperation terminal. ARCH_OPER_012 when the terminal operation occurs before the forbidden operation.
<MaximumOperationCount maximum="N"> At most N selected operations occur in one declaration. ARCH_OPER_011 for every occurrence after N, in lexical source order.

<ForbiddenOperations> is related but separate: it rejects one direct selected operation anywhere in the layer. See forbidden operation policies for that direct API policy.

Selecting a declaration and its operations

Each rule has one <DeclarationMatcher> followed by one or more <OperationMatcher> elements. A declaration matcher selects the method, constructor, property accessor, or other supported member body that owns the rule:

<DeclarationMatcher>
  <ContainingType endsWith="Kitchen" />
  <Member endsWith="Pizza" memberKind="Method" />
</DeclarationMatcher>

ContainingType and Member are conjunctive: both must match when both are present. Their normal matcher attributes use the standard AND-within / OR-between semantics. Member accepts Method, Constructor, Property, Field, or Event through memberKind; use sibling rules for alternative declarations.

Sibling <OperationMatcher> elements are alternatives. A matcher resolves Roslyn symbols, not source spelling, so aliases and fully qualified names identify the same selected member. The matcher vocabulary, operation kinds, and staticAccess behavior are the same as forbidden operation policies.

Ordering rules add a related target:

  • <BeforeOperation> is the operation that a required operation must precede.
  • <AfterOperation> is the terminal operation that a forbidden operation must not follow.

Dominance and lexical order

ordering="Dominance" is the default. It uses Roslyn control-flow graphs, so a validation hidden in one if branch does not satisfy a rule for a bake call that can occur after either branch. This is the safe default for “must happen before” rules.

ordering="Lexical" compares source order only. It is useful when the policy is intentionally about source structure rather than every runtime path, but it is weaker: an earlier call inside a conditional block can satisfy a later call even when that branch is not taken.


<RequiredOperation ordering="Lexical">
  ...
</RequiredOperation>

allowedSites and blockedSites work on every selected operation in the rule, including ordering targets. For example, allowedSites="Method" prevents a static member read from being counted as the required operation. The filters do not turn a lexical rule into a dominance rule or vice versa.

What this feature does not claim

  • It does not infer that a method called Validate is actually a validator; the XML selects the resolved member explicitly.
  • It does not inspect runtime behavior, asynchronous continuation execution, reflection, delegates, or another method's body.
  • It does not analyse generated code by default.
  • It does not offer automatic code fixes for the ARCH_OPER_* diagnostics; adding a call, changing its order, or removing an extra operation is a domain decision.

The surrounding tools reuse the same configuration model:

  • Arse validates, documents, merges, splits, and reports the policies.
  • The WPF and Visual Studio graph editors preserve and edit the layer-scoped XML.
  • The Visual Studio companion shows concrete ARCH_OPER_* results through Sites Diagnostics and QuickInfo.

None of those hosts reimplements the evaluator.

Focused examples:

Operation contracts

<Operations> describes a named source-level operation only when a team explicitly writes it down. It connects a selected owner method to zero or more selected entry points and, optionally, to request and response type shapes. It does not infer HTTP routes, ASP.NET controllers, queue handlers, scheduled jobs, or business meaning from names.

Use it when a few important paths deserve a stronger, named rule than ordinary dependency direction. A pizza order is a useful small example: a waiter may receive PlacePizzaOrderRequest, but the kitchen owns the operation. A selected waiter method must call that kitchen method directly, and both methods may be required to use the same request and response types.

<Operations description="Important restaurant operations are named explicitly.">
  <Operation name="PlacePizzaOrder"
             allowedOwnerLayers="Application"
             allowedEntryPointLayers="Controller"
             description="A waiter submits one order to the kitchen.">
    <Owner>
      <DeclarationMatcher>
        <ContainingType endsWith="Kitchen" />
        <Member exactName="PlacePizzaOrder" memberKind="Method" />
      </DeclarationMatcher>
    </Owner>
    <Request><Class exactName="PlacePizzaOrderRequest" /></Request>
    <Response><Class exactName="PlacePizzaOrderResponse" /></Response>
    <EntryPoint>
      <DeclarationMatcher>
        <ContainingType endsWith="Controller" />
        <Member exactName="PlacePizzaOrder" memberKind="Method" />
      </DeclarationMatcher>
    </EntryPoint>
  </Operation>
</Operations>

Meaning

Element or attribute Meaning
<Operation name="..."> A human-selected identifier. It is not inferred from code. Names are unique across loaded configuration files.
<Owner> Exactly one required method declaration selector. The workspace host checks that it resolves to exactly one source method in the inspected project or solution.
<EntryPoint> Zero or more method selectors. Each matching method must directly invoke the configured owner.
<Request> Optional <Class> matcher. When present, the owner and selected entry points must each accept a matching parameter.
<Response> Optional <Class> matcher. When present, the owner and selected entry points must return a matching direct return type. Task<T> is not unwrapped implicitly.
allowedOwnerLayers Optional comma-separated layer paths for the owner. Use Application for a root layer and /Ordering/Application for a nested layer.
allowedEntryPointLayers Optional comma-separated layer paths for entry points.

ContainingType and Member inside a DeclarationMatcher use the shared semantic matcher vocabulary and are combined with AND semantics. The owner and entry-point Member must use memberKind="Method". Request and Response use the normal <Class> matcher vocabulary, including combined attributes and structural declaration matchers.

What is checked where

The compiler analyzer reports a concrete operation-contract diagnostic for each local fact:

  • ARCH_OPCT_001 when a selected owner or entry point is outside its configured host layer;
  • ARCH_OPCT_002 when a selected owner or entry point lacks the configured request parameter, or a selected entry point does not directly invoke the selected owner;
  • ARCH_OPCT_008 when a selected owner or entry point returns the wrong direct response type.

The workspace-backed Arse commands (arse inspect and arse report) report a separate finding when an operation has no matching owner or more than one matching owner across the inspected scope. The graph editors preserve and edit the source contract; that cardinality fact cannot be proven by one project's compiler analyzer invocation.

The check deliberately does not follow helpers, delegates, asynchronous continuations, reflection, or method return values. A direct call is a clear source-level contract; anything broader needs a separate explicit policy rather than an optimistic guess.

Tool support

  • Arse: inspect and report include operation-owner cardinality findings for a project or solution. documentation renders the manifest in XML order.
  • WPF graph editor and Visual Studio graph host: the root inspector can add, edit, or remove an <Operations> container. It is presented as a source-contract editor, not a dependency graph edge.
  • Visual Studio editor: when Sites Diagnostics are enabled, local ARCH_OPCT_* violations appear as method-site indicators with QuickInfo.
  • Code fixes: none. Connecting an entry point to a workflow, or deciding how to reshape a request/response contract, is a domain decision.

Focused examples: Example.Arch_OPCT_001.ParticipantNotAllowed, Example.Arch_OPCT_002.RequiredOwnerInvocation, and Example.Arch_OPCT_008.ResponseShapeMismatch.

Assembly attribute policies

<AssemblyAttributePolicy> checks the attributes emitted on the current compiled assembly. It is a compiler analyzer rule, so a violation is reported as ARCH_ASSM_001 during ordinary builds and in the editor.

This is useful when an assembly-level declaration represents an architectural decision rather than incidental metadata. For example, InternalsVisibleTo grants another assembly access to internal code. A team may want that grant to be explicit and limited to approved friends.

<ArchitecturalLevels>
  <AssemblyAttributePolicy description="Friend access stays deliberate.">
    <Forbidden>
      <Attribute exactFullName="System.Runtime.CompilerServices.InternalsVisibleToAttribute"
                 description="This friend has not been approved.">
        <Argument index="0" exactName="NotAllowedExample" />
      </Attribute>
    </Forbidden>
  </AssemblyAttributePolicy>
</ArchitecturalLevels>

That configuration rejects either of these equivalent final assembly facts:

[assembly: InternalsVisibleTo("NotAllowedExample")]
<ItemGroup>
  <InternalsVisibleTo Include="NotAllowedExample" />
</ItemGroup>

The SDK generates the second form as a compiled InternalsVisibleToAttribute. The analyzer reads Compilation.Assembly.GetAttributes(), so it does not need special logic for this SDK item and it has no dependency on the attribute's assembly. The policy merely names the semantic attribute type in XML.

Allowed and forbidden rules

An <Attribute> rule uses the normal type matcher attributes, including exactName, exactFullName, startsWith, endsWith, contains, regex, and typeKind. Attributes on one rule are combined with AND; sibling rules are alternatives.

<Forbidden> is a deny list. Any matching rule produces ARCH_ASSM_001, even if an Allowed rule also matches.

<Allowed> is a scoped allow list. It only constrains attribute types selected by at least one of its rules. Unrelated assembly attributes remain untouched. For a selected attribute type, one allowed rule must match its arguments.

<AssemblyAttributePolicy>
  <Allowed>
    <Attribute exactFullName="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
      <Argument index="0" exactName="ApprovedKitchen" />
    </Attribute>
    <Attribute exactFullName="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
      <Argument index="0" exactName="ApprovedBakery" />
    </Attribute>
  </Allowed>
</AssemblyAttributePolicy>

Here the two <Attribute> rules are alternatives: either approved friend is accepted. This does not prohibit attributes such as CLSCompliantAttribute, because none of the allowed rules selects that attribute type.

Arguments

<Argument> selects either a positional constructor argument or a named argument:

<Attribute exactFullName="Restaurant.FriendAccessAttribute">
  <Argument index="0" exactName="PastryTeam" />
  <Argument name="CanReadRecipes" exactName="true" />
</Attribute>

Use exactly one of index or name. Every child <Argument> must match, so the rule above requires both the first constructor argument and the named CanReadRecipes argument. Values are compared as invariant text using typeName, exactName, startsWith, endsWith, contains, or regex.

Scope and tooling

Assembly attribute policies are root-level rules. They do not belong to a C# layer, dependency edge, or syntactic site: the check runs once against the completed compilation's assembly metadata.

  • The analyzer produces ARCH_ASSM_001 and a report row with the assembly, attribute type, rule, and reason.
  • Arse documentation and violation reports render the policies in authored configuration order.
  • The shared configuration editor used by the WPF graph editor and Visual Studio graph window can inspect, add, edit, and remove root-level <AssemblyAttributePolicy> elements. They are shown as source-metadata policies rather than dependency-graph edges.
  • There is no automatic code fix. Removing a friend, adding one to an allow list, or changing a generated project item requires an explicit ownership decision.

Phase-one boundary

This feature checks compiled assembly attributes, including those generated by an SDK item. It does not inspect arbitrary raw MSBuild properties or items. A future workspace-level MSBuild policy could cover those inputs, but it would be a different feature with different host requirements and should not be hidden behind a compiler analyzer rule.

Focused examples:

ASP.NET Core example pack

AnaalIJzer does not need an ASP.NET Core dependency to enforce many useful Web API rules. Roslyn resolves the symbols in your application; the ordinary matcher and policy vocabulary can then select facts such as [ApiController], ControllerBase, action parameters, public return types, and direct method calls.

The runnable Example.AspNetCore pack uses real Microsoft.NET.Sdk.Web projects to show four framework-neutral rules:

Project Rule demonstrated Intended finding
LayerBoundaries <Class withAttribute="ApiController" /> classifies endpoints, then ordinary layer edges keep controllers from injecting repositories directly. ARCH_DEP_001
ModelBindingNames RequireDeclarationNameMatchesType protects selected honest-type action parameters from misleading names. ARCH_NAME_008
OperationContracts An explicit <Operations> rule requires selected controller actions to directly invoke one application owner with a selected request and response shape. ARCH_OPCT_002
ApiSurface <ApiSurface> prevents an endpoint from exposing IQueryable<T> even when ordinary dependency rules permit use of it. ARCH_API_001

For example, a controller layer can be classified solely from its real attribute:

<Layer name="Endpoint">
  <Class withAttribute="ApiController" />
</Layer>

<Layer name="Application">
  <Class endsWith="Service" />
</Layer>

<AllowedDependency from="Endpoint" to="Application" />

These rules examine explicit source semantics. They do not infer route templates, compare a "{patientId}" token to a parameter, discover minimal-API handlers from MapGet, inventory route groups, or compare source with generated OpenAPI. Those are genuinely ASP.NET-specific concerns and are deliberately outside this generic pack.

Entity Framework Core example pack

AnaalIJzer does not need an Entity Framework Core dependency to enforce useful persistence boundaries. Roslyn resolves the EF Core symbols in the application project; ordinary matchers and policies then select facts such as DbContext, IQueryable<T>, IEntityTypeConfiguration<T>, Migration, and [Index].

The runnable Example.EntityFrameworkCore pack uses real Microsoft.EntityFrameworkCore packages to demonstrate six framework-neutral configurations:

Project Rule demonstrated Intended finding
ContextBoundary A repository owns DbContext injection; an Application service only knows the repository. ARCH_DEP_001 for direct Application-to-DbContext injection.
ContextCreation A dedicated factory may create and return DbContext; Application code may not construct one. ARCH_DEP_001 at Site=New.
QuerySurface Application code may immediately project a repository-owned IQueryable<T>, but may not retain it in a local. ARCH_DEP_001 at Site=Local.
ModelConfigurationPlacement IEntityTypeConfiguration<T> implementations belong under Persistence/Mapping. ARCH_SRC_007 for a misplaced configuration.
MigrationPlacement Migration subclasses belong under Persistence/Migrations. ARCH_SRC_007 for a misplaced migration.
DomainPurity An optional team policy permits EF mapping annotations in Persistence but blocks them in Domain. ARCH_DEP_001 at Site=Attribute.

For example, a generic DbContext boundary needs no EF-specific analyzer feature:

<Layer name="Application">
  <Class endsWith="Service" />
</Layer>

<Layer name="Repository">
  <Class endsWith="Repository" />
</Layer>

<Layer name="Context">
  <Class inherits="DbContext" />
</Layer>

<AllowedDependency from="Application" to="Repository" />
<AllowedDependency from="Repository" to="Context" allowedSites="Constructor" />

The package deliberately makes no claim to diagnose N+1 queries, query performance, tracking behavior, runtime transactions, whether OnModelCreating registers every configuration, or whether migrations were applied. Those require EF Core runtime or model knowledge rather than a compile-time architectural policy.

Generated code analysis

AnaalIJzer excludes files Roslyn identifies as generated by default. Generated output is often large, changes frequently, and is owned by a source generator, designer, or another build step. Checking it without an explicit decision can turn one architectural rule into a noisy stream of findings that nobody can safely act on.

<GeneratedCode> is the deliberate opt-in. It affects compiler diagnostics, Arse inspection and reports, graph code evidence, and Visual Studio layer/site information alike. It does not change normal source analysis: non-generated C# files are always in scope.

<ArchitecturalLevels>
  <GeneratedCode mode="IncludeConfigured"
                 maximumDocumentLength="8192"
                 description="Inspect the generated kitchen file that this team owns.">
    <Path endsWith="Generated_Clock_Kitchen.g.cs" />
  </GeneratedCode>

  <Layer name="Kitchen">
    <Class endsWith="Kitchen" />
  </Layer>
</ArchitecturalLevels>
mode Effect
Exclude The default. Generated source is ignored.
IncludeConfigured Analyze only generated files matching at least one <Path>. This is the recommended mode.
IncludeAll Analyze every generated C# file that remains under maximumDocumentLength. Use only when the generator output is intentionally part of the architecture contract.

maximumDocumentLength defaults to 262144 characters and accepts values from 1 through 4194304. The cap is a guardrail for IDE and build responsiveness; a generated document larger than the configured cap is skipped.

Each <Path> uses the ordinary textual matcher attributes: typeName, exactName, startsWith, endsWith, contains, and regex. Attributes on one path are combined with AND semantics; sibling paths are alternatives. IncludeConfigured requires at least one valid path. IncludeAll and Exclude reject path children as invalid configuration (ARCH_CONF_003) so the intent stays unambiguous.

The scope can technically be supplied through AssemblyMetadata("AnaalIJzerSettings", ...), but a file-based Architecture.anl is usually clearer because it can describe several generated files and their ownership without crowding a source file.

The Visual Studio companion and WPF graph editor honor the scope when they collect code evidence: generated types, sites, and violations become visible only when the same configuration would analyze them. The graph itself remains an architecture graph, not a source-generator browser.

Focused example: Example.GeneratedCode opts one .g.cs file into a selected DateTime.UtcNow rule and produces one ARCH_OPER_001.

Project Architecture

ProjectArchitecture adds rules for .csproj references.

Use it when the problem is at project level rather than type level:

  • one project should not reference another project at all;
  • a project reference is architecturally wrong even if no code uses it yet;
  • an individual project reference is architecturally wrong even if the broader solution remains valid.

Project references also have a habit of outliving their reason: the code that needed them is deleted, the reference stays, and two years later somebody treats it as intended design.

Example

<ArchitecturalLevels>
  <ProjectArchitecture requireRecognizedProjects="true">
    <ProjectGroup name="Presentation">
      <Project endsWith=".Web" />
    </ProjectGroup>

    <ProjectGroup name="Application">
      <Project endsWith=".Application" />
    </ProjectGroup>

    <ProjectGroup name="Domain">
      <Project endsWith=".Domain" />
    </ProjectGroup>

    <AllowedProjectReference from="Presentation" to="Application" />
    <AllowedProjectReference from="Application" to="Domain" />
  </ProjectArchitecture>
</ArchitecturalLevels>

With that configuration:

  • Shop.Web -> Shop.Application is allowed
  • Shop.Application -> Shop.Domain is allowed
  • Shop.Web -> Shop.Domain raises ARCH_PROJ_001

Matchers

Project matchers are textual and apply to the project file name without .csproj.

Supported attributes:

Attribute Meaning
typeName Exact match
exactName Exact match
startsWith Prefix match
endsWith Suffix match
contains Substring match
regex Regular expression

Attributes on one Project element are combined with AND semantics. Separate Project elements inside one ProjectGroup are alternatives.

Rules

Supported edges:

<AllowedProjectReference from="Presentation" to="Application" />
<BlockedProjectReference from="Domain" to="Infrastructure" />
<AllowedProjectReference from="Tests" to="*" />
<AllowedProjectReference from="*" to="Shared" />

Notes:

  • * means any configured project group
  • blocked rules win over allowed rules
  • if a source group has at least one allowed rule, that source enters allowlist mode
  • same-group references need an explicit self-edge while that source is in allowlist mode
  • allowedSites, blockedSites, and appliesToDescendants do not apply here

Narrow A Group Edge To Specific Projects

Project groups are useful reporting and policy buckets. They do not need to become singleton groups merely because one pair of projects needs a narrower exception.

Add optional From and To child selectors to narrow one otherwise group-level edge:

<AllowedProjectReference from="Application" to="Contracts"
                         description="Only the ordering application owns the ordering contract.">
  <From exactName="Shop.Orders.Application" />
  <To exactName="Shop.Orders.Contracts" />
</AllowedProjectReference>

Both selectors must match. Attributes on one selector use the ordinary combined matcher rules, and multiple selectors on the same side are alternatives:

<BlockedProjectReference from="Application" to="Contracts">
  <From exactName="Shop.Legacy.Application" />
  <To exactName="Shop.Legacy.Contracts" />
  <To exactName="Shop.Private.Contracts" />
</BlockedProjectReference>

This blocks Shop.Legacy.Application from referencing either selected contract project. It does not block another project merely because it shares the Application group.

A nonmatching From selector does not place every project in its group into allowlist mode. Once a source selector matches, however, its To selector is enforced: an unselected target produces ARCH_PROJ_001. Blocked selectors still win over a broad allowed group edge.

Recognition

requireRecognizedProjects defaults to false.

When enabled:

  • the source project must match a ProjectGroup
  • the target project must match a ProjectGroup

If either side is unrecognized, ARCH_PROJ_001 reports that directly.

Build Integration

Roslyn does not reliably expose project-reference provenance by itself.

The analyzer package therefore ships a buildTransitive target that writes a small project-reference manifest and adds it as an analyzer AdditionalFile. Less elegant than asking the compiler, and it has the distinct advantage of working.

Arse and solution inspection do not need that generated manifest because they can inspect MSBuildWorkspace project references directly.

For rules about logical modules across an entire solution, use solution topology instead. ProjectArchitecture remains a compiler analyzer feature and produces ARCH_PROJ_001; SolutionTopology is explicit workspace inspection and produces ARCH_SOL_001 / ARCH_SOL_006 report findings.

Raw Assembly References

Use assembly reference policies for a direct MSBuild <Reference> / HintPath dependency that is neither a project reference nor a NuGet package. Those policies are intentionally evaluated by arse inspect and arse report, not as compiler ARCHxxx diagnostics.

IDE Fix Support

For deterministic cases, the config fixer layer can update project architecture rules too:

  • ARCH_PROJ_001 can add a missing <AllowedProjectReference from="..." to="..." />
  • ARCH_PROJ_001 can add a narrow exact-project rule with <From> and <To> selectors
  • same-group ARCH_PROJ_001 can add an explicit self-edge
  • blocked-edge ARCH_PROJ_001 can remove the matching <BlockedProjectReference ... />
  • ARCH_PKG_001 can append an exact <Package exactName="..."/> matcher to the matched allowed package list

Because ARCH_PROJ_001 and ARCH_PKG_001 are compilation-end diagnostics, host UX varies a little: build reports and host tooling are the most reliable surfaces, while editor light-bulb visibility depends on how the IDE exposes Location.None diagnostics.

Assembly reference policies

AssemblyReferencePolicy protects a project group from direct raw MSBuild <Reference> items. It is for legacy DLLs, hand-maintained HintPath references, and other assembly dependencies that are neither a ProjectReference nor a NuGet package.

This is deliberately a workspace policy, not a compiler analyzer diagnostic. A normal Roslyn analyzer execution cannot reliably discover the complete MSBuild provenance of an assembly reference. Run arse inspect or arse report against a project or solution to evaluate it.

<ArchitecturalLevels>
  <ProjectArchitecture requireRecognizedProjects="true">
    <ProjectGroup name="Domain">
      <Project endsWith=".Domain" />
    </ProjectGroup>

    <AssemblyReferencePolicy projectGroup="Domain"
                             description="The domain does not take a dependency on the legacy transport DLL.">
      <Forbidden>
        <Assembly exactName="Legacy.Transport" />
      </Forbidden>
    </AssemblyReferencePolicy>
  </ProjectArchitecture>
</ArchitecturalLevels>

Given this project file:

<ItemGroup>
  <Reference Include="Legacy.Transport">
    <HintPath>lib\Legacy.Transport.dll</HintPath>
  </Reference>
</ItemGroup>

arse inspect --project Shop.Domain.csproj reports an Assembly reference policy finding. arse report adds a separate workspace section with the source project, assembly identity, raw HintPath, and policy reason. A regular dotnet build remains free of a new ARCHxxx result for this rule.

What is matched

Assembly matches the assembly identity from the Include attribute, before any version, culture, or public-key suffix. The standard textual matcher attributes are available and are case-insensitive:

Attribute Meaning
typeName / exactName Exact assembly identity
startsWith Assembly identity prefix
endsWith Assembly identity suffix
contains Assembly identity substring
regex Assembly identity regular expression

Attributes on one Assembly element are combined with AND semantics; separate Assembly elements are alternatives. A Forbidden match wins over an Allowed match, just as with package policies.

The first scope intentionally inventories direct <Reference> elements declared in the project file. HintPath is recorded as evidence in reports, but it is not a policy matcher: policy should name the assembly that matters rather than accidentally depend on one machine's folder layout.

When to use a different rule

  • Use project-architecture.md for another project in the solution. That is compiler-enforced ARCH_PROJ_001 territory.
  • Use a PackagePolicy for a NuGet package ID. That is compiler-enforced ARCH_PKG_001 territory.
  • Use AssemblyReferencePolicy only when the build really has a raw assembly reference.

There is no automatic code fix for this workspace finding. Removing a legacy DLL reference, replacing it with a project boundary, or deliberately adjusting the policy is an architecture decision rather than a mechanically safe text edit.

Focused scenario: Example.AssemblyReferenceBoundaries contains a clean project build and an intentionally failing Arse inspection.

Solution topology

SolutionTopology is the solution-wide counterpart to ProjectArchitecture.

Use it when the architecture rule is about the shape of an entire loaded solution:

  • several projects belong to one logical module;
  • only some modules may reference one another;
  • a direct project reference needs evidence in a solution report, not a compiler diagnostic;
  • the configured module graph must remain acyclic.

SolutionTopology is intentionally evaluated by Arse through MSBuildWorkspace, not by the compiler analyzer. A normal project build remains self-contained and does not need to load its sibling projects.

Example

<ArchitecturalLevels>
  <SolutionTopology requireRecognizedProjects="true"
                    enforceAcyclic="true">
    <Module name="DiningRoom">
      <Project endsWith=".Web" />
    </Module>
    <Module name="Kitchen">
      <Project endsWith=".Application" />
    </Module>
    <Module name="Pantry">
      <Project endsWith=".Infrastructure" />
    </Module>

    <AllowedModuleReference from="DiningRoom" to="Kitchen" />
    <BlockedModuleReference from="Kitchen" to="Pantry" />
  </SolutionTopology>
</ArchitecturalLevels>

The restaurant names are only a readable domain metaphor. The arrows mean “may reference,” never runtime request or data flow.

With this configuration, Shop.Web -> Shop.Application is allowed and Shop.Application -> Shop.Infrastructure produces ARCH_SOL_001 during an explicitly enforced solution inspection.

Modules and matchers

Each Module has a unique name and one or more Project matchers. Project uses the same textual matcher attributes as ProjectArchitecture:

Attribute Meaning
typeName / exactName Exact project-name match without .csproj
startsWith Prefix match
endsWith Suffix match
contains Substring match
regex Regular expression

Attributes on one Project matcher are combined with AND semantics. Multiple Project matchers in a module are alternatives. If more than one module matches, the first module in configuration order is authoritative.

Module-reference rules

AllowedModuleReference and BlockedModuleReference use named modules or *:

<AllowedModuleReference from="DiningRoom" to="Kitchen" />
<BlockedModuleReference from="Kitchen" to="Pantry" />
<AllowedModuleReference from="Tests" to="*" />

Blocked rules win. Like ProjectArchitecture, a source module enters allowlist mode only when it has a matching allowed rule. A module with only blocked rules remains blocklist-only, which makes it possible to introduce topology checks gradually.

requireRecognizedProjects defaults to false. When enabled, each endpoint of an observed direct project reference must match a module. enforceAcyclic defaults to false; when enabled, a configured cycle among explicit allowed module rules produces ARCH_SOL_006.

Run it deliberately

arse inspect --solution src\Shop\Shop.slnx --enforce-topology --output build\Artifacts\architecture-health.md --force
arse inspect --solution src\Shop\Shop.slnx --enforce-topology --output build\Artifacts\architecture-health.json --force

The Markdown report is intended for review. Choosing a .json output path writes the same ordered findings as machine-readable evidence, including source and target project paths, module names, the reason, and the matching rule location. Headless Arse returns exit code 3 when the inspection finds a problem.

The repository includes a reusable Solution topology GitHub workflow. Call it from a product workflow or dispatch it with a solution path; it restores and builds Arse, uploads both evidence files, and fails only after those artifacts are available.

ARCH_SOL_001 and ARCH_SOL_006 are report finding codes, not compiler ARCH diagnostics. This distinction is intentional: opening an entire solution is a tooling operation, while an analyzer must stay fast and safe inside a normal project compilation.

Viewing the topology

Open a configured solution in the standalone WPF graph editor or use Extensions > IJzer > Show Dependency Graphs in Visual Studio. The graph renders SolutionTopology as a separate, read-only group: each configured module is a node, configured module rules are connections, and loaded direct project references appear as evidence connections. A permitted observed reference is muted; a ARCH_SOL_001 violation is highlighted for investigation.

That view deliberately does not expose drag-to-edit controls for modules or module rules. SolutionTopology is currently authored in Architecture.anl, and keeping the diagram read-only prevents a user from assuming that moving a module changes the solution policy. The normal layer graph remains editable when the same configuration also contains <Layer> rules.

See Example.SolutionTopology for a compact multi-project example whose normal build succeeds and whose explicit solution inspection fails with one intentional ARCH_SOL_001.

API surface policies

An <AllowedDependency> answers whether code may use another layer. An <ApiSurface> answers a different question: whether an externally visible declaration may expose that layer to its callers. Using a type internally is a private arrangement; returning it in a public signature is a promise to everyone downstream, made without a meeting.

This distinction is useful for repository-owned fluent query surfaces. An application service may use a LollyQueryable internally, but its public API should return a stable LollyProjection contract:

<Layer name="Application">
  <Class endsWith="Service" />

  <ApiSurface description="Public application APIs expose contracts only.">
    <AllowedLayer path="/Contracts" />
    <BlockedLayer path="/RepositoryQuerySurface" />
  </ApiSurface>
</Layer>

<Layer name="Contracts">
  <Class endsWith="Projection" />
</Layer>

<Layer name="RepositoryQuerySurface">
  <Class endsWith="Queryable" />
</Layer>


<AllowedDependency from="Application" to="RepositoryQuerySurface" />
public class CandyOrderingService
{
    // Allowed: the public result is a contract.
    public LollyProjection OrderProjectedLolly() => null!;

    // Allowed: a private implementation detail is not external API.
    private LollyQueryable BuildQuery() => null!;

    // ARCH_API_001: a repository-owned query surface escapes through public API.
    public LollyQueryable OrderRawLolly() => null!;
}

Evaluation rules

  • The policy applies to its layer and all descendant layers.
  • Parent and child policies are cumulative; a child cannot override a parent denial.
  • A matching <BlockedLayer> wins over an <AllowedLayer>.
  • If one or more <AllowedLayer> entries apply at the current site, the exposed recognized type must match one of them.
  • A parent layer path selects its complete subtree.
  • By default, unclassified framework and third-party types are ignored.
  • Set requireRecognizedTypes="true" to reject unclassified exposed types too.
  • Only externally visible declarations are checked: public, protected, and protected internal, through an externally visible containing-type chain.

API sites

Site Exposed declaration
Constructor Constructor parameter
Method Method or delegate parameter
MethodReturn Method or delegate return type
Property Property, indexer type, or indexer parameter
Field Field or event type
Inheritance Base class
InterfaceImplementation Implemented interface
GenericArgument Generic arguments and nested signature parts
Attribute Attribute type on an externally visible declaration

allowedSites makes an API layer rule apply only at the listed sites. blockedSites makes it apply everywhere except the listed sites:

<AllowedLayer path="/Contracts" allowedSites="Method, MethodReturn, Property" />
<BlockedLayer path="/RepositoryQuerySurface" blockedSites="Method" />

Locals, object creation, generic invocation, and static member access are implementation behavior rather than API declarations, so <ApiSurface> does not inspect them.

To inspect the public object graph behind an allowed signature type, enable TransitiveExposure. Direct violations remain ARCH_API_001; hidden violations reached through a permitted contract report ARCH_API_010.

Example project: Example.Arch_API_001.ApiSurfaceLeakage

Transitive API exposure

Direct API checks stop at the declared signature type. A contract can therefore look safe while its public object graph exposes a repository-owned type one step later:

CandyOrderingService.OrderRawLolly
    -> CandyReceipt.RawQuery
    -> LollyQueryable

Add <TransitiveExposure> to an existing <ApiSurface> to inspect that object graph. A query surface rarely gets published through a receipt property on purpose; it arrives because that property was convenient on a Tuesday.

<Layer name="Application">
  <Class endsWith="Service" />

  <ApiSurface description="Public application APIs expose contracts only.">
    <TransitiveExposure
        maxDepth="3"
        description="Follow public contract members for hidden repository surfaces." />
    <AllowedLayer path="/Contracts" />
    <BlockedLayer path="/RepositoryQuerySurface" />
  </ApiSurface>
</Layer>

maxDepth defaults to 3 and accepts values from 1 through 10. Traversal is opt-in: omitting <TransitiveExposure> preserves direct ARCH_API_001 behavior.

The analyzer performs a breadth-first traversal and reports the shortest forbidden path. It follows externally visible fields, events, properties, indexers, method and constructor signatures, base types, interfaces, constraints, arrays, tuples, nullable values, delegates, and generic arguments. Private implementation details are ignored.

Traversal is:

  • bounded by maxDepth;
  • cached per compilation;
  • cycle-safe for self-referential and mutually recursive contracts;
  • cancellable;
  • stopped at unrecognized types unless requireRecognizedTypes="true" makes the unrecognized exposure itself invalid.

The nested member's own API site is evaluated against allowedSites and blockedSites. A property reached through a public contract therefore uses Property, even when the root contract was exposed at MethodReturn.

A directly forbidden signature still reports ARCH_API_001 only. ARCH_API_010 is reserved for a permitted root type whose public object graph reaches a forbidden type.

Example project: Example.Arch_API_010.TransitiveExposure

Boundary entry points

<EntryPoints> lets a parent boundary say which child layers or specific dependency types are valid external doors into that boundary.

Restaurant version:

  • the kitchen may have several rooms inside it;
  • outside staff may enter through the service counter;
  • they should not walk straight into the cooking area.

That is different from <AllowedDependency>:

  • <AllowedDependency> says whether one role may depend on another at all;
  • <EntryPoints> says which part of a boundary is the allowed doorway after the dependency is otherwise legal.

Example:

<Layer name="Ordering">
  <Namespace startsWith="Shop.Ordering" />

  <EntryPoints>
    <EntryPoint layer="Contracts" />
    <EntryPoint allowedSites="Method">
      <Class endsWith="OrderingFacade" />
    </EntryPoint>
  </EntryPoints>

  <Layer name="Contracts">
    <Class typeKind="Interface" />
  </Layer>

  <Layer name="Implementation">
    <Class typeKind="Class" />
  </Layer>
</Layer>

Rules:

  • no <EntryPoints> means no ARCH_BOUND_007;
  • entry points only restrict callers outside the owning boundary;
  • internal calls inside the same boundary are unchanged;
  • nested boundaries are cumulative from outermost to innermost;
  • entry points never grant a dependency that <AllowedDependency> would deny.

A door is only meaningful in a wall that already exists.

Selector forms

Each <EntryPoint> uses exactly one selector form:

Form Meaning
layer="Contracts" permit entry through that descendant layer subtree
matcher elements permit entry through dependency types matching <Class>, <Namespace>, or <Assembly>

allowedSites and blockedSites work the same way as on dependency edges.

Example

See Example.Arch_BOUND_007.BoundaryEntryPoints, where Presentation -> Ordering is allowed in general, but only Ordering/Contracts is a valid external entry point.

Source locations

<SourceLocations> lets a layer say where its types are allowed to live on disk. This is separate from layer matching:

  • layer matchers answer "what role does this type have?";
  • source locations answer "does that role live in the right project or folder?"

Source locations validate placement after classification; they do not assign types to layers from folders or projects. See Layer membership and physical layout for the reasoning and the alternatives considered.

Folder structure is the first thing a newcomer reads and among the last things anyone keeps honest. <SourceLocations> checks it instead of relying on convention alone.

Restaurant version:

  • the type may still be a Chef;
  • SourceLocations checks whether that chef is actually in the kitchen, not wandering around the pantry office.

Example:

<Layer name="Ordering">
  <Namespace startsWith="Shop.Ordering" />

  <SourceLocations relativeTo="Project">
    <Source startsWith="Ordering/" />
    <Source startsWith="Contracts/Ordering/" assemblyName="Shop.Contracts" />
  </SourceLocations>
</Layer>

<Source> uses the same textual matcher attributes as other text-based matching:

Attribute Meaning
typeName Exact normalized path text
exactName Exact normalized path text
startsWith Path prefix
endsWith Path suffix
contains Path fragment
regex Regular expression against the normalized path
assemblyName Optional exact compilation assembly name that must also match

Attributes on one <Source> are combined with AND semantics. Separate <Source> elements are alternatives.

relativeTo

Value Base path
Project MSBuildProjectDirectory
Configuration The physical .anl file that declared the rule
Absolute The full normalized source path

Default: Project.

Configuration is only valid for file-based settings. Inline AssemblyMetadata("AnaalIJzerSettings", ...) has no physical settings directory, so that combination reports ARCH_CONF_003.

Partial types

Every declaration of a partial type must satisfy every applicable source-location policy. One correctly placed file does not hide one misplaced file.

Nested layers

Source-location policies are cumulative through ancestry:

  • a parent layer policy still applies to child layers;
  • a child can add more specific ownership rules;
  • a child cannot relax a parent source-location rule.

Example

See Example.SourceLocations, where one SweetShop.Ordering type is correctly placed under Ordering/ and another is deliberately misplaced under Infrastructure/.

Observed dependency cycles

enforceObservedAcyclic="true" tells AnaalIJzer to inspect the dependencies that actually occur in source code and fail when those observed edges form a cycle.

This is different from enforceAcyclic:

  • enforceAcyclic checks the configured <AllowedDependency> graph;
  • enforceObservedAcyclic checks the dependencies the code is currently using.

Example:

<ArchitecturalLevels enforceObservedAcyclic="true">
  <Layer name="Ordering">
    <Namespace startsWith="Shop.Ordering" />
  </Layer>

  <Layer name="Notifications">
    <Namespace startsWith="Shop.Notifications" />
  </Layer>

  <AllowedDependency from="Ordering" to="Notifications" />
  <AllowedDependency from="Notifications" to="Ordering" />
</ArchitecturalLevels>

That configuration is still legal as a configured graph. It only becomes ARCH_DEP_006 when code really uses both directions and closes the cycle. Permission for two layers to talk both ways is cheap; a codebase where neither can be changed without the other is the expensive part.

Restaurant version:

  • the restaurant manual may allow the waiter and the chef to talk both ways;
  • enforceObservedAcyclic asks whether the current staff behavior has actually turned that into a loop where each role now waits on the next.

Behavior:

  • default is false;
  • accepted values are true, false, 1, and 0;
  • invalid values report ARCH_CONF_003 and disable observed-cycle enforcement;
  • a project build only sees cycles inside that compilation;
  • arse inspect --solution can also find cross-project observed cycles.

See Example.Arch_DEP_006.ObservedCycle.

requireRecognizedDependencies attribute

requireRecognizedDependencies is a comma-separated list of dependency sites. At each listed site, a dependency used by a layered caller must itself belong to a configured layer. An unknown type reports ARCH_DEP_002. When the attribute is omitted, unknown types do not report ARCH_DEP_002 - otherwise a brand-new config would flag every framework type in the project before lunch, and be switched off shortly after.

The attribute can be placed on <ArchitecturalLevels> or on a <Layer>:

  • On <ArchitecturalLevels>, it applies to every layered caller.
  • On <Layer>, it applies only to callers classified into that layer or one of its descendants.
  • Root, parent-layer, and child-layer site lists are cumulative.
<ArchitecturalLevels requireRecognizedDependencies="Constructor, Local">
  ...
</ArchitecturalLevels>

The values are trimmed and case-insensitive. Supported values are Constructor, Method, MethodReturn, Field, Property, Local, New, GenericInvocation, GenericArgument, Inheritance, InterfaceImplementation, Attribute, and StaticMember. Empty or unknown values make the configuration invalid and report ARCH_CONF_003.

Example projects: Example.Arch_DEP_002.UnrecognizedDependency, Example.RequiredRecognizedDependencySites, Example.LayerScopedRecognizedDependencies

Rule: The configured site determines where an unknown dependency is an error.

flowchart LR
    Chef --> Pantry
    Chef -. "ARCH_DEP_002 at Constructor" .-> Mystery["MysteryBox<br/>no configured layer"]
<ArchitecturalLevels requireRecognizedDependencies="Constructor">
  <Layer name="Chef"><Class endsWith="Chef" /></Layer>
  <Layer name="Pantry"><Class endsWith="Pantry" /></Layer>
  <AllowedDependency from="Chef" to="Pantry" />
</ArchitecturalLevels>
// Chef -> Pantry is recognized and allowed.
public class PizzaChef(IIngredientPantry pantry) { }

// ARCH_DEP_002 at Constructor: MysteryBox belongs to no configured layer.
public class ExperimentalChef(MysteryBox box) { }

For partial adoption, keep the root loose and require recognized dependencies only inside a stricter layer:

<ArchitecturalLevels>
  <Layer name="LegacyKitchen">
    <Class typeName="LegacyChef" />
  </Layer>

  <Layer name="AuditedKitchen" requireRecognizedDependencies="Constructor">
    <Class typeName="AuditedChef" />
  </Layer>
</ArchitecturalLevels>
// Valid: LegacyKitchen does not require unknown constructor dependencies yet.
public class LegacyChef(MysteryBox box) { }

// ARCH_DEP_002: AuditedKitchen requires constructor dependencies to be classified.
public class AuditedChef(MysteryBox box) { }

This setting controls whether ARCH_DEP_002 is produced, not its severity. Use Roslyn's standard .editorconfig mechanism to show it as a warning:

[*.cs]
dotnet_diagnostic.ARCH_DEP_002.severity = warning

enableReport / reportPath attributes

When enableReport="true" is set on <ArchitecturalLevels>, Arse uses reportPath as the default output for arse report. Fixing the path in configuration keeps CI and local runs writing to the same file, instead of two reports disagreeing with each other from different folders. The path is resolved relative to the config file; for inline AssemblyMetadata("AnaalIJzerSettings", ...), it is resolved relative to the project file. If omitted, Arse defaults to architectural-violations.md next to the project. Solution-level reports use the first configured project as the representative settings source; if no reportPath is enabled there, Arse writes architectural-violations.md next to the solution.

<ArchitecturalLevels enableReport="true"
                     reportPath="../../docs/architectural-violations.md">
  …
</ArchitecturalLevels>

enableDocumentation / documentationPath attributes

When enableDocumentation="true" is set, Arse uses documentationPath as the default output for arse documentation. The generated Markdown contains Mermaid dependency diagrams, site-filter labels, allowed and forbidden type-policy summaries with their scopes, and the XML rules with their descriptions in configuration order. Path resolution mirrors reportPath; the default is architecture-documentation.md next to the project. Generated documentation is only accurate while it is still being generated, which is exactly why the path belongs in configuration rather than in someone's shell history.

<ArchitecturalLevels enableDocumentation="true"
                     documentationPath="../../docs/architecture-documentation.md"
                     description="Order-processing boundaries and query-surface rules.">
  …
</ArchitecturalLevels>

description attributes

Every XML element that participates in the ruleset can carry a description attribute. That includes:

  • Structure
    • <ArchitecturalLevels>, <Include>, and <Layer>;
  • Matchers and exceptions
    • <Class>, <Namespace>, <Assembly>, <Type>, <NestedType>, <ContainingType>, <Member>, <Name>, <Source>, <Target>, <Exceptions>, and <Fix>;
  • Type and dependency policies
    • <Allowed>, <Forbidden>, <AllowedDependency>, <BlockedDependency>, <ApiSurface>, <AllowedLayer>, and <BlockedLayer>;
  • Namespace and operation contracts
    • <NamespaceHierarchyPolicy>, <BlockedRelation>, <Operations>, <Operation>, <Owner>, <Request>, <Response>, and <EntryPoint>;
  • Name, visibility, inheritance, and return policies
    • <NameRules>, <RequireMatchingNames>, <RequireDeclarationNameMatchesType>, <Allow>, <VisibilityPolicy>, <InheritancePolicy>, <ReturnValuePolicy>, and <AllowedReturn>;
  • Operation policies
    • <ForbiddenOperations>, <ForbiddenOperation>, <BehavioralOperations>, <RequiredOperation>, <RequiredOperationBefore>, <ForbiddenOperationAfter>, <MaximumOperationCount>, <DeclarationMatcher>, <BeforeOperation>, <AfterOperation>, and <OperationMatcher>;
  • Declaration matchers
    • <Constructor>, <Method>, <Property>, <Field>, <Event>, <Operator>, and <Conversion>.

Descriptions do not affect diagnostics. They are the cheapest place to record intent: without one, a future reviewer has to guess why a rule exists, and guesswork usually resolves in favour of deleting it.

<Layer name="QuerySurface"
       description="Repository-owned fluent query builders that must be projected before leaving repository-owned code.">
  <Class endsWith="Query"
         description="Query objects are transient access points, not application dependencies." />
</Layer>

<AllowedDependency from="Persistence" to="QuerySurface"
                   allowedSites="MethodReturn, New"
                   description="Repositories may create and return query surfaces as fluent access points." />

Example project: Example.DocumentationDemo

<details> <summary>Dependency graph</summary>

<img src="Examples/Documentation/Example.DocumentationDemo/Example.DocumentationDemo-Graph.png" alt="Example.DocumentationDemo dependency graph">

</details>


Diagnostics

The analyzer ships with twenty-nine compiler diagnostic IDs. IDs follow ARCH_<CONCERN>_<REASON>: the concern names the policy family and the shared three-digit reason identifies the kind of failure. Dependency, name-rule, API-surface, return-value, operation-policy, and namespace-hierarchy diagnostics expose their syntactic site through the Site property where applicable.

ID Meaning
ARCH_DEP_001 Illegal layer dependency - no <AllowedDependency> edge permits this site
ARCH_DEP_002 Dependency is unrecognized at a required site
ARCH_TYPE_001 Type violates an applicable <Allowed> or <Forbidden> policy
ARCH_DEP_004 Wrong-direction dependency - reverse of a configured edge
ARCH_DEP_005 Same-layer dependency
ARCH_CONF_003 Invalid architecture configuration
ARCH_CONF_006 Cyclic allowed-dependency graph while enforceAcyclic is enabled
ARCH_NAME_008 Name rule violation
ARCH_API_001 Externally visible API exposes a type rejected by its layer policy
ARCH_PROJ_001 Direct project reference violates ProjectArchitecture
ARCH_PKG_001 Direct package reference violates ProjectArchitecture
ARCH_VIS_001 Declared accessibility violates a layer visibility policy
ARCH_CONT_008 Contract declaration shape violates a layer contract policy
ARCH_API_010 Allowed API root transitively exposes a type rejected by its layer policy
ARCH_SRC_007 Layer source declaration is outside an allowed source location
ARCH_BOUND_007 Dependency enters a boundary through a disallowed entry point
ARCH_EXC_009 Architecture exception metadata, expiry, or stale state requires review
ARCH_DEP_006 Observed source dependencies form a cycle between configured layers
ARCH_INH_001 Declared base type or implemented interfaces violate a layer inheritance policy
ARCH_RET_001 A direct returned expression violates a layer return-value policy
ARCH_OPER_001 A selected resolved operation violates a layer forbidden-operation policy
ARCH_OPER_002 A required operation is absent or does not dominate every exit
ARCH_OPER_011 A selected operation exceeds its configured maximum count
ARCH_OPER_012 Selected operations violate configured ordering
ARCH_OPCT_001 An operation-contract participant belongs to a disallowed layer
ARCH_OPCT_002 A required request or owner invocation is missing
ARCH_OPCT_008 An operation-contract response shape does not match
ARCH_ASSM_001 A compiled assembly attribute violates an AssemblyAttributePolicy
ARCH_NS_007 A source namespace relationship violates a NamespaceHierarchyPolicy

The example projects referenced below are self-contained and deliberately broken so Visual Studio, Rider, and dotnet build show the corresponding ARCH_<CONCERN>_<REASON> error. They fail on purpose; the repository is not having a bad day.

Examples in Visual Studio

Why three IDs for layering instead of one?

The original design folded every layering problem under ARCH_DEP_001. The three reasons are independent and call for different remediation:

  • Missing or site-filtered edge (ARCH_DEP_001) - most often a real architectural mistake, or a sign the configuration is incomplete. Fix the dependency, add an <AllowedDependency> edge, or adjust the edge's allowedSites / blockedSites.
  • Wrong direction (ARCH_DEP_004) - almost always a real architectural mistake. The fix is usually inversion of control (introduce an abstraction in the lower layer), never adding a reverse edge.
  • Same layer (ARCH_DEP_005) - sometimes intentional (helper types collaborating within a layer). Many teams want to suppress this category project-wide while keeping ARCH_DEP_001 and ARCH_DEP_004 as errors.

Splitting the IDs makes the three policies independently configurable in .editorconfig or <NoWarn>, exposes the reason directly in the IDE error list without parsing the message, and makes the architectural intent of each rule self-documenting. A single shared ID is easier to implement and much harder to triage: "layering error, one of three unrelated causes" is not a useful line to meet in a build log.

ARCH_DEP_001 - Illegal layer dependency

Reported when a type in layer A depends on a type in layer B, no <AllowedDependency from="A" to="B"/> permits the current dependency site, and the violation is neither a wrong-direction nor a same-layer case (those have their own IDs).

Example output:

error ARCH_DEP_001: 'ImpatientCustomer' (layer Customer) may not depend on 'IChef'
  (layer Chef): no <AllowedDependency from="Customer" to="Chef"/> is configured

If an edge exists but a site filter excludes the current site, the diagnostic names that instead - the most common surprise on this rule, because the dependency itself is permitted, just not in that shape:

error ARCH_DEP_001: 'AllowedLocalSiteExample' (layer Caller) may not depend on 'AllowedLocalType'
  (layer AllowedLocalDependency): <AllowedDependency from="Caller" to="AllowedLocalDependency"/> is configured,
  but allowedSites does not include Constructor

Example project: Example.Arch_DEP_001.SkipsLayer

Rule: Customer -> Waiter -> Chef is allowed; direct Customer -> Chef is not.

flowchart LR
    Customer --> Waiter --> Chef
    Customer -. "bad: bypasses Waiter" .-> Chef
<AllowedDependency from="Customer" to="Waiter" />
<AllowedDependency from="Waiter" to="Chef" />

// Customer -> Waiter is allowed.
public class HungryCustomer(IWaiter waiter) { }

// ARCH_DEP_001: Customer -> Chef has no AllowedDependency edge.
// A customer should ask a waiter rather than direct the chef.
public class ImpatientCustomer(IChef chef) { }

Example project: Example.Arch_DEP_001.NoEdge

Rule: Customer -> Waiter -> Chef is allowed, but no edge permits Waiter -> Pantry.

flowchart LR
    Customer --> Waiter --> Chef
    Waiter -. "bad: no Pantry edge" .-> Pantry
<AllowedDependency from="Customer" to="Waiter" />
<AllowedDependency from="Waiter" to="Chef" />

// Customer -> Waiter is allowed.
public class HungryCustomer(IWaiter waiter) { }

// Waiter -> Chef is allowed, but Waiter -> Pantry is not.
// The waiter passes the order to the chef rather than entering the pantry.
public class TableWaiter(IChef chef, IIngredientPantry pantry) { }
Real-world uses
  • Keep an HTTP endpoint from injecting DbContext or a repository directly when the application service owns the use case.
  • Stop a domain or application type from calling an email, queue, or file-system adapter without going through the configured boundary.

ARCH_DEP_002 - Unrecognized dependency

Reported when a layered type uses a dependency that does not belong to any configured layer and root-level or caller-layer requireRecognizedDependencies includes the current site.

Example output:

error ARCH_DEP_002: 'ExperimentalChef' (layer Chef) depends on 'MysteryBox'
  which is not assigned to any architectural layer
Choose recognition sites deliberately

Constructor is a useful starting point when the goal is to close the injection graph without forcing DTOs and method data into architectural layers. Add other sites only when those references are part of the boundary you want to enforce.

Consider a mapper method on an Application type:

// OrderService is in the Application layer.
public class OrderService(IOrderRepository repository)
{
    public OrderDto Map(OrderRecord record, OrderStatus status)
    {
        return new OrderDto { Id = record.Id, Status = status.ToString() };
    }
}

With requireRecognizedDependencies="Constructor", only IOrderRepository must be classified. With requireRecognizedDependencies="Constructor, Method, MethodReturn, New", OrderRecord, OrderStatus, and OrderDto must also belong to configured layers because they appear at selected sites. Whether that counts as enforcement or as paperwork depends entirely on whether those types are genuinely part of the boundary you are defending.

At the root, the setting is site-scoped for every layered caller. On a layer, it is site-scoped for callers in that layer and its descendants, which is useful when only one area of a legacy codebase is ready to require fully classified dependencies. Recognized dependencies still pass through normal type policies and layer-edge rules.

IDE code fixes

When the dependency really does belong to a known role, the IDE can classify it into an existing layer by adding an exact <Class typeName="..."/> matcher. When the site was enforced too aggressively, the IDE can also remove the current site from requireRecognizedDependencies either globally or for the current caller layer.

Real-world uses
  • Close a newly hardened constructor-injection boundary so an unclassified vendor client or SDK cannot slip into it unnoticed.
  • Gradually require new code in one legacy area to classify architectural dependencies, without forcing the rest of the solution to do so yet.

enforceAcyclic attribute

Set enforceAcyclic="true" to require explicit allowed dependency edges to form an acyclic graph. A cycle reports ARCH_CONF_006 before code needs to use every permitted direction, on the grounds that a cycle you have merely authorised is still a cycle waiting for a deadline to discover it:

<ArchitecturalLevels enforceAcyclic="true">
  <AllowedDependency from="Ordering" to="Inventory" />
  <AllowedDependency from="Inventory" to="Billing" />
  <AllowedDependency from="Billing" to="Ordering" />
</ArchitecturalLevels>

Wildcard and self-edges are excluded because they do not describe a finite directional chain. An unfiltered matching <BlockedDependency> removes blocked directions from cycle evaluation.

Example project: Example.Arch_CONF_006.CyclicGraph

ARCH_TYPE_001 - Type policy violation

Reported when a dependency type matches an applicable <Forbidden> pattern or does not match an applicable <Allowed> list. The two causes read similarly in an error list but mean different things: one type is specifically unwelcome, while the other simply never made the guest list. If a <Fix Rename="…"> is configured on a forbidden pattern, Visual Studio and Rider offer a rename code fix. Forbidden-rule matches can also add the type to that rule's <Exceptions> block. For allow-list failures, the IDE can add an exact <Class typeName="..."/> matcher to every applicable <Allowed> list.

Example output:

error ARCH_TYPE_001: 'ReportingService' (layer Application) may not use 'LegacyOrderStore':
  the type matches a global <Forbidden> rule: Persistence types must use the Repository suffix.
Real-world uses
  • Require persistence abstractions to use a Repository convention and reject legacy Store or Manager types at the dependency site.
  • Keep selected framework types, such as EF Core attributes or transport DTOs, out of a domain boundary with a scoped <Forbidden> policy.

ARCH_DEP_004 - Wrong-direction dependency

Reported when a type in layer A depends on a type in layer B and <AllowedDependency from="B" to="A"/> is configured - i.e. the dependency runs in the reverse direction of a configured edge. It gets its own ID because adding the reverse edge is such an inviting fix and so seldom the correct one.

Example output:

error ARCH_DEP_004: 'IngredientPantry' (layer Pantry) may not depend on 'IChef'
  (layer Chef): this is the reverse of the configured 'Chef -> Pantry' edge

Example project: Example.Arch_DEP_004.WrongDirection

Rule: The allowed edge is Chef -> Pantry. Depending in the reverse direction is not allowed.

flowchart LR
    Chef --> Pantry
    Pantry -. "bad: reverses the relationship" .-> Chef
<AllowedDependency from="Chef" to="Pantry" />

// Chef -> Pantry is allowed.
public class PizzaChef(IIngredientPantry pantry) { }

// ARCH_DEP_004: Pantry -> Chef reverses the configured direction.
// The pantry supplies the chef; it does not direct the chef.
public class IngredientPantry(IChef chef) { }
Real-world uses
  • Catch a repository or infrastructure adapter that starts calling an application service to decide what it should persist.
  • Stop a lower-level module from reaching upward into an endpoint, UI, or orchestration layer just because the reverse edge already exists.

ARCH_DEP_005 - Same-layer dependency

Reported when two types in the same layer depend on each other and no self-edge has been configured for that layer. By default peers within a layer are not allowed to take a hard dependency on each other; this is the safest default because intra-layer fan-out tends to grow unnoticed into a web that no one designed and everyone maintains.

To opt a single layer in to same-layer dependencies, declare an explicit self-edge:

<AllowedDependency from="Chef" to="Chef" />

With that edge in place, PizzaChef may depend on ISauceChef (both in Chef) without ARCH_DEP_005 firing. Other layers without a self-edge keep the default prohibition.

Self-edges can also be limited to particular dependency sites. This is useful, but it is not the only way to model an interface and its implementation.

Common use cases

Possible: interface and implementation share one architectural role

The interface and implementation may live in the same project, namespace, or even file. If both intentionally belong to DataAbstraction, allow only inheritance within that layer:

<Layer name="DataAbstraction">
  <Class endsWith="Repository" />
</Layer>

<AllowedDependency from="DataAbstraction"
                   to="DataAbstraction"
                   allowedSites="InterfaceImplementation" />
// Allowed: interface implementation is an InterfaceImplementation-site dependency.
public class ExampleRepository : IExampleRepository { }

// ARCH_DEP_005: Constructor is not allowed by the InterfaceImplementation-only self-edge.
public class ReportingRepository(IExampleRepository repository) { }

ExampleRepository : IExampleRepository is allowed, while constructor, field, property, and method dependencies between repository peers still report ARCH_DEP_005. This deliberately permits inheritance within DataAbstraction; it does not mean interfaces and implementations must share a layer.

Alternative: colocated interfaces and implementations have different architectural roles

For a narrower model, put contracts and implementations in separate architectural layers even when they live in the same project, namespace, or file. Match interfaces first, implementations second, and allow only implementation-to-contract inheritance:

<Layer name="DataContracts">
  <Class endsWith="Repository" typeKind="Interface" />
</Layer>

<Layer name="DataImplementation">
  <Class endsWith="Repository" typeKind="Class" />
</Layer>

<AllowedDependency from="DataImplementation"
                   to="DataContracts"
                   allowedSites="InterfaceImplementation" />

No self-edge is needed: ExampleRepository -> IExampleRepository crosses from DataImplementation to DataContracts at the InterfaceImplementation site. Both rules use the same suffix, while typeKind distinguishes the contract from its implementation without relying on the I naming convention.

Interfaces must come from a dedicated contracts project

Use an assembly matcher when the project boundary itself carries architectural meaning:

<Layer name="DataContracts">
  <Assembly exactName="MyCompany.Data.Abstractions" />
</Layer>

<Layer name="DataImplementation">
  <Class endsWith="Repository" />
</Layer>

<AllowedDependency from="DataImplementation"
                   to="DataContracts"
                   allowedSites="InterfaceImplementation" />

An implementation may now implement a contract from MyCompany.Data.Abstractions. A locally declared IExampleRepository does not match DataContracts; it falls into DataImplementation through the class matcher and still produces ARCH_DEP_005 because no self-edge exists. This enforces the separate-project convention without making it a built-in analyzer opinion.

Add other sites to allowedSites, or omit the site filter, when the implementation project is also intentionally allowed to consume contract types through constructors, methods, or properties. See Site filters and Assembly matchers for the complete options.

None of these structures is built into the analyzer. InterfaceImplementation works for implemented interfaces, while Inheritance works for base classes and interface-to-interface inheritance. The matchers and edges decide which model applies.

Example output:

error ARCH_DEP_005: 'PizzaChef' (layer Chef) may not depend on 'ISauceChef'
  (layer Chef): types in the same layer ('Chef') may not depend on each other

Example projects: Example.Arch_DEP_005.SameLayer, Example.SameLayerInheritance, Example.CombinedMatchers

Rule: By default, types within the same layer may not depend on each other. A layer can opt in to same-layer dependencies by declaring an explicit self-edge: <AllowedDependency from="X" to="X"/>.

flowchart LR
    DessertChef --> Pantry
    PizzaChef -. "bad: commands peer" .-> SauceChef
<Layer name="Chef">
  <Class endsWith="Chef" />
</Layer>
// Chef -> Pantry is allowed.
public class DessertChef(IIngredientPantry pantry) { }

// ARCH_DEP_005: PizzaChef and ISauceChef are both in the Chef layer.
// Chefs may share a pantry, but should not command each other directly.
public class PizzaChef(ISauceChef sauceChef) { }
Real-world uses
  • Prevent an application layer from becoming a mesh of services that constructor-inject one another instead of extracting a clearer collaboration boundary.
  • Permit only an interface implementation relationship within a contracts-and-implementation layer while continuing to reject peer-to-peer runtime dependencies.

ARCH_CONF_003 - Invalid architecture configuration

Reported when settings cannot be evaluated reliably: malformed or schema-invalid XML, missing includes, duplicate layers, invalid or ambiguous matchers, invalid site filters, or dependency rules that reference unknown layers. The analyzer does not become silently inactive when configuration parsing fails, because a misspelled layer name producing the same clean build as a flawless codebase is flattering, but not informative.

Example project: Example.Arch_CONF_003.UnknownLayer

Real-world uses
  • Fail CI when a layer was renamed but an edge, include, or policy still references the old name.
  • Catch a malformed drop-in .anl rule pack before it prevents the intended rules from loading.

ARCH_CONF_006 - Cyclic architecture dependency graph

Reported when enforceAcyclic="true" and the explicit allowed dependency graph contains a cycle. The message prints the detected chain, for example Ordering -> Inventory -> Billing -> Ordering, so the loop does not have to be reconstructed by hand from three rules written on three different days.

Example project: Example.Arch_CONF_006.CyclicGraph

Real-world uses
  • Reject a proposed set of allowed module edges that would let Ordering, Billing, and Inventory depend on one another in a loop.
  • Detect when individually reasonable dependency rules combine into a cyclic architectural policy.

ARCH_NAME_008 - Name rule violation

Reported when a value movement or declaration inside a layer matches an applicable <NameRules> policy, but the compared names do not normalize to the same meaning and no matching <Allow> mapping permits that site. Most findings turn out to be a misleading name rather than wrong behaviour, which is precisely the point: the name is what the next reader believes.

Example message:

'OrderService' (layer Application) violates name rule 'RequireMatchingNames' at Property:
source 'animalId' normalizes to 'animal.id', target 'Customer.Id' normalizes to 'customer.id'

Declaration/type example:

'PatientEndpoint' (layer AspEndpoints) violates name rule
'RequireDeclarationNameMatchesType' at Method: type 'DoctorId' normalizes to
'doctor.id', declaration name 'patientId' normalizes to 'patient.id'

Examples: Example.NameRules, Example.NameRuleLanguageForms, Example.NameRuleIntraProceduralTracking, Example.DeclarationNameMatchesType, and Example.HonestTypeEndpointNames.

Typical fixes:

  • Pass or assign the value with the matching meaning.
  • Rename the local, parameter, field, or property when the code is correct but the name is misleading.
  • Add a narrow <Allow> mapping when the translation is intentional.
  • Scope that mapping with allowedSites or blockedSites when it should only be valid in one kind of code location.
Real-world uses
  • Catch DoctorId patientId on a convention-bound web endpoint before framework model binding connects the right value to the wrong meaning.
  • Detect customerId being passed to an invoiceId parameter when both values are the same primitive type and the compiler cannot distinguish them.

ARCH_API_001 - API surface leakage

ARCH_API_001 means an externally visible declaration exposes a type rejected by the owning layer's <ApiSurface> policy. It tracks the gap between what your code uses and what your callers can see; callers only ever notice the second one.

'CandyOrderingService' (layer Application) exposes 'LollyQueryable'
(layer RepositoryQuerySurface) at MethodReturn: the API surface policy
in layer 'Application' blocks layer '/RepositoryQuerySurface' at MethodReturn

The diagnostic is reported on the exposed type syntax. Its properties include the caller and exposed type/layer, canonical API Site, ApiMemberName, exact denial reason, and the originating configuration location.

Common fixes are:

  • project the internal type to a contract before returning it;
  • make the declaration non-public when it is an implementation detail;
  • add the exposed type to the intended contract layer;
  • deliberately adjust the <ApiSurface> policy or its site filter.
IDE code fixes

The IDE can add a missing <AllowedLayer>, widen an existing allowedSites list, relax blockedSites, and when the denial comes from requireRecognizedTypes="true" it can disable that requirement for the policy.

Adding an <AllowedDependency> is not an ARCH_API_001 fix by itself. That edge permits internal use; it does not grant permission to publish the type as API.

Example project: Example.Arch_API_001.ApiSurfaceLeakage

Real-world uses
  • Prevent a public application method or endpoint from returning IQueryable<T>, leaving callers coupled to a repository-owned query mechanism.
  • Stop a service contract from exposing EF entities, internal transport models, or persistence-only abstractions as part of its public API.

ARCH_PROJ_001: Project Reference Violation

ARCH_PROJ_001 reports an illegal direct project reference.

This is a project-topology rule, not a type-usage rule.

Example

Project 'Shop.Web' (project group Presentation) may not reference project 'Shop.Domain' (project group Domain): no AllowedProjectReference permits project group 'Presentation' to reference project group 'Domain'

Typical Causes

  • a direct <ProjectReference /> skips an allowed project group
  • a project reference points in the wrong architectural direction
  • a blocked project edge is present
  • a project-specific <From> or <To> selector narrows a group edge away from this project pair
  • requireRecognizedProjects="true" is enabled and one side matches no ProjectGroup
  • a same-group reference exists without an explicit self-edge while that source group is in allowlist mode

Typical Fixes

  • remove the illegal <ProjectReference />
  • move the shared abstraction into an allowed project group
  • add the missing allowed project edge if the topology is intentional
  • add a narrow exact-project edge when only this project pair is intentional
  • classify the unrecognized project with a ProjectGroup
  • add an explicit self-edge if same-group references are intentionally allowed

IDE Fix Support

When both project groups are already recognized, the config fixer layer can:

  • add the missing <AllowedProjectReference from="..." to="..." />
  • add a narrow <AllowedProjectReference> with exact <From> and <To> project selectors
  • add an explicit same-group self-edge
  • remove the matching blocking <BlockedProjectReference ... />

Because ARCH_PROJ_001 is reported at compilation end, whether that action appears as a normal editor light bulb depends on the host. The edit logic itself is tested in ProjectArchitectureCodeFixTests.cs.

Not The Same As

  • ARCH_DEP_001: illegal type dependency in code
  • ARCH_DEP_004: wrong-direction type dependency in code
  • ARCH_DEP_005: same-layer type dependency in code

ARCH_PROJ_001 can fire even when no source file currently uses the referenced project. That is intentional: an unused reference is a standing invitation, and someone eventually accepts it.

Real-world uses

  • Keep a Web or UI project from adding a direct reference to Infrastructure when Application is the intended crossing point.
  • Enforce that a Domain project never references a database, messaging, or hosting project even when no C# type has been used from it yet.

ARCH_PKG_001: Package Reference Violation

ARCH_PKG_001 reports an illegal NuGet package reference under ProjectArchitecture.

This is a project-topology and dependency-policy rule, not a type-usage rule.

Example

Project 'Shop.Domain' (project group Domain) may not reference package 'Microsoft.Extensions.Logging' 9.0.0: the package does not match the Allowed package list for project group 'Domain'

Typical Causes

  • a project group has an allow-list package policy and the package does not match any allowed matcher
  • a package matches a forbidden package matcher
  • requireRecognizedProjects="true" is enabled and the source project matches no ProjectGroup
  • a direct package reference is legal but its transitive package is still denied when includeTransitive="true" is active

Typical Fixes

  • remove the package reference from the project
  • move the dependency to a project group where that package belongs
  • widen the allowed package list if the dependency is intentional
  • re-scope the forbidden package matcher if it is too broad
  • classify the source project with a ProjectGroup when recognition is the actual issue

IDE Fix Support

For the deterministic allow-list case, the config fixer layer can append an exact matcher such as:

<Package exactName="Microsoft.Extensions.Logging" />

That fix is only offered when the current violation is specifically an allowed-list miss. If a forbidden matcher rejected the package, the fixer does not guess by weakening that rule - somebody wrote it deliberately, and undoing it should take at least as much thought.

Because ARCH_PKG_001 is reported at compilation end, host UX depends on how the IDE surfaces Location.None diagnostics. The edit logic itself is covered by PackagePolicyCodeFixTests.cs.

Not The Same As

  • ARCH_TYPE_001: forbidden type usage in C# code
  • ARCH_PROJ_001: illegal direct project reference
  • ARCH_DEP_001: illegal type dependency between layers

Real-world uses

  • Prevent a domain project from taking a direct dependency on EF Core, ASP.NET Core, or a concrete database provider.
  • Restrict a sensitive or expensive package to one integration project instead of letting it spread through the solution by copy-pasted package references.

ARCH_VIS_001 - Visibility policy violation

Reported when a source declaration belongs to a layer with an applicable <VisibilityPolicy> and its declared accessibility does not pass that policy. Accessibility is quick to widen under time pressure and slow to narrow again once callers have found it.

Example:

'SourLollyQueryable' (layer RepositoryQuerySurface) is declared Public:
the VisibilityPolicy for Type in layer 'RepositoryQuerySurface' allows only Internal, File

The diagnostic is reported on an accessibility modifier when one exists, otherwise on the declaration identifier.

Diagnostic properties include:

  • CallerTypeName
  • CallerLayerName
  • DeclaredSymbolName
  • DeclarationTarget
  • DeclaredAccessibility
  • ViolationReason
  • the originating rule path, line, and column

Typical fixes:

  • reduce the declaration's accessibility;
  • narrow or change the policy when the public declaration is intentional;
  • move the declaration to a layer whose visibility contract matches its responsibility.
IDE code fixes

For configuration-backed fixes, the IDE can add the reported accessibility to allowedAccessibilities, remove it from blockedAccessibilities, or remove a single-value blocking policy when that is the only thing it does.

Example project: Example.Arch_VIS_001.VisibilityPolicy

Real-world uses
  • Keep repository query builders and persistence helpers internal so they cannot become accidental application contracts.
  • Require implementation-only handlers, factories, or composition-root types to stay hidden even when a developer reaches for public during a refactor.

ARCH_CONT_008 - Contract purity violation

Reported when a source declaration belongs to a layer with an applicable <ContractPolicy> and its declaration shape does not pass that policy. A contract that has acquired setters, state, and a method body is an implementation wearing a contract's job title.

Example:

'Name' (layer Contracts) violates contract purity at DisallowedPropertyAccessor:
the ContractPolicy in layer 'Contracts' allows only property accessors Get

The diagnostic is reported on the offending member, accessor, body, or type identifier, depending on the failing rule.

Diagnostic properties include:

  • CallerTypeName
  • CallerLayerName
  • DeclaredSymbolName
  • ContractViolationKind
  • ViolationReason
  • the originating rule path, line, and column

Typical fixes:

  • remove the implementation body from the contract member;
  • replace mutable setters with get / init;
  • move stateful helpers or concrete implementations out of the contract layer;
  • broaden the policy only when that contract shape is intentional.

Focused example projects:

Real-world uses
  • Keep request, response, and port contracts as simple data or signatures instead of letting mutable state and default implementations leak into them.
  • Enforce a team convention that shared contracts expose getters only, while conversion logic and behavior live in an implementation layer.

ARCH_API_010 - Forbidden transitive exposure

ARCH_API_010 reports when an externally visible declaration exposes an allowed root type whose public object graph reaches a type rejected by the owning layer's <ApiSurface> policy.

public class CandyReceipt
{
    public LollyQueryable RawQuery { get; init; } = new();
}

// ARCH_API_010: CandyOrderingService.OrderRawLolly
//          -> CandyReceipt.RawQuery
//          -> LollyQueryable
public CandyReceipt OrderRawLolly()
{
    return new CandyReceipt();
}

The primary location is the root signature, because that declaration publishes the unsafe graph. When the nested member is source-backed, its declaration is included as an additional diagnostic location.

Diagnostic properties include:

  • ApiMemberName and ExposureRootMember;
  • ExposurePath and ExposureDepth;
  • NestedMemberName and NestedMemberContainingType;
  • the forbidden type and layer;
  • the nested member's canonical Site;
  • the exact policy reason and configuration origin.

A direct forbidden type reports ARCH_API_001 instead. The two diagnostics are deliberately not duplicated; one complaint per leak is sufficient.

Example project: Example.Arch_API_010.TransitiveExposure

Real-world uses

  • Catch a public response DTO that looks harmless at the root but contains an internal query object or persistence entity several properties deeper.
  • Prevent a collection, wrapper, or generic result type from reintroducing an API type that the direct public signature correctly avoided.

ARCH_SRC_007 - Source-location violation

ARCH_SRC_007 means a type matched a layer, but one of its source declarations is not in an allowed owned location for that layer.

Example message:

'CandyOrderingService' belongs to layer 'Ordering/Application' but source file 'Infrastructure/CandyOrderingService.cs' does not match an allowed SourceLocations rule for layer 'Ordering'

Typical causes:

  • the namespace still matches the intended layer, but the file was moved into the wrong folder;
  • a partial type was split across owned and unowned locations;
  • a configuration-relative rule points at the wrong base folder;
  • an assembly-constrained source rule matches the folder but not the project assembly.

Typical fixes:

  • move the file into the owned folder or project;
  • tighten or correct the <SourceLocations> patterns;
  • split mixed-responsibility partial declarations;
  • if the layout is intentional, add an explicit <Source> rule that documents it.
IDE code fix

The IDE can add an exact <Source exactName="..."/> matcher for the reported file path to the owning layer's <SourceLocations> block. For inline metadata config, the assembly attribute is rewritten in place.

Important: folders do not classify layers by themselves. ARCH_SRC_007 only runs after the type has already been matched into a layer by the normal layer matchers. A folder tree is a claim about ownership; this rule is what checks whether the claim is still true.

See Example.SourceLocations for a small build-verified sample.

Real-world uses

  • Keep EF Core migrations and IEntityTypeConfiguration<T> mappings in the persistence folders that own deployment and schema concerns.
  • Detect a feature handler or contract that still matches the right namespace after a file move but now lives under the wrong project or bounded-context folder.

ARCH_BOUND_007 - Boundary entry-point violation

ARCH_BOUND_007 reports when a dependency already passed the normal dependency graph, but still enters a boundary through the wrong child layer or type.

Example message:

'CandyController' (layer Presentation) may not enter boundary 'Ordering' through 'CandyOrderingService' (layer Ordering/Implementation): the boundary permits entry only through Ordering/Contracts

Typical causes:

  • a controller reaches into an implementation layer instead of a contract layer;
  • a facade entry point is allowed only at certain sites, but the dependency appears at a blocked site;
  • nested boundaries define progressively narrower external entry doors.
IDE code fixes

The IDE can add a missing <EntryPoint>, add the current site to an entry point's allowedSites, or remove the current site from blockedSites when the boundary policy is too narrow for the intended call shape.

Important precedence rule:

  • if the dependency is already illegal for the usual reasons, you still get ARCH_DEP_001, ARCH_TYPE_001, ARCH_DEP_004, or ARCH_DEP_005;
  • ARCH_BOUND_007 only appears when the dependency graph allowed the dependency first.

In restaurant terms: you are welcome in the building, just not through the kitchen window.

See Example.Arch_BOUND_007.BoundaryEntryPoints.

Real-world uses

  • Require controllers, jobs, and message consumers to enter an application boundary through its contract or facade layer rather than its implementation classes.
  • Keep plug-in or module consumers on a deliberately small public entry surface even when implementation types are otherwise dependency-legal.

ARCH_EXC_009 - Architecture exception requires review

ARCH_EXC_009 is a warning about the exception itself, not about the original architectural rule.

It appears when an exception matcher:

  • is missing required metadata from <ExceptionPolicy>;
  • has an invalid expiresOn date;
  • has already expired;
  • is close to expiry;
  • or, in Arse health inspection, is stale and matches no type in the inspected scope.

Example warning:

Architecture exception for Class 'typeName="LegacyManager"' is missing required owner metadata

Typical causes:

  1. The config has <ExceptionPolicy requireOwner="true" />, but the exception has no owner.
  2. The exception expired before Sunday, July 26, 2026.
  3. The exception was once needed, but the type it named no longer exists.

Important semantics:

  • Missing or invalid required metadata makes the exception inactive.
  • Expired exceptions are ignored by the matcher engine.
  • If an expired exception used to suppress another diagnostic, that original diagnostic can reappear.
  • Expiring-soon exceptions still suppress the original diagnostic until their expiry date.

A stale exception naming a type that was deleted two refactors ago protects nothing; it only makes the config longer and the next reader more nervous.

The warning carries these properties:

  • ExceptionMatcherKind
  • ExceptionMatcherLabel
  • ExceptionReason
  • ExceptionOwner
  • ExceptionExpiresOn
  • ExceptionStatus

See also:

Real-world uses

  • Make a temporary legacy dependency exception name an owner and expiry date, so it has a route back to normal architecture rather than becoming permanent configuration sediment.
  • Surface an exception for a type that was deleted or moved, so stale suppression rules do not make reviewers wonder what they still protect.

ARCH_DEP_006 - Observed architectural dependency cycle

ARCH_DEP_006 reports when the dependencies that currently exist in source code form a cycle between configured layers.

Example message:

Observed architectural dependency cycle: Ordering -> Notifications -> Ordering

This is intentionally different from ARCH_CONF_006:

  • ARCH_CONF_006 says the configuration permits a cycle;
  • ARCH_DEP_006 says the code is currently using a cycle.

Typical causes:

  • two architectural areas have started calling each other directly over time;
  • one boundary grew a convenience reverse dependency;
  • both directions are allowed, but the current code reality has become circular.

Nobody sets out to design a cycle. A cycle is what is left over after several individually reasonable decisions.

Important behavior:

  • ARCH_DEP_006 only appears when enforceObservedAcyclic="true" is enabled;
  • the cycle is built from observed source dependency sites, not from hypothetical allowed edges;
  • direct diagnostics such as ARCH_DEP_001 and ARCH_DEP_004 still report separately;
  • arse inspect --solution can find cross-project observed cycles that one project build cannot see by itself.

See Example.Arch_DEP_006.ObservedCycle.

Real-world uses

  • Reveal that an Order module calls Notifications and Notifications now calls Order back, even though both directions were once allowed separately.
  • Find a solution-wide cycle introduced by cross-project source dependencies before it turns into a deployment or testing knot.

ARCH_INH_001 - Inheritance policy violation

Reported when a source declaration belongs to a layer with an applicable <InheritancePolicy> and its declared base-type or interface contract does not pass that policy.

Example:

'SyrupEntity' (layer PersistenceEntities) violates inheritance policy at MissingRequiredBaseType:
the InheritancePolicy in layer 'PersistenceEntities' requires one of base types Entity

The diagnostic is reported on the declaration identifier. The type is not wrong where it is used; it is wrong where it is declared, so that is where the squiggle goes.

Diagnostic properties include:

  • CallerTypeName
  • CallerLayerName
  • DeclaredSymbolName
  • InheritanceViolationKind
  • ViolationReason
  • the originating rule path, line, and column

Typical fixes:

  • inherit the required base type;
  • implement the missing interface contract;
  • move the declaration out of the layer if it is not meant to follow that shared inheritance rule;
  • narrow or broaden the policy only when the declaration is intentionally outside the current contract.

Example project: Example.Arch_INH_001.InheritancePolicy

Real-world uses
  • Require every persistence entity in a selected layer to inherit the team’s shared Entity base type.
  • Require selected handlers, commands, or plug-ins to implement the common interface that their host expects before they can enter that layer.

ARCH_RET_001 - Return-value policy violation

Reported when a method has an applicable global or layer-scoped <ReturnValuePolicy> and returns a direct expression rejected by either a forbidden matcher or an <AllowedReturn> shape allow-list. A global policy also applies when the containing type is not assigned to a layer.

Example:

'PrepareMysteryPizza' (layer Kitchen) violates return-value policy at MethodReturn:
the ReturnValuePolicy in layer 'Kitchen' blocks returned literal value="null"

The diagnostic is reported on the return expression. It can cover null, an empty string, a numeric or enum sentinel, a specific member access, object creation, a direct call selected by semantic matcher attributes, or any shape omitted from an <AllowedReturn> block.

Diagnostic properties include:

  • CallerTypeName
  • CallerLayerName
  • DeclaredSymbolName
  • Site (MethodReturn)
  • ReturnValueRuleTarget
  • ReturnValueRule
  • ReturnValueRuleMode (Forbidden for a matching forbidden child, Allowed for a value omitted from an allow-list)
  • ViolationReason
  • the originating rule path, line, and column

Typical fixes:

  • return a meaningful value rather than the configured sentinel;
  • turn an optional lookup into an explicit fallback or error result before returning it;
  • assign a direct invocation to a named result before returning it when the policy requires <AllowedReturn><Identifier /></AllowedReturn>;
  • move the method outside the layer only when the policy is layer-scoped; a global policy intentionally follows every method;
  • narrow the policy only when that direct return is intentionally allowed.

There is no automatic code fix. The policy tells AnaalIJzer which return expression is unacceptable; it cannot know which domain-specific value, result type, variable name, fallback, or exception behavior is correct. The analyzer recognises the rejected shape; it has no opinion about what your domain should say instead.

Focused examples: Example.Arch_RET_001.ExplicitNullReturn, Example.Arch_RET_001.AnnotatedInvocationReturn, Example.Arch_RET_001.ConfiguredLiteralReturns, Example.Arch_RET_001.DirectInvocationReturn, and Example.GlobalReturnValuePolicy.

Real-world uses
  • Stop a service layer from returning null, string.Empty, zero, or a known enum sentinel as an undeclared “not found” signal.
  • Require a nullable third-party call to become a domain fallback, Result, or other explicit outcome before it escapes a selected boundary.
  • Require a method to return through a named hand-off point so the layer has a natural place for inspection, logging, normalization, or a later handling rule.

ARCH_OPER_001 - Forbidden operation policy violation

Reported when code in a layer with an applicable <ForbiddenOperations> policy uses a selected resolved operation.

Example:

'PizzaKitchen' (layer Kitchen) may not use operation 'System.DateTime.UtcNow' at StaticMember:
the ForbiddenOperations policy in layer 'Kitchen' blocks System.DateTime.UtcNow at StaticMember

The diagnostic is reported on the selected source expression. It is symbol-based rather than text-based, so using Clock = System.DateTime; Clock.UtcNow and global::System.DateTime.UtcNow are the same configured operation.

Diagnostic properties include:

  • CallerTypeName
  • CallerLayerName
  • DeclaredSymbolName
  • Site
  • OperationKind
  • OperationDisplayName
  • OperationPolicyRule
  • ViolationReason
  • the originating rule path, line, and column

Typical fixes:

  • inject or explicitly pass an adapter, such as a restaurant clock, instead of reading a framework static member;
  • use the intended asynchronous API instead of blocking with Task.Wait() or Task<T>.Result;
  • move service resolution to the composition root rather than locating a dependency in application code;
  • narrow the policy only when that exact operation is intentionally allowed in the owning layer.

There is no automatic code fix. AnaalIJzer can identify the selected forbidden operation, but the correct architectural replacement belongs to the application.

Focused examples: Example.Arch_OPER_001.ClockAccess, Example.Arch_OPER_001.BlockingTaskAccess, Example.Arch_OPER_001.ServiceLocation, and Example.Arch_OPER_001.SelectedEnvironmentMember.

Real-world uses
  • Force application code to receive time through a clock abstraction, keeping business decisions deterministic in tests.
  • Prevent blocking Task.Result, Task.Wait(), or service-location calls from appearing in request-handling code where they hide dependencies or cause scalability problems.

ARCH_OPER_002 - Required operation missing

Reported when a declaration body in a layer with an applicable <BehavioralOperations> policy does not contain a configured required operation, or the operation does not dominate every exit when dominance is required.

Example:

'PizzaKitchen' (layer Kitchen) violates behavioral-operation policy 'required Invocation operation' at StaticMember:
the BehavioralOperations policy in layer 'Kitchen' requires required Invocation operation before PizzaOven.Bake in declaration 'PizzaKitchen.PrepareMysteryPizza()'

The diagnostic is attached to the owning declaration because no missing operation has a source span to underline.

Diagnostic properties include:

  • CallerTypeName
  • CallerLayerName
  • DeclaredSymbolName
  • Site
  • OperationKind
  • OperationDisplayName
  • OperationPolicyRule
  • BehavioralOperationViolationKind
  • BehavioralOperationOrdering
  • ViolationReason
  • the originating rule path, line, and column

Typical responses are to add the required domain operation, make it execute on every relevant control-flow path, or narrow the declaration/operation matcher when the policy selected more code than intended.

There is no automatic code fix. The configuration can identify a mechanically provable violation, but it cannot decide whether the correct repair is a validation call, a different workflow, an idempotency guard, a separate operation, or a broader design change.

See behavioral operation policies for exact semantics, especially the difference between Dominance and Lexical ordering.

Focused example: Example.Arch_OPER_002.RequiredOperation.

Real-world uses
  • Require validation, authorization, or idempotency checking somewhere on every path through a selected operation.
  • Require an audit, persistence, or publication step before a workflow can return.

ARCH_OPER_011 - Operation cardinality

Reported when a selected declaration contains more occurrences of an operation than a configured <MaximumOperationCount> permits.

The diagnostic is attached to the first occurrence beyond the maximum, so the highlighted source is the operation that made the count invalid.

Typical responses are to remove an accidental duplicate, introduce an idempotent workflow, narrow the matcher, or intentionally revise the maximum.

There is no automatic code fix because deciding which occurrence is redundant is a domain decision.

See behavioral operation policies for matcher and counting semantics.

Focused example: Example.Arch_OPER_011.MaximumOperationCount.

Real-world uses
  • Ensure a payment, message publication, or transaction commit happens at most once.
  • Prevent duplicate audit writes or repeated calls to a non-idempotent external operation.

ARCH_OPER_012 - Operation ordering

Reported when a declaration violates <RequiredOperationBefore> or <ForbiddenOperationAfter>.

The diagnostic is attached to the selected operation at the invalid position: for example, a save without prior validation or a mutation after a terminal commit.

Typical responses are to move or add the required earlier operation, remove a forbidden later operation, or narrow the declaration and operation matchers.

There is no automatic code fix because reordering side effects can change program behavior.

See behavioral operation policies, especially the difference between dominance and lexical ordering.

Focused examples: Example.Arch_OPER_012.RequiredOperationBefore and Example.Arch_OPER_012.ForbiddenOperationAfter.

Real-world uses
  • Require authorization or validation before persistence, publication, or payment.
  • Prevent source mutations, logging, or outbound calls after a configured terminal operation.

ARCH_OPCT_001 - Operation-contract participant not allowed

ARCH_OPCT_001 means a selected owner or entry-point method belongs to a layer that is not listed in allowedOwnerLayers or allowedEntryPointLayers.

The diagnostic is attached to the selected declaration and includes the operation name, participant role, effective layer, and configuration location.

There is no automatic code fix because moving a declaration or changing an operation's ownership is an explicit architecture decision.

Example: Example.Arch_OPCT_001.ParticipantNotAllowed

Real-world uses

  • Keep HTTP controllers as operation entry points while application services remain the owners.
  • Prevent infrastructure or presentation code from becoming the configured owner of a business operation.

ARCH_OPCT_002 - Required operation-contract participant missing

ARCH_OPCT_002 means a method selected by an explicit root-level <Operations> rule is missing a configured request parameter or a selected entry point does not invoke the configured owner.

Violation kind Meaning
OwnerMissingRequest The selected owner has no parameter matching <Request>.
EntryPointMissingRequest The selected entry point has no parameter matching <Request>.
EntryPointDoesNotInvokeOwner The selected entry point does not directly call the selected owner in its own body.

The diagnostic properties include the operation name, participant role, violation kind, and configuration location. A separate workspace finding covers missing or ambiguous owners across a project or solution.

There is deliberately no automatic code fix. The analyzer can show which declared source fact is missing, but it cannot safely decide which service should own a workflow or how a response should be reshaped.

Example: Example.Arch_OPCT_002.RequiredOwnerInvocation

Real-world uses

  • Require a web endpoint, scheduled job, or message consumer to call the designated application operation with the intended request and response shapes.
  • Prevent a workflow from quietly moving into a controller or worker when the configured application owner is supposed to remain its single entry point.

ARCH_OPCT_008 - Operation-contract shape mismatch

ARCH_OPCT_008 means a selected owner or entry point returns a type that does not match the operation's configured <Response> shape.

The diagnostic is attached to the selected declaration and records whether the owner or entry point failed the response check.

There is no automatic code fix because converting a response contract can require mapping, error handling, and domain-specific data selection.

Example: Example.Arch_OPCT_008.ResponseShapeMismatch

Real-world uses

  • Keep controllers, consumers, and application owners aligned on one explicit response contract.
  • Prevent an operation owner from leaking a raw persistence or framework response type.

ARCH_ASSM_001 - Assembly attribute policy violation

ARCH_ASSM_001 means an attribute emitted on the current assembly matches a root-level <AssemblyAttributePolicy> rule that does not permit it.

The policy examines final semantic assembly metadata. It therefore catches both a C# declaration such as [assembly: InternalsVisibleTo("OtherAssembly")] and an SDK item such as <InternalsVisibleTo Include="OtherAssembly" /> that produces the same attribute during compilation.

The diagnostic identifies the current assembly, the fully qualified attribute type, the matching policy rule, and the policy reason. SDK-generated attributes may not have a useful source location; the diagnostic remains a compilation result because the forbidden metadata is still real.

Real-world uses

  • Limit InternalsVisibleTo grants to approved test, migration, or companion assemblies.
  • Prevent a compliance, runtime, or plugin-registration attribute from being attached with an unapproved argument value.
  • Require selected assembly metadata attributes to use a known publisher, capability, or environment value.
  • Keep equivalent handwritten and SDK-generated assembly metadata under one policy instead of maintaining separate source and project-file checks.

There is deliberately no automatic code fix. The analyzer can identify the rejected metadata, but it cannot decide whether to remove an assembly friend, change the project setting that generated it, or widen the policy.

Examples:

ARCH_NS_007 - Namespace hierarchy dependency violation

ARCH_NS_007 means a resolved type dependency crossed a relationship blocked by a root-level <NamespaceHierarchyPolicy>.

Example:

'OrderTicket' (namespace 'Restaurant.Orders') may not depend on 'HeadChef' (namespace 'Restaurant') at Constructor:
NamespaceHierarchyPolicy 'Restaurant' blocks DescendantToAncestor dependencies.

The rule is about source ownership, not runtime request flow. In the restaurant example, an order detail may not reach upward into a root-level chef implementation just because both happen to live beneath Restaurant.

The analyzer reports the diagnostic at the actual dependency site and supports constructor and method signatures, fields, properties, locals, object creation, generic arguments and invocations, inheritance, interface implementation, attributes, and static member access. A using directive alone does not create ARCH_NS_007.

Typical fixes

  • Move the shared abstraction to a namespace both sides are allowed to use.
  • Replace the direct reference with a contract owned by the appropriate boundary.
  • Adjust the blocked relationship only when the dependency direction is deliberate.
  • Scope a blocked relation to selected sites when the ownership rule is intentionally narrower.

ARCH_NS_007 has no automatic code fix. Moving ownership or choosing a contract is an architectural decision; an automatic change would be guesswork with a very confident-looking diff.

Diagnostic properties

  • CallerTypeName
  • DepTypeName
  • CallerNamespace
  • DependencyNamespace
  • NamespaceHierarchyRoot
  • NamespaceHierarchyRelation
  • NamespaceHierarchyRuleXmlPath
  • NamespaceHierarchyRuleXmlLine
  • NamespaceHierarchyRuleXmlCol
  • Site
  • ViolationReason
  • Comment

Focused examples: Example.Arch_NS_007.NamespaceHierarchy.DescendantToAncestor, Example.Arch_NS_007.NamespaceHierarchy.AncestorToDescendant, Example.Arch_NS_007.NamespaceHierarchy.SiblingToSibling, and Example.Arch_NS_007.NamespaceHierarchy.SameNamespace.

Diagnostic properties

Dependency diagnostics (ARCH_DEP_001, ARCH_DEP_004, and ARCH_DEP_005), ARCH_NAME_008, and the API-surface diagnostics carry a Site property in Diagnostic.Properties. Code-fix providers, reporters, and CI dashboards can group findings without parsing message text, which beats a regex dashboard that breaks the day the wording improves.

The policy families add their own properties:

  • ARCH_VIS_001
    • DeclarationTarget, DeclaredAccessibility, and DeclaredSymbolName;
  • ARCH_INH_001
    • DeclaredSymbolName and InheritanceViolationKind;
  • ARCH_RET_001
    • Site=MethodReturn, DeclaredSymbolName, ReturnValueRuleTarget, ReturnValueRule, and ReturnValueRuleMode;
    • ReturnValueRuleMode is Forbidden for a matching forbidden expression and Allowed when no <AllowedReturn> shape matched;
  • ARCH_OPER_001
    • Site, OperationKind, OperationDisplayName, and OperationPolicyRule;
  • ARCH_OPER_002, ARCH_OPER_011, and ARCH_OPER_012
    • Site, DeclaredSymbolName, OperationKind, OperationDisplayName, OperationPolicyRule, BehavioralOperationViolationKind, and BehavioralOperationOrdering;
    • a missing operation points at its owning declaration, while a selected failing operation points at the operation itself;
  • ARCH_ASSM_001
    • AssemblyAttributeTypeName and AssemblyAttributePolicyRule, plus the normal caller and rule-origin properties;
    • an SDK-generated attribute may have no source span because the SDK emitted it;
  • ARCH_NS_007
    • CallerNamespace, DependencyNamespace, NamespaceHierarchyRoot, NamespaceHierarchyRelation, and the rule XML path/line/column properties;
  • ARCH_API_001
    • ApiMemberName, identifying the declaration that published the dependency type;
  • ARCH_API_010
    • ExposureRootMember, ExposurePath, ExposureDepth, NestedMemberName, and NestedMemberContainingType;
    • its Site identifies the nested public member that exposed the forbidden type.
Site value Where the dependency was introduced
Constructor Constructor parameter (including primary constructors)
Method Non-constructor method parameter
MethodReturn Non-constructor method return type
Field Field declaration
Property Property declaration
Local Local variable declaration
New new T(...) or target-typed new() expression
GenericArgument Generic type argument of an outer type (Lazy<T>, IEnumerable<T>, …)
GenericInvocation Generic method invocation (service-locator style: services.GetService<T>())
Inheritance Base class inheritance or interface-to-interface inheritance
InterfaceImplementation Class, record, or struct implements an interface
Attribute Attribute used on a type or one of its members
StaticMember Static method, property, field, event, or reduced extension-method access

Example project: Example.Arch_DEP_001.NonConstructorInjection

Rule: Dependencies introduced outside the constructor are still dependencies. Fields, properties, method signatures, local variables, inheritance, interface implementation, attributes, static member access, new expressions and generic service-locator invocations are all checked against the configured layer edges. Classes, records, structs, and interfaces can all act as callers.

Type-kind example: Example.NonClassCallers

flowchart LR
    Customer --> Waiter --> Chef
    Customer -. "bad: hidden Chef dependency" .-> Chef
<AllowedDependency from="Customer" to="Waiter" />
<AllowedDependency from="Waiter" to="Chef" />

// ARCH_DEP_001: field dependency
public class FieldDependencyCustomer
{
    private readonly IChef _chef = null!;
}

// ARCH_DEP_001: property dependency
public class PropertyDependencyCustomer
{
    public IChef Chef { get; set; } = null!;
}

// ARCH_DEP_001: method parameter
public class MethodDependencyCustomer
{
    public void OrderFrom(IChef chef) { }
}

// ARCH_DEP_001: method return type
public class MethodReturnCustomer
{
    public IChef FindChef() => null!;
}

// ARCH_DEP_001: creating a Chef directly
public class NewingCustomer
{
    public void Run() => _ = new DirectChef();
}

// ARCH_DEP_001: a hidden lookup still bypasses the Waiter.
public class ServiceLocatorCustomer
{
    public void Run(IServiceProvider services)
        => _ = services.GetRequiredService<IChef>();
}

Q/A

Why are Task or Nullable blocked?

If you see a message like this:

'ISsoManager' (layer Application/Contracts) may not depend on 'Task' (layer Crosscutting):
no allowed dependency gate from 'Application/Contracts' to 'Crosscutting' is configured in boundary 'Application'

then Task or Nullable has been classified into one of your configured layers. The analyzer has no particular opinion about Task; something in the configuration adopted it into a layer, and the rules simply did as they were told. Once a matcher puts Task, Nullable, or another framework type in Crosscutting, normal layer and nested-boundary rules apply to it.

The cleanest fix is usually not to classify framework types into application architecture layers unless you really mean to. Keep Crosscutting scoped to your own code:

<Layer name="Crosscutting">
  <Assembly exactName="MyCompany.Shared" />
  <Namespace startsWith="MyCompany.Shared" />
</Layer>

If an existing matcher is broad enough to catch System.Threading.Tasks.Task, System.Nullable<T>, or other platform types, narrow that matcher first. Use <Exceptions> only as a migration aid when narrowing the matcher is not immediately practical.

If you intentionally model framework or shared primitives as a layer, make that intention explicit. A separate Framework layer often reads better than mixing platform types into business crosscutting concerns:

<Layer name="Framework">
  <Class typeName="Task" />
  <Class typeName="Nullable" />
  <Class typeName="CancellationToken" />
</Layer>

<AllowedDependency from="*" to="Framework" appliesToDescendants="true" />

With nested layers, a top-level wildcard without appliesToDescendants is not enough. A type in Application/Contracts must also pass the Application boundary gate. Use appliesToDescendants="true" when the whitelist should be global-ish:

<AllowedDependency from="*" to="Crosscutting" appliesToDescendants="true" />

For stricter business boundaries, keep the edge local to the parent boundary instead:

<AllowedDependency from="*" to="Crosscutting" />

<Layer name="Application">
  <Layer name="Contracts">
    <Class endsWith="Manager" typeKind="Interface" />
  </Layer>

  <AllowedDependency from="Contracts" to="/Crosscutting" />
</Layer>

Use a site filter if the framework type should only appear in API shapes:

<AllowedDependency from="Contracts"
                   to="/Crosscutting"
                   allowedSites="MethodReturn, Property" />

If the diagnostic is ARCH_DEP_001, the problem is a missing layer relationship. If the diagnostic is ARCH_TYPE_001, the type matched <Forbidden> or failed <Allowed>; fix the type policy instead.


Suppressing a violation

If you have a justified exception to the rule, suppress it with a standard #pragma using the specific ID for the reason you want to allow (ARCH_DEP_001, ARCH_DEP_004 or ARCH_DEP_005):

#pragma warning disable ARCH_DEP_001 // justified: bootstrapping cross-cutting concern
public class DiagnosticsController(IHealthRepository health) : ControllerBase { }
#pragma warning restore ARCH_DEP_001

Or use a [SuppressMessage] attribute on the class:

[System.Diagnostics.CodeAnalysis.SuppressMessage(
    "Architecture", "ARCH_DEP_001",
    Justification = "Bootstrapping concern that intentionally crosses layers")]
public class DiagnosticsController(IHealthRepository health) : ControllerBase { }

To silence one category across an entire project without touching individual files, add the ID to <NoWarn> in the .csproj - for example <NoWarn>$(NoWarn);ARCH_DEP_005</NoWarn> to allow same-layer dependencies while keeping ARCH_DEP_001 and ARCH_DEP_004 as errors.

Write the justification either way. A suppression with a reason is a documented decision; a bare #pragma is a puzzle left for whoever opens the file next year.


Violation report

In addition to inline diagnostics, Arse can write a Markdown summary of every violation it finds. Enable a default path by setting enableReport="true" on the <ArchitecturalLevels> root and optionally reportPath, or pass --output directly:

<ArchitecturalLevels enableReport="true"
                     reportPath="../../docs/architectural-violations.md">
  …
</ArchitecturalLevels>
arse report --project src\MyApp\MyApp.csproj --force
arse report --solution src\MyApp.slnx --output docs\architectural-violations.md --force

The report does a few specific things:

  • Groups code dependency, type-policy, and name-rule violations by their exact diagnostic IDs.
  • Adds a Suggested Configuration block for ARCH_DEP_002, with <Layer> and <AllowedDependency> snippets for the unrecognized dependencies it found.
  • Accepts --project for one assembly or --solution for an architecture spread across several projects.
  • Leaves configuration findings and cycles to the inspect health report.

It is a violation report, not a second health report with a different filename.

  • CI dashboards - commit the report as a build artifact and diff it across runs to track architectural drift.
  • Onboarding - point new contributors at a single file that summarizes the project's layering health.
  • Bootstrapping - start with requireRecognizedDependencies="Constructor" and enableReport="true" on a legacy codebase, copy the suggested <Layer> snippets into the config, then add more sites deliberately.

The report is written by RonSijm.AnaalIJzer.Reporting.ArchitecturalViolationReporter. Arse runs the analyzer in-process with Roslyn, converts the resulting diagnostics into report rows, and writes the file explicitly. Normal analyzer builds do not perform filesystem I/O, because an analyzer that writes files during a parallel build is a support ticket waiting to be filed.

Assembly-metadata failures (ARCH_ASSM_001) are reported in a dedicated table with the current assembly, emitted attribute type, matching policy rule, and reason. The table includes project-file-generated attributes such as InternalsVisibleTo even when they do not map to a handwritten source location.

Example report

This repository ships a rendered example report. It comes from Examples/Documentation/Example.ReportDemo, which intentionally contains one violation of each diagnostic ID.

Regenerate it from the repo root:

dotnet run --project src\Tools\RonSijm.AnaalIJzer.Arse -- report --project Examples\Documentation\Example.ReportDemo\Example.ReportDemo.csproj --force

In your own codebase, install the tool and run arse report --project path\to\Project.csproj or arse report --solution path\to\Solution.slnx. Pass --output to override the default path, and --force to overwrite an existing file.


Architecture health

An application can obey every configured edge while its architecture settings quietly drift. arse inspect checks both the settings and, when given a project or solution, the code evidence behind them:

arse inspect --project src\MyApp\MyApp.csproj --output docs\architecture-health.md --force
arse inspect --solution src\MyApp.slnx --output docs\architecture-health.md --force
arse inspect --solution src\MyApp.slnx --enforce-topology --output build\Artifacts\architecture-health.json --force
arse inspect --config Architecture.anl --force

The input decides how far inspection goes:

  • One .anl file checks configuration validity and configured cycles without loading MSBuild.
  • One project also checks:
    • unclassified or ambiguously classified types;
    • matchers that resolve no current types;
    • stale exceptions and unused allowed edges;
    • configured and observed dependency cycles;
    • current analyzer violations.
  • One solution runs the project checks for every C# project and writes one combined report.
    • Add --enforce-topology to report configured module edges as ARCH_SOL_001 and module cycles as ARCH_SOL_006.
  • A .json output path writes the same ordered findings as machine-readable evidence.

Unused edges and dead matchers are the configuration equivalent of unreachable code: harmless until somebody reads them as a statement of intent.

Example project: Example.ArchitectureHealth


Architecture documentation

For configurations that grow large, a single graph is not always enough. A diagram can show the arrows while still leaving the reader to guess what a wildcard, site filter, include, or type policy was meant to protect.

Arse can therefore generate one Markdown document containing:

  • Mermaid dependency diagrams;
  • layer and edge descriptions;
  • scoped allow/block type-policy summaries;
  • rules in the same order as the XML.

Set enableDocumentation="true" and optionally documentationPath, or pass --output directly:

<ArchitecturalLevels enableDocumentation="true"
                     documentationPath="../../docs/architecture-documentation.md"
                     description="Order-processing boundaries and query-surface rules.">
  …
</ArchitecturalLevels>

What the document does with the graph

The output is one Markdown file:

  • Unrelated dependency chains get separate sections and Mermaid diagrams.
    • An ordering chain and a billing chain do not need to share one confusing canvas merely because they share one settings file.
  • Wildcard rules are shown after the connected graphs.
  • Nested layers become Mermaid subgraphs.
  • Accompanying tables use canonical layer paths.

Choose how much source evidence to include

XML-only documentation remains the lightweight default and does not load or compile an application:

arse documentation --config Architecture.anl --include-input

For a project-backed document, add --include-code-evidence:

arse documentation --project MyApplication.csproj --include-code-evidence --include-input

The optional code-evidence section evaluates the rules against the current Roslyn compilation. It adds:

  • project types resolved through each top-level <Class> and <Namespace> matcher;
  • concrete caller/dependency/site usages permitted by every <AllowedDependency>;
  • types that remain unclassified;
  • current analyzer violations with diagnostic ID, site, caller, dependency, and source location.

This uses the analyzer's actual rule resolution, including document order, semantic matchers, and nested exceptions. It does not rebuild a cheaper approximation for the documentation and hope nobody notices the difference.

--include-input is independent of code evidence. It appends an Input Configuration section containing the root XML and a short note identifying it as the source for the document. With project input, Architecture.anl is included when present; otherwise the evaluated AssemblyMetadata("AnaalIJzerSettings", ...) XML is included. Without this flag, documentation output remains unchanged.

Edges with allowedSites, blockedSites, or appliesToDescendants get both a Mermaid label and a table row. The table also identifies the boundary gate that owns the rule. This keeps nested egress, ingress, and cascading rules distinguishable even when they resolve to the same canonical endpoints.

Descriptions are especially useful for repository query surfaces. You might allow a repository to return a transient OrderQuery so callers can immediately project it:

<AllowedDependency from="Persistence" to="QuerySurface"
                   allowedSites="MethodReturn, New"
                   description="Repositories may create and return query surfaces as fluent access points." />
<AllowedDependency from="QuerySurface" to="Projection"
                   allowedSites="MethodReturn, New"
                   description="Query surfaces may create projections and return only those projected objects." />

That documents the intent clearly: the repository owns the query surface, while outside layers receive a projected DTO rather than keeping a queryable object around where extra application logic can creep in. A diagram on its own shows which arrows exist; only the descriptions record why anyone drew them.

The documentation is written by RonSijm.AnaalIJzer.Reporting.ArchitectureDocumentationGenerator. Arse's report and documentation commands are independent - run either, both, or neither.

Example documentation

This repository ships a rendered documentation example. It is generated from Examples/Documentation/Example.DocumentationDemo, which contains a deliberately busy settings file with descriptions on each rule node.

Regenerate it from the repo root:

Examples\Documentation\Example.DocumentationDemo\GenerateDocumentation.bat

The example batch file invokes Arse with --config and targets that example's Architecture.anl directly.

In your own project:

  • use arse documentation --project path\to\Project.csproj --include-code-evidence --include-input when the document should include compiled code evidence;
  • use arse documentation --config path\to\Architecture.anl --include-input when the settings alone are enough;
  • pass --output to override documentationPath;
  • pass --force to overwrite an existing file.

Documentation coverage is guarded by ToolRunner_GeneratesDocumentationForSupportedConfigurationFeatures, which runs the real arse documentation --config path against a feature-matrix XML containing nested layers, descriptions, type policies, exceptions, rename fixes, site filters, wildcard rules and input inclusion.

Root-level source-metadata policies such as <AssemblyAttributePolicy> are rendered in authored configuration order and in their own table. They describe emitted assembly attributes rather than dependency graph edges, so they appear as policy documentation instead of Mermaid nodes.


No config source = no diagnostics

If no Architecture.anl additional file or AssemblyMetadata("AnaalIJzerSettings", ...) value is present, the analyzer is completely silent. This makes the analyzer opt-in per project: you can reference it centrally and activate it only in projects that supply configuration. Adoption can then happen one project at a time, which is usually the only pace at which adoption happens at all.


"I still don't understand"

If the written explanation is not clicking yet, clone the repository and open the projects under Examples/. They are small, self-contained, and clearly labelled where they are supposed to break. Sometimes one red squiggle explains more than another page of XML reference.


Design note: why generated files live in the tools

The analyzer reports ARCH_<CONCERN>_<REASON> diagnostics and deliberately does not write files during compilation. Roslyn analyzers run in IDEs, build servers, and design-time builds, so keeping them free of filesystem side effects avoids surprising writes and follows Roslyn's analyzer guidance.

The shared tooling engine is the explicit generation host used by both Arse modes. It can load a project with MSBuildWorkspace or read an XML settings file directly for documentation. For project-backed operations it reads the same Architecture.anl / AssemblyMetadata("AnaalIJzerSettings", ...) config as the analyzer and runs the analyzer in-process when a violation report is needed:

  • generate-config inspects a project and writes a validated baseline configuration.
  • export-config persists compiled inline AnaalIJzerSettings XML.
  • documentation renders dependency diagrams and rule descriptions with ArchitectureDocumentationGenerator.
  • report runs the analyzer and renders diagnostics with ArchitecturalViolationReporter.
  • merge-config flattens XML files and transitive includes into one configuration.
  • split-config extracts disconnected dependency graphs into an include-based configuration.

That keeps normal builds focused on diagnostics while still making reports and documentation easy to regenerate in CI or before committing documentation updates. The split is deliberate: compiling a repository should not modify it.

There are no supported framework assets in this package.

Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.6 101 9/25/2026
0.3.5 97 9/20/2026
0.3.4 110 9/20/2026
0.3.3 107 9/7/2026
0.3.2 108 9/6/2026
0.3.1 104 9/3/2026
0.3.0 92 9/2/2026
0.2.2 101 9/2/2026
0.2.1 147 8/28/2026
0.2.0.1 109 8/26/2026
0.2.0 99 8/26/2026
0.1.5 142 7/17/2026
0.1.4 141 7/8/2026
0.1.3 130 7/7/2026
0.1.2 132 7/7/2026
0.1.1 130 7/2/2026
0.1.0 128 7/2/2026
0.0.8 170 6/26/2026
0.0.7 130 6/25/2026
0.0.6.2 139 6/23/2026
Loading failed