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
ConsoleorFiledirectly - 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."
}
]
}
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.
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."
}
]
}
Then code like this can be rejected at compile time:
namespace MyApp.Domain;
public class Order
{
public void Print()
{
Console.WriteLine("Order");
}
}
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"
}
}
Then a type can declare its architectural role directly:
using MyApp.Architecture;
namespace MyApp.Domain;
[Domain]
public class Order
{
}
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>
Then the normal build is enough:
dotnet build
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:
Top comments (1)
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?