DEV Community

loach
loach

Posted on Originally published at zenn.dev

Enforcing C# Architecture Rules at Compile Time with a Roslyn Analyzer

When a C# project adopts layered architecture or Clean Architecture, we often define rules such as:

  • the Domain layer must not depend on the Application layer
  • the Domain layer must not call I/O APIs such as Console or File directly
  • interop entry points must stay inside a specific boundary

The problem is that rules written only in README files, ADRs, or design documents are still easy to violate.

A review can miss a dependency. A temporary shortcut can become permanent. Over time, the implementation slowly drifts away from the architecture described in the documentation.

To make these rules executable, I built ArchitectureAnalyzer, a Roslyn Analyzer that validates architecture rules during normal C# compilation.

GitHub: https://github.com/mao2009/ArchitectureAnalyzer

Project site: https://mao2009.github.io/ArchitectureAnalyzer/

Define the architecture as data

ArchitectureAnalyzer keeps project-specific rules outside the analyzer itself. They live in an architecture.contract.json file.

For example:

{
  "layers": [
    {
      "name": "Domain",
      "namespaceRoots": ["MyApp.Domain"]
    },
    {
      "name": "Application",
      "namespaceRoots": ["MyApp.Application"]
    }
  ],
  "forbiddenDependencies": [
    {
      "from": "Domain",
      "to": "Application",
      "reason": "Domain must not depend on the outer Application layer."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

With this contract, any type below MyApp.Domain is classified as Domain and any type below MyApp.Application as Application.

If Domain references Application, the build fails with a Roslyn diagnostic such as:

error AARC002: 'MyApp.Domain.Order' (Domain) must not depend on
'MyApp.Application.OrderService' (Application):
Domain must not depend on the outer Application layer.
Enter fullscreen mode Exit fullscreen mode

This means the compiler catches the violation before a human reviewer has to.

Forbidden APIs

Architecture rules are not limited to layer-to-layer dependencies.

You can also block specific APIs from a layer. For example, to keep console I/O out of Domain:

{
  "forbiddenApis": [
    {
      "layer": "Domain",
      "type": "System.Console",
      "reason": "Console I/O must be abstracted behind an Infrastructure adapter."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Then code like this can be rejected at compile time:

namespace MyApp.Domain;

public class Order
{
    public void Print()
    {
        Console.WriteLine("Order");
    }
}
Enter fullscreen mode Exit fullscreen mode

Why JSON instead of hard-coded rules?

Different projects use different architectures. Hard-coding Domain, Application, and Infrastructure into the analyzer would make it project-specific.

Instead, ArchitectureAnalyzer follows a simple split:

The analyzer implements the mechanism. The project defines the policy.

That also means architecture rules can be reviewed and versioned in Git like source code.

Attribute-based layer declarations

Version 0.1.0 also supports explicit layer markers using attributes.

A contract can map attributes to layers:

{
  "layerDeclaration": {
    "required": true,
    "markerAttributes": [
      {
        "attributeFqn": "MyApp.Architecture.DomainAttribute",
        "layer": "Domain"
      },
      {
        "attributeFqn": "MyApp.Architecture.ApplicationAttribute",
        "layer": "Application"
      }
    ],
    "validateNamespaceConsistency": true,
    "markerNamespace": "MyApp.Architecture"
  }
}
Enter fullscreen mode Exit fullscreen mode

Then a type can declare its architectural role directly:

using MyApp.Architecture;

namespace MyApp.Domain;

[Domain]
public class Order
{
}
Enter fullscreen mode Exit fullscreen mode

When namespace and attribute declarations disagree, the analyzer can report the mismatch.

This is useful when you want architecture to be visible both from project structure and directly in code.

Why a Roslyn Analyzer?

The same validation could be implemented as a custom CLI or CI script, but a Roslyn Analyzer has an important advantage: it becomes part of the normal development loop.

You get diagnostics in the IDE, during dotnet build, and in CI without teaching developers a separate command.

Install the package and provide the contract as an additional file:

<ItemGroup>
  <PackageReference Include="loach.ArchitectureAnalyzer"
                    Version="0.1.0"
                    PrivateAssets="all" />
  <AdditionalFiles Include="architecture.contract.json" />
</ItemGroup>
Enter fullscreen mode Exit fullscreen mode

Then the normal build is enough:

dotnet build
Enter fullscreen mode Exit fullscreen mode

The same command naturally becomes a CI architecture gate.

What it does not do

ArchitectureAnalyzer does not decide whether your architecture is good.

It only answers a narrower question:

Does the implementation still obey the architecture rules this project explicitly defined?

Reflection, dynamic, generated code that is excluded from analysis, and some indirect access patterns are outside the guarantees of a normal static analyzer.

That limitation is intentional. The tool is designed to prevent architecture drift, not to replace architectural design.

Closing thoughts

Architecture documentation is valuable, but documentation alone cannot stop code from drifting away from it.

By moving the rules into a machine-readable contract and validating them during compilation, architecture becomes something the build can enforce rather than something reviewers merely have to remember.

ArchitectureAnalyzer is open source, and the project is available here:

https://github.com/mao2009/ArchitectureAnalyzer

Top comments (1)

Collapse
 
iqtechsolutions profile image
Ivan Rossouw

An executable architecture contract turns a review convention into a build signal. I’d treat the contract itself as production policy: protect it with ownership, review its diff, and ratchet an existing codebase instead of making every legacy violation an error on day one. Start with one forbidden edge, baseline current diagnostics, then fail only on new violations; pair analyzer tests with a real consumer dotnet build so packaging and AdditionalFiles wiring are covered too. Since namespace roots can leave unclassified code invisible, could the analyzer report coverage gaps as well?