Architecture Enforcement
"Architecture is the set of decisions you wish you could get right early in a project, but which you are not necessarily more likely to get right than any other." -- Ralph Johnson
The Invisible Rules Problem
Architecture is the domain where conventions are most commonly violated. Not because developers are careless, but because the rules are invisible.
A layered architecture -- Presentation depends on Application, Application depends on Domain, Infrastructure implements Domain interfaces -- is a set of dependency constraints. These constraints exist in diagrams, ADRs, wiki pages, and onboarding guides. They exist everywhere except where they matter: in the compiler.
This makes architecture the perfect case study for the Convention Tax. The rules are simple to state ("Domain must not reference Infrastructure"), expensive to document (diagrams, decision records, dependency matrices), and expensive to enforce (architecture tests, CI scripts, code reviews). Every team pays this tax. Most teams pay it poorly -- and architecture erodes sprint by sprint until someone calls a "tech debt week" to fix the dependency graph that has been quietly rotting for eighteen months.
The Convention Tax for architecture is uniquely painful because architecture violations are silent. A missing validator causes a user-facing error. A missing DI registration causes a startup crash. A missing EF mapping causes a query failure. But an illegal dependency between layers? The code compiles. The tests pass. The application runs. Nothing fails -- until six months later when you try to extract a bounded context into a separate deployable and discover that your "independent" Domain project has 47 transitive references to ASP.NET Core.
Architecture violations are the termites of software. Invisible. Silent. Structural.
Let's trace the evolution.
Era 1: Code -- No Enforcement
In the beginning, there were no rules. Or rather, there were rules -- spoken in meetings, written on whiteboards, agreed upon in architecture discussions -- but nothing in the codebase prevented violations.
// OrderController.cs — in the "Presentation" layer
// The team agreed: controllers should only talk to Application services.
// But it's Thursday afternoon, the sprint ends Friday, and the developer
// needs data that the Application layer doesn't expose yet.
using System.Data.SqlClient;
using MyApp.Domain.Entities;
namespace MyApp.Presentation.Controllers;
public class OrderController : Controller
{
// Direct reference to infrastructure — violates the architecture
private readonly string _connectionString;
public OrderController(IConfiguration config)
{
_connectionString = config.GetConnectionString("Default")!;
}
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(Guid id)
{
// Presentation → Infrastructure, bypassing Application and Domain entirely
using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
using var cmd = new SqlCommand(
"SELECT Id, CustomerId, TotalAmount, Status FROM Orders WHERE Id = @Id",
connection);
cmd.Parameters.AddWithValue("@Id", id);
using var reader = await cmd.ExecuteReaderAsync();
if (!await reader.ReadAsync())
return NotFound();
// Manual mapping in the controller — no domain model, no validation
var order = new
{
Id = reader.GetGuid(0),
CustomerId = reader.GetGuid(1),
TotalAmount = reader.GetDecimal(2),
Status = reader.GetInt32(3)
};
return Ok(order);
}
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
// Direct domain entity construction in the presentation layer
var order = new Order(
Guid.NewGuid(),
request.CustomerId,
request.Items.Select(i => new LineItem(
Guid.NewGuid(),
i.ProductId,
i.Quantity,
i.UnitPrice)).ToList());
// Direct SQL insert — bypassing the repository pattern entirely
using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
using var cmd = new SqlCommand(
"INSERT INTO Orders (Id, CustomerId, TotalAmount, Status) " +
"VALUES (@Id, @CustomerId, @TotalAmount, @Status)",
connection, transaction);
cmd.Parameters.AddWithValue("@Id", order.Id);
cmd.Parameters.AddWithValue("@CustomerId", order.CustomerId);
cmd.Parameters.AddWithValue("@TotalAmount", order.TotalAmount);
cmd.Parameters.AddWithValue("@Status", (int)order.Status);
await cmd.ExecuteNonQueryAsync();
foreach (var item in order.LineItems)
{
using var itemCmd = new SqlCommand(
"INSERT INTO LineItems (Id, OrderId, ProductId, Quantity, UnitPrice) " +
"VALUES (@Id, @OrderId, @ProductId, @Quantity, @UnitPrice)",
connection, transaction);
itemCmd.Parameters.AddWithValue("@Id", item.Id);
itemCmd.Parameters.AddWithValue("@OrderId", order.Id);
itemCmd.Parameters.AddWithValue("@ProductId", item.ProductId);
itemCmd.Parameters.AddWithValue("@Quantity", item.Quantity);
itemCmd.Parameters.AddWithValue("@UnitPrice", item.UnitPrice);
await itemCmd.ExecuteNonQueryAsync();
}
await transaction.CommitAsync();
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}// OrderController.cs — in the "Presentation" layer
// The team agreed: controllers should only talk to Application services.
// But it's Thursday afternoon, the sprint ends Friday, and the developer
// needs data that the Application layer doesn't expose yet.
using System.Data.SqlClient;
using MyApp.Domain.Entities;
namespace MyApp.Presentation.Controllers;
public class OrderController : Controller
{
// Direct reference to infrastructure — violates the architecture
private readonly string _connectionString;
public OrderController(IConfiguration config)
{
_connectionString = config.GetConnectionString("Default")!;
}
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(Guid id)
{
// Presentation → Infrastructure, bypassing Application and Domain entirely
using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
using var cmd = new SqlCommand(
"SELECT Id, CustomerId, TotalAmount, Status FROM Orders WHERE Id = @Id",
connection);
cmd.Parameters.AddWithValue("@Id", id);
using var reader = await cmd.ExecuteReaderAsync();
if (!await reader.ReadAsync())
return NotFound();
// Manual mapping in the controller — no domain model, no validation
var order = new
{
Id = reader.GetGuid(0),
CustomerId = reader.GetGuid(1),
TotalAmount = reader.GetDecimal(2),
Status = reader.GetInt32(3)
};
return Ok(order);
}
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
// Direct domain entity construction in the presentation layer
var order = new Order(
Guid.NewGuid(),
request.CustomerId,
request.Items.Select(i => new LineItem(
Guid.NewGuid(),
i.ProductId,
i.Quantity,
i.UnitPrice)).ToList());
// Direct SQL insert — bypassing the repository pattern entirely
using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
using var cmd = new SqlCommand(
"INSERT INTO Orders (Id, CustomerId, TotalAmount, Status) " +
"VALUES (@Id, @CustomerId, @TotalAmount, @Status)",
connection, transaction);
cmd.Parameters.AddWithValue("@Id", order.Id);
cmd.Parameters.AddWithValue("@CustomerId", order.CustomerId);
cmd.Parameters.AddWithValue("@TotalAmount", order.TotalAmount);
cmd.Parameters.AddWithValue("@Status", (int)order.Status);
await cmd.ExecuteNonQueryAsync();
foreach (var item in order.LineItems)
{
using var itemCmd = new SqlCommand(
"INSERT INTO LineItems (Id, OrderId, ProductId, Quantity, UnitPrice) " +
"VALUES (@Id, @OrderId, @ProductId, @Quantity, @UnitPrice)",
connection, transaction);
itemCmd.Parameters.AddWithValue("@Id", item.Id);
itemCmd.Parameters.AddWithValue("@OrderId", order.Id);
itemCmd.Parameters.AddWithValue("@ProductId", item.ProductId);
itemCmd.Parameters.AddWithValue("@Quantity", item.Quantity);
itemCmd.Parameters.AddWithValue("@UnitPrice", item.UnitPrice);
await itemCmd.ExecuteNonQueryAsync();
}
await transaction.CommitAsync();
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}What goes wrong:
The intended dependency graph was clean:
Presentation → Application → Domain ← InfrastructurePresentation → Application → Domain ← InfrastructureThe actual dependency graph after twelve months of deadline pressure:
Presentation → Application → Domain ← Infrastructure
↓ ↑ ↑ ↑
└──────────────┘ └──────────┘
↓ ↑
└────────────────────────────────┘Presentation → Application → Domain ← Infrastructure
↓ ↑ ↑ ↑
└──────────────┘ └──────────┘
↓ ↑
└────────────────────────────────┘Presentation references Infrastructure directly for "quick" database queries. Application references Infrastructure for "just this one" utility class. Domain references Infrastructure because someone added a logging call inside an entity. Every violation was small. Every violation was expedient. Nobody caught any of them because nothing in the codebase said "this is wrong."
The architecture diagram on the wiki still shows clean layers. The codebase tells a different story. And the gap between the two widens with every sprint.
The irony of Era 1 is that the architecture was correct on day one. The team drew the right diagram. They agreed on the right rules. They had the right intentions. What they lacked was any mechanism to preserve those intentions across twelve months of feature work, team changes, and deadline pressure.
Architecture without enforcement is a wish. And wishes are not architectural constraints.
Convention cost: 0 lines. No documentation, no enforcement, no architecture. Just an honor system that honors nothing.
Era 2: Configuration -- NDepend XML
NDepend introduced the idea that architecture rules should be explicit and machine-verifiable. This was revolutionary. For the first time, an architecture diagram could be checked against reality. Define your layers in XML, specify the allowed dependencies, and let the tool analyze your assemblies for violations.
The problem was not the idea. The idea was excellent. The problem was the medium: external XML configuration files maintained in a separate tool, producing reports in a separate dashboard, consumed by a separate audience.
<?xml version="1.0" encoding="utf-8" ?>
<NDepend>
<Queries>
<!-- Define architectural layers -->
<Group Name="Architecture Rules">
<!-- Layer definitions -->
<Query Active="True" DisplayList="True" DisplayStat="True">
<![CDATA[
// <Name>Define layers</Name>
let presentation = Application.Assemblies.WithNameLike("Presentation")
let application = Application.Assemblies.WithNameLike("Application")
let domain = Application.Assemblies.WithNameLike("Domain")
let infrastructure = Application.Assemblies.WithNameLike("Infrastructure")
]]>
</Query>
<!-- Domain must not reference Infrastructure -->
<Query Active="True" DisplayList="True" DisplayStat="True">
<![CDATA[
// <Name>Domain must not depend on Infrastructure</Name>
warnif count > 0
from t in Types
where t.IsUsing("MyApp.Infrastructure")
&& t.ParentAssembly.Name == "MyApp.Domain"
select new { t, t.NbLinesOfCode }
]]>
</Query>
<!-- Domain must not reference Presentation -->
<Query Active="True" DisplayList="True" DisplayStat="True">
<![CDATA[
// <Name>Domain must not depend on Presentation</Name>
warnif count > 0
from t in Types
where t.IsUsing("MyApp.Presentation")
&& t.ParentAssembly.Name == "MyApp.Domain"
select new { t, t.NbLinesOfCode }
]]>
</Query>
<!-- Presentation must not reference Infrastructure directly -->
<Query Active="True" DisplayList="True" DisplayStat="True">
<![CDATA[
// <Name>Presentation must not depend on Infrastructure</Name>
warnif count > 0
from t in Types
where t.IsUsing("MyApp.Infrastructure")
&& t.ParentAssembly.Name == "MyApp.Presentation"
select new { t, t.NbLinesOfCode }
]]>
</Query>
<!-- Application must not reference Presentation -->
<Query Active="True" DisplayList="True" DisplayStat="True">
<![CDATA[
// <Name>Application must not depend on Presentation</Name>
warnif count > 0
from t in Types
where t.IsUsing("MyApp.Presentation")
&& t.ParentAssembly.Name == "MyApp.Application"
select new { t, t.NbLinesOfCode }
]]>
</Query>
<!-- Dependency matrix: allowed references -->
<Query Active="True" DisplayList="True" DisplayStat="True">
<![CDATA[
// <Name>Dependency Matrix Visualization</Name>
from a1 in Application.Assemblies
from a2 in Application.Assemblies
where a1 != a2 && a1.IsUsing(a2)
select new {
From = a1.Name,
To = a2.Name,
NbTypes = a1.Types.UsingAny(a2.Types).Count()
}
]]>
</Query>
</Group>
</Queries>
</NDepend><?xml version="1.0" encoding="utf-8" ?>
<NDepend>
<Queries>
<!-- Define architectural layers -->
<Group Name="Architecture Rules">
<!-- Layer definitions -->
<Query Active="True" DisplayList="True" DisplayStat="True">
<![CDATA[
// <Name>Define layers</Name>
let presentation = Application.Assemblies.WithNameLike("Presentation")
let application = Application.Assemblies.WithNameLike("Application")
let domain = Application.Assemblies.WithNameLike("Domain")
let infrastructure = Application.Assemblies.WithNameLike("Infrastructure")
]]>
</Query>
<!-- Domain must not reference Infrastructure -->
<Query Active="True" DisplayList="True" DisplayStat="True">
<![CDATA[
// <Name>Domain must not depend on Infrastructure</Name>
warnif count > 0
from t in Types
where t.IsUsing("MyApp.Infrastructure")
&& t.ParentAssembly.Name == "MyApp.Domain"
select new { t, t.NbLinesOfCode }
]]>
</Query>
<!-- Domain must not reference Presentation -->
<Query Active="True" DisplayList="True" DisplayStat="True">
<![CDATA[
// <Name>Domain must not depend on Presentation</Name>
warnif count > 0
from t in Types
where t.IsUsing("MyApp.Presentation")
&& t.ParentAssembly.Name == "MyApp.Domain"
select new { t, t.NbLinesOfCode }
]]>
</Query>
<!-- Presentation must not reference Infrastructure directly -->
<Query Active="True" DisplayList="True" DisplayStat="True">
<![CDATA[
// <Name>Presentation must not depend on Infrastructure</Name>
warnif count > 0
from t in Types
where t.IsUsing("MyApp.Infrastructure")
&& t.ParentAssembly.Name == "MyApp.Presentation"
select new { t, t.NbLinesOfCode }
]]>
</Query>
<!-- Application must not reference Presentation -->
<Query Active="True" DisplayList="True" DisplayStat="True">
<![CDATA[
// <Name>Application must not depend on Presentation</Name>
warnif count > 0
from t in Types
where t.IsUsing("MyApp.Presentation")
&& t.ParentAssembly.Name == "MyApp.Application"
select new { t, t.NbLinesOfCode }
]]>
</Query>
<!-- Dependency matrix: allowed references -->
<Query Active="True" DisplayList="True" DisplayStat="True">
<![CDATA[
// <Name>Dependency Matrix Visualization</Name>
from a1 in Application.Assemblies
from a2 in Application.Assemblies
where a1 != a2 && a1.IsUsing(a2)
select new {
From = a1.Name,
To = a2.Name,
NbTypes = a1.Types.UsingAny(a2.Types).Count()
}
]]>
</Query>
</Group>
</Queries>
</NDepend>// NDepend integration in build script
// ndepend.console.exe MyApp.ndproj /OutDir NdependResults
// Parse results, check for critical violations
// Typically: a dashboard that builds at night, reviewed by... nobody// NDepend integration in build script
// ndepend.console.exe MyApp.ndproj /OutDir NdependResults
// Parse results, check for critical violations
// Typically: a dashboard that builds at night, reviewed by... nobodyWhat goes wrong:
- NDepend is a commercial tool with a significant per-seat license cost. Not every developer has it installed. Not every developer can run the analysis locally.
- The CQL (Code Query Language) rules are powerful but written in a DSL that most developers never learn. The tech lead writes the rules. Everyone else ignores the dashboard.
- The XML configuration is disconnected from the code. When you rename an assembly, the NDepend config must be updated separately. When you add a new project, the layer definition must be updated. When you restructure the solution, the dependency matrix must be rebuilt.
- The results appear in a standalone dashboard, not in the IDE. A violation that produces a red squiggle in Visual Studio gets fixed immediately. A violation that appears in a dashboard three clicks away from the code gets added to the backlog and forgotten.
📁 ndepend/
├── MyApp.ndproj ← NDepend project file
├── rules/
│ ├── architecture-rules.xml ← Layer dependency rules (80+ lines)
│ ├── naming-rules.xml ← Naming convention rules
│ └── complexity-rules.xml ← Complexity thresholds
├── reports/
│ └── latest/ ← HTML report — who reads this?
└── README.md ← "How to run NDepend analysis" (30 lines)📁 ndepend/
├── MyApp.ndproj ← NDepend project file
├── rules/
│ ├── architecture-rules.xml ← Layer dependency rules (80+ lines)
│ ├── naming-rules.xml ← Naming convention rules
│ └── complexity-rules.xml ← Complexity thresholds
├── reports/
│ └── latest/ ← HTML report — who reads this?
└── README.md ← "How to run NDepend analysis" (30 lines)Convention cost: ~110 lines of XML configuration + 30 lines of documentation. Results visible in a dashboard. Violations caught after build, in a separate tool. Developer friction: high.
The Configuration era's fundamental problem is separation. The rules live in one tool. The code lives in another. The results appear in a third place. Three systems that must agree, maintained by three different processes, visible to three different audiences. The tech lead maintains NDepend. The developers maintain the code. Nobody maintains the alignment between them.
This is the Configuration-era pattern from Part I: the configuration and the code are two separate truth sources. They drift. They always drift.
Era 3: Convention -- NetArchTest
NetArchTest brought architecture enforcement into the test suite. Write rules as C# code, run them as unit tests, get failures in the same CI pipeline as everything else.
This was genuine progress. The rules are code, not XML. They live in the solution, not in a separate tool. They run in CI, not in a dashboard. They fail the build, not a report.
The Shift to Code
NetArchTest (and its Java counterpart ArchUnit) represented a genuine philosophical shift: architecture rules should be expressed in the same language as the code they guard. No more XML. No more separate tools. No more dashboards. The architecture rules are C# code, they live in the test project, they run in CI, and they fail the build.
This is correct in principle and valuable in practice. But it introduces the Convention Tax: the rules must be documented (so developers understand why the test fails), and the rules must be maintained (so the test keeps working as the codebase evolves).
The Convention
The architecture convention is simple to state:
- Domain must not reference Application, Infrastructure, or Presentation
- Application depends only on Domain
- Infrastructure implements Domain interfaces, depends on Domain and Application
- Presentation depends on Application only (not Domain directly, not Infrastructure)
Four rules. One paragraph. Every Clean Architecture project has this convention.
The Documentation
<!-- wiki/architecture-decisions/ADR-001-layer-dependencies.md -->
# ADR-001: Layer Dependencies
## Status
Accepted
## Context
We follow Clean Architecture / Onion Architecture. We need clear rules
about which project can reference which, so that our dependency graph
remains clean and testable.
## Decision
### Allowed Dependencies
| From | May Reference | Must NOT Reference |
|-------------------|------------------------|----------------------------|
| Domain | (nothing) | Application, Infrastructure, Presentation |
| Application | Domain | Infrastructure, Presentation |
| Infrastructure | Domain, Application | Presentation |
| Presentation | Application | Domain (direct), Infrastructure |
### Rationale
- Domain is the core — it must have zero outward dependencies
- Application orchestrates domain logic — it must not know about persistence
- Infrastructure implements interfaces defined in Domain
- Presentation talks to Application via commands/queries, never directly
to Domain entities or Infrastructure services
## Consequences
- All interfaces (IRepository, IEmailSender, etc.) live in Domain or Application
- Concrete implementations live in Infrastructure
- Controllers inject Application services, never Domain or Infrastructure directly
- New projects must be assigned to a layer before adding references<!-- wiki/architecture-decisions/ADR-001-layer-dependencies.md -->
# ADR-001: Layer Dependencies
## Status
Accepted
## Context
We follow Clean Architecture / Onion Architecture. We need clear rules
about which project can reference which, so that our dependency graph
remains clean and testable.
## Decision
### Allowed Dependencies
| From | May Reference | Must NOT Reference |
|-------------------|------------------------|----------------------------|
| Domain | (nothing) | Application, Infrastructure, Presentation |
| Application | Domain | Infrastructure, Presentation |
| Infrastructure | Domain, Application | Presentation |
| Presentation | Application | Domain (direct), Infrastructure |
### Rationale
- Domain is the core — it must have zero outward dependencies
- Application orchestrates domain logic — it must not know about persistence
- Infrastructure implements interfaces defined in Domain
- Presentation talks to Application via commands/queries, never directly
to Domain entities or Infrastructure services
## Consequences
- All interfaces (IRepository, IEmailSender, etc.) live in Domain or Application
- Concrete implementations live in Infrastructure
- Controllers inject Application services, never Domain or Infrastructure directly
- New projects must be assigned to a layer before adding references<!-- wiki/onboarding/architecture.md -->
# Architecture Guide for New Developers
## Layer Overview
<!-- wiki/onboarding/architecture.md -->
# Architecture Guide for New Developers
## Layer Overview
┌────────────────────────┐ │ Presentation │ ← Controllers, ViewModels, Blazor pages ├────────────────────────┤ │ Application │ ← Commands, Queries, Handlers, DTOs ├────────────────────────┤ │ Domain │ ← Entities, Value Objects, Domain Events ├────────────────────────┤ │ Infrastructure │ ← EF Core, Email, File Storage, External APIs └────────────────────────┘
Dependency direction: top → down (Presentation → Application → Domain) Infrastructure implements Domain interfaces (dependency inversion)
## Rules
1. Never add a `<ProjectReference>` from Domain to any other project
2. Never add a `<ProjectReference>` from Application to Infrastructure
3. Never add a `<ProjectReference>` from Presentation to Infrastructure
4. If you need something from Infrastructure in Presentation, add an
interface in Application and implement it in Infrastructure
5. If you need a Domain entity in Presentation, create a DTO in
Application and map it
## Common Mistakes
- Adding `using MyApp.Infrastructure` in a controller → use the interface
- Creating a Domain entity in a controller → use a command handler
- Adding a logger implementation in Domain → inject ILogger via interface
## How to Check
Run `dotnet test --filter Category=Architecture` before every PR
## Rules
1. Never add a `<ProjectReference>` from Domain to any other project
2. Never add a `<ProjectReference>` from Application to Infrastructure
3. Never add a `<ProjectReference>` from Presentation to Infrastructure
4. If you need something from Infrastructure in Presentation, add an
interface in Application and implement it in Infrastructure
5. If you need a Domain entity in Presentation, create a DTO in
Application and map it
## Common Mistakes
- Adding `using MyApp.Infrastructure` in a controller → use the interface
- Creating a Domain entity in a controller → use a command handler
- Adding a logger implementation in Domain → inject ILogger via interface
## How to Check
Run `dotnet test --filter Category=Architecture` before every PRThat is ~45 lines of ADR and onboarding documentation. Both documents will drift from reality. The ADR will never be updated after initial writing. The onboarding guide will be read once by new hires and forgotten.
The documentation is not wrong -- it is accurate at the time of writing. But it is a snapshot, and the codebase is a living system. The moment the documentation is written, it begins drifting from the code it describes. An ADR that says "Presentation must not reference Domain directly" is correct on the day it is written. Three months later, a team member adds a using MyApp.Domain.Entities to a controller because the Application layer does not have the DTO they need yet. The ADR is now wrong. Nobody updates it. The next developer reads the ADR, looks at the codebase, sees the contradiction, and trusts the codebase over the ADR -- which means the violation becomes the de facto convention.
Documentation-based architecture is a losing game. The code always wins.
The Enforcement Code
// tests/ArchitectureTests/LayerDependencyTests.cs
using NetArchTest.Rules;
public class LayerDependencyTests
{
private static readonly Assembly DomainAssembly = typeof(Order).Assembly;
private static readonly Assembly ApplicationAssembly = typeof(CreateOrderCommand).Assembly;
private static readonly Assembly InfrastructureAssembly = typeof(AppDbContext).Assembly;
private static readonly Assembly PresentationAssembly = typeof(OrderController).Assembly;
[Fact]
public void Domain_Should_Not_Reference_Application()
{
var result = Types.InAssembly(DomainAssembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Application")
.GetResult();
result.IsSuccessful.Should().BeTrue(
"Domain must not depend on Application. Violations: " +
string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>()));
}
[Fact]
public void Domain_Should_Not_Reference_Infrastructure()
{
var result = Types.InAssembly(DomainAssembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Infrastructure")
.GetResult();
result.IsSuccessful.Should().BeTrue(
"Domain must not depend on Infrastructure. Violations: " +
string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>()));
}
[Fact]
public void Domain_Should_Not_Reference_Presentation()
{
var result = Types.InAssembly(DomainAssembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Presentation")
.GetResult();
result.IsSuccessful.Should().BeTrue(
"Domain must not depend on Presentation. Violations: " +
string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>()));
}
[Fact]
public void Application_Should_Not_Reference_Infrastructure()
{
var result = Types.InAssembly(ApplicationAssembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Infrastructure")
.GetResult();
result.IsSuccessful.Should().BeTrue(
"Application must not depend on Infrastructure. Violations: " +
string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>()));
}
[Fact]
public void Application_Should_Not_Reference_Presentation()
{
var result = Types.InAssembly(ApplicationAssembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Presentation")
.GetResult();
result.IsSuccessful.Should().BeTrue(
"Application must not depend on Presentation. Violations: " +
string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>()));
}
[Fact]
public void Presentation_Should_Not_Reference_Infrastructure()
{
var result = Types.InAssembly(PresentationAssembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Infrastructure")
.GetResult();
result.IsSuccessful.Should().BeTrue(
"Presentation must not depend on Infrastructure. Violations: " +
string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>()));
}
[Fact]
public void Presentation_Should_Not_Reference_Domain_Directly()
{
var result = Types.InAssembly(PresentationAssembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Domain")
.GetResult();
result.IsSuccessful.Should().BeTrue(
"Presentation must not depend on Domain directly. " +
"Use Application DTOs instead. Violations: " +
string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>()));
}
}// tests/ArchitectureTests/LayerDependencyTests.cs
using NetArchTest.Rules;
public class LayerDependencyTests
{
private static readonly Assembly DomainAssembly = typeof(Order).Assembly;
private static readonly Assembly ApplicationAssembly = typeof(CreateOrderCommand).Assembly;
private static readonly Assembly InfrastructureAssembly = typeof(AppDbContext).Assembly;
private static readonly Assembly PresentationAssembly = typeof(OrderController).Assembly;
[Fact]
public void Domain_Should_Not_Reference_Application()
{
var result = Types.InAssembly(DomainAssembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Application")
.GetResult();
result.IsSuccessful.Should().BeTrue(
"Domain must not depend on Application. Violations: " +
string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>()));
}
[Fact]
public void Domain_Should_Not_Reference_Infrastructure()
{
var result = Types.InAssembly(DomainAssembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Infrastructure")
.GetResult();
result.IsSuccessful.Should().BeTrue(
"Domain must not depend on Infrastructure. Violations: " +
string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>()));
}
[Fact]
public void Domain_Should_Not_Reference_Presentation()
{
var result = Types.InAssembly(DomainAssembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Presentation")
.GetResult();
result.IsSuccessful.Should().BeTrue(
"Domain must not depend on Presentation. Violations: " +
string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>()));
}
[Fact]
public void Application_Should_Not_Reference_Infrastructure()
{
var result = Types.InAssembly(ApplicationAssembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Infrastructure")
.GetResult();
result.IsSuccessful.Should().BeTrue(
"Application must not depend on Infrastructure. Violations: " +
string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>()));
}
[Fact]
public void Application_Should_Not_Reference_Presentation()
{
var result = Types.InAssembly(ApplicationAssembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Presentation")
.GetResult();
result.IsSuccessful.Should().BeTrue(
"Application must not depend on Presentation. Violations: " +
string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>()));
}
[Fact]
public void Presentation_Should_Not_Reference_Infrastructure()
{
var result = Types.InAssembly(PresentationAssembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Infrastructure")
.GetResult();
result.IsSuccessful.Should().BeTrue(
"Presentation must not depend on Infrastructure. Violations: " +
string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>()));
}
[Fact]
public void Presentation_Should_Not_Reference_Domain_Directly()
{
var result = Types.InAssembly(PresentationAssembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Domain")
.GetResult();
result.IsSuccessful.Should().BeTrue(
"Presentation must not depend on Domain directly. " +
"Use Application DTOs instead. Violations: " +
string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>()));
}
}// tests/ArchitectureTests/ProjectReferenceTests.cs
public class ProjectReferenceTests
{
[Theory]
[InlineData("MyApp.Domain.csproj", "MyApp.Application")]
[InlineData("MyApp.Domain.csproj", "MyApp.Infrastructure")]
[InlineData("MyApp.Domain.csproj", "MyApp.Presentation")]
[InlineData("MyApp.Application.csproj", "MyApp.Infrastructure")]
[InlineData("MyApp.Application.csproj", "MyApp.Presentation")]
[InlineData("MyApp.Presentation.csproj", "MyApp.Infrastructure")]
public void Project_Should_Not_Reference_Forbidden_Project(
string projectFile, string forbiddenReference)
{
var projectPath = Path.Combine(
GetSolutionDirectory(), "src", projectFile);
var content = File.ReadAllText(projectPath);
content.Should().NotContain(
$"Include=\"{forbiddenReference}\"",
$"{projectFile} must not reference {forbiddenReference}");
}
private static string GetSolutionDirectory()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !dir.GetFiles("*.sln").Any())
dir = dir.Parent;
return dir?.FullName ?? throw new Exception("Solution directory not found");
}
}// tests/ArchitectureTests/ProjectReferenceTests.cs
public class ProjectReferenceTests
{
[Theory]
[InlineData("MyApp.Domain.csproj", "MyApp.Application")]
[InlineData("MyApp.Domain.csproj", "MyApp.Infrastructure")]
[InlineData("MyApp.Domain.csproj", "MyApp.Presentation")]
[InlineData("MyApp.Application.csproj", "MyApp.Infrastructure")]
[InlineData("MyApp.Application.csproj", "MyApp.Presentation")]
[InlineData("MyApp.Presentation.csproj", "MyApp.Infrastructure")]
public void Project_Should_Not_Reference_Forbidden_Project(
string projectFile, string forbiddenReference)
{
var projectPath = Path.Combine(
GetSolutionDirectory(), "src", projectFile);
var content = File.ReadAllText(projectPath);
content.Should().NotContain(
$"Include=\"{forbiddenReference}\"",
$"{projectFile} must not reference {forbiddenReference}");
}
private static string GetSolutionDirectory()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !dir.GetFiles("*.sln").Any())
dir = dir.Parent;
return dir?.FullName ?? throw new Exception("Solution directory not found");
}
}# .github/workflows/ci.yml (excerpt)
- name: Run architecture tests
run: dotnet test --filter Category=Architecture --no-build
# If architecture tests fail, the PR is blocked# .github/workflows/ci.yml (excerpt)
- name: Run architecture tests
run: dotnet test --filter Category=Architecture --no-build
# If architecture tests fail, the PR is blockedConvention Tax Calculation
📄 wiki/ADR-001-layer-dependencies.md (25 lines)
📄 wiki/onboarding/architecture.md (20 lines)
📄 LayerDependencyTests.cs — 7 NetArchTest rules (65 lines)
📄 ProjectReferenceTests.cs — .csproj parsing (20 lines)
📄 CI script running architecture tests (3 lines)
────────────────────────────────────────
Total convention overhead: ~133 lines📄 wiki/ADR-001-layer-dependencies.md (25 lines)
📄 wiki/onboarding/architecture.md (20 lines)
📄 LayerDependencyTests.cs — 7 NetArchTest rules (65 lines)
📄 ProjectReferenceTests.cs — .csproj parsing (20 lines)
📄 CI script running architecture tests (3 lines)
────────────────────────────────────────
Total convention overhead: ~133 lines133 lines of documentation and enforcement code for four dependency rules. And the feedback loop is still slow: the developer adds an illegal reference, writes code against it, commits, pushes, waits for CI, sees the architecture test fail, has to undo the reference and rewrite. By then they have already built on the violation. Removing it means rework.
Where Convention Still Fails
Even with NetArchTest, architecture erosion finds a way:
Gap 1: Transitive Dependencies. NetArchTest checks that types in Assembly A do not directly reference types in Assembly B. But what about NuGet packages? If the Domain project references a NuGet package that itself references System.Data.SqlClient, the Domain project now has a transitive dependency on database infrastructure. NetArchTest does not catch this. The convention says "Domain must not depend on Infrastructure," but the convention does not define whether NuGet transitives count.
Gap 2: New Projects. When a developer creates a new project MyApp.Shared, the architecture tests do not cover it. The developer must remember to add test cases for the new project's layer constraints. The convention says "every project must have architecture tests." The enforcement of that convention is... another convention.
Gap 3: Namespace Drift. NetArchTest checks assembly-level dependencies. But within a single assembly, namespaces can drift. A Domain project might have a Domain.Infrastructure namespace that contains infrastructure concerns. The convention says "no infrastructure code in Domain," but the enforcement only checks cross-assembly references, not intra-assembly namespace organization.
Gap 4: The Test Itself. The architecture test must be maintained. When someone renames an assembly from MyApp.Domain to MyApp.Core, the test breaks -- not because the architecture was violated, but because the test was looking for the wrong assembly name. The test becomes another artifact to maintain, another thing that can be wrong independently of the code it guards.
// The most common architecture test failure:
// "System.IO.FileNotFoundException: Could not load file or assembly 'MyApp.Domain'"
//
// Translation: someone renamed the project and forgot to update the test.
// The architecture test is not testing architecture. It is testing itself.// The most common architecture test failure:
// "System.IO.FileNotFoundException: Could not load file or assembly 'MyApp.Domain'"
//
// Translation: someone renamed the project and forgot to update the test.
// The architecture test is not testing architecture. It is testing itself.Gap 5: Exceptions and Pragmatism. Every architecture test suite eventually acquires an exceptions list. "Domain must not reference Infrastructure, EXCEPT for the logging extension methods." "Presentation must not reference Domain directly, EXCEPT for the shared enums." Each exception weakens the rule. After two years, the exceptions list is longer than the rules list, and the architecture tests are testing a swiss cheese version of the original architecture.
// The inevitable evolution of architecture tests:
[Fact]
public void Domain_Should_Not_Reference_Infrastructure()
{
var result = Types.InAssembly(DomainAssembly)
.That()
.DoNotHaveNameMatching("LoggingExtensions") // exception 1
.And()
.DoNotHaveNameMatching("CacheHelper") // exception 2
.And()
.DoNotHaveNameMatching("DateTimeProvider") // exception 3
.ShouldNot()
.HaveDependencyOn("MyApp.Infrastructure")
.GetResult();
// Three exceptions later, what exactly is this test protecting?
}// The inevitable evolution of architecture tests:
[Fact]
public void Domain_Should_Not_Reference_Infrastructure()
{
var result = Types.InAssembly(DomainAssembly)
.That()
.DoNotHaveNameMatching("LoggingExtensions") // exception 1
.And()
.DoNotHaveNameMatching("CacheHelper") // exception 2
.And()
.DoNotHaveNameMatching("DateTimeProvider") // exception 3
.ShouldNot()
.HaveDependencyOn("MyApp.Infrastructure")
.GetResult();
// Three exceptions later, what exactly is this test protecting?
}NetArchTest is good. It is genuinely the best tool in the Convention era. But it runs at test time, not compile time. The developer discovers the violation minutes (or hours, if they pushed to CI) after introducing it. The right answer came too late.
Era 4: Contention -- [Layer] with Source Generators and Analyzers
What if the layer rules were declared in code, enforced by the compiler, and documented by the generated output?
The Developer Writes This
// src/MyApp.Domain/AssemblyAttributes.cs
[assembly: Layer("Domain")]// src/MyApp.Domain/AssemblyAttributes.cs
[assembly: Layer("Domain")]// src/MyApp.Application/AssemblyAttributes.cs
[assembly: Layer("Application")]
[assembly: DependsOn("Domain")]// src/MyApp.Application/AssemblyAttributes.cs
[assembly: Layer("Application")]
[assembly: DependsOn("Domain")]// src/MyApp.Infrastructure/AssemblyAttributes.cs
[assembly: Layer("Infrastructure")]
[assembly: DependsOn("Domain")]
[assembly: DependsOn("Application")]
[assembly: Implements("Domain")] // "I implement interfaces defined in Domain"// src/MyApp.Infrastructure/AssemblyAttributes.cs
[assembly: Layer("Infrastructure")]
[assembly: DependsOn("Domain")]
[assembly: DependsOn("Application")]
[assembly: Implements("Domain")] // "I implement interfaces defined in Domain"// src/MyApp.Presentation/AssemblyAttributes.cs
[assembly: Layer("Presentation")]
[assembly: DependsOn("Application")]// src/MyApp.Presentation/AssemblyAttributes.cs
[assembly: Layer("Presentation")]
[assembly: DependsOn("Application")]Four files. Four [Layer] attributes. A handful of [DependsOn] declarations. That is the entire architecture definition. No wiki page. No ADR. No NetArchTest rules. No CI script. The attributes declare the architecture. The SG generates documentation and infrastructure. The analyzer enforces the boundaries.
Read those four files and you understand the architecture. There is no other document to consult. There is no other artifact to maintain. The attributes are the single source of truth.
The Attributes
namespace Cmf.Architecture.Lib;
/// <summary>
/// Declares which architectural layer this assembly belongs to.
/// The Source Generator uses this to generate dependency constraints.
/// The Analyzer uses this to emit compile errors for illegal references.
/// </summary>
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
public sealed class LayerAttribute : Attribute
{
public string Name { get; }
public LayerAttribute(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
}
/// <summary>
/// Declares an allowed dependency from this assembly's layer to another layer.
/// Any reference to a layer NOT declared with [DependsOn] is a compile error.
/// </summary>
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public sealed class DependsOnAttribute : Attribute
{
public string LayerName { get; }
public DependsOnAttribute(string layerName)
{
LayerName = layerName ?? throw new ArgumentNullException(nameof(layerName));
}
}
/// <summary>
/// Declares that this assembly provides implementations for interfaces
/// defined in the specified layer. Used for dependency inversion documentation.
/// </summary>
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public sealed class ImplementsAttribute : Attribute
{
public string LayerName { get; }
public ImplementsAttribute(string layerName)
{
LayerName = layerName ?? throw new ArgumentNullException(nameof(layerName));
}
}namespace Cmf.Architecture.Lib;
/// <summary>
/// Declares which architectural layer this assembly belongs to.
/// The Source Generator uses this to generate dependency constraints.
/// The Analyzer uses this to emit compile errors for illegal references.
/// </summary>
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
public sealed class LayerAttribute : Attribute
{
public string Name { get; }
public LayerAttribute(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
}
/// <summary>
/// Declares an allowed dependency from this assembly's layer to another layer.
/// Any reference to a layer NOT declared with [DependsOn] is a compile error.
/// </summary>
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public sealed class DependsOnAttribute : Attribute
{
public string LayerName { get; }
public DependsOnAttribute(string layerName)
{
LayerName = layerName ?? throw new ArgumentNullException(nameof(layerName));
}
}
/// <summary>
/// Declares that this assembly provides implementations for interfaces
/// defined in the specified layer. Used for dependency inversion documentation.
/// </summary>
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public sealed class ImplementsAttribute : Attribute
{
public string LayerName { get; }
public ImplementsAttribute(string layerName)
{
LayerName = layerName ?? throw new ArgumentNullException(nameof(layerName));
}
}What the Source Generator Produces
1. InternalsVisibleTo Restrictions
// ── Generated: LayerVisibility.g.cs (in MyApp.Domain) ──
// Domain has no [DependsOn] declarations.
// Therefore: no InternalsVisibleTo is generated.
// Internal types in Domain are invisible to all other projects.
// This is correct: Domain should expose only its public API.
// ── Generated: LayerVisibility.g.cs (in MyApp.Application) ──
[assembly: InternalsVisibleTo("MyApp.Domain")]
// Application's internal helpers are visible to Domain (for shared kernel scenarios)
// but NOT to Infrastructure or Presentation.
// ── Generated: LayerVisibility.g.cs (in MyApp.Infrastructure) ──
[assembly: InternalsVisibleTo("MyApp.Domain")]
[assembly: InternalsVisibleTo("MyApp.Application")]
// Infrastructure internals visible to the layers it implements.// ── Generated: LayerVisibility.g.cs (in MyApp.Domain) ──
// Domain has no [DependsOn] declarations.
// Therefore: no InternalsVisibleTo is generated.
// Internal types in Domain are invisible to all other projects.
// This is correct: Domain should expose only its public API.
// ── Generated: LayerVisibility.g.cs (in MyApp.Application) ──
[assembly: InternalsVisibleTo("MyApp.Domain")]
// Application's internal helpers are visible to Domain (for shared kernel scenarios)
// but NOT to Infrastructure or Presentation.
// ── Generated: LayerVisibility.g.cs (in MyApp.Infrastructure) ──
[assembly: InternalsVisibleTo("MyApp.Domain")]
[assembly: InternalsVisibleTo("MyApp.Application")]
// Infrastructure internals visible to the layers it implements.2. Dependency Graph Documentation (Markdown)
// ── Generated: ARCHITECTURE.g.md ──
// Auto-generated from [Layer] and [DependsOn] attributes. Do not edit.
// # Architecture: Layer Dependencies
//
// | Layer | Depends On | Implemented By |
// |------------------|----------------------|------------------|
// | Domain | (none) | Infrastructure |
// | Application | Domain | - |
// | Infrastructure | Domain, Application | - |
// | Presentation | Application | - |
//
// ## Dependency Rules
// - Domain → (nothing) — Domain is the innermost layer
// - Application → Domain only
// - Infrastructure → Domain, Application (implements Domain interfaces)
// - Presentation → Application only
//
// ## Violations
// Any reference not declared with [DependsOn] produces compile error ARCH001.// ── Generated: ARCHITECTURE.g.md ──
// Auto-generated from [Layer] and [DependsOn] attributes. Do not edit.
// # Architecture: Layer Dependencies
//
// | Layer | Depends On | Implemented By |
// |------------------|----------------------|------------------|
// | Domain | (none) | Infrastructure |
// | Application | Domain | - |
// | Infrastructure | Domain, Application | - |
// | Presentation | Application | - |
//
// ## Dependency Rules
// - Domain → (nothing) — Domain is the innermost layer
// - Application → Domain only
// - Infrastructure → Domain, Application (implements Domain interfaces)
// - Presentation → Application only
//
// ## Violations
// Any reference not declared with [DependsOn] produces compile error ARCH001.3. Layer Registry for Runtime Validation
// ── Generated: LayerRegistry.g.cs ──
[GeneratedCode("Cmf.Architecture.Generators", "1.0.0")]
public static class LayerRegistry
{
public static IReadOnlyDictionary<string, LayerInfo> Layers { get; } =
new Dictionary<string, LayerInfo>
{
["Domain"] = new LayerInfo(
Name: "Domain",
AssemblyName: "MyApp.Domain",
DependsOn: Array.Empty<string>(),
Implements: Array.Empty<string>()),
["Application"] = new LayerInfo(
Name: "Application",
AssemblyName: "MyApp.Application",
DependsOn: new[] { "Domain" },
Implements: Array.Empty<string>()),
["Infrastructure"] = new LayerInfo(
Name: "Infrastructure",
AssemblyName: "MyApp.Infrastructure",
DependsOn: new[] { "Domain", "Application" },
Implements: new[] { "Domain" }),
["Presentation"] = new LayerInfo(
Name: "Presentation",
AssemblyName: "MyApp.Presentation",
DependsOn: new[] { "Application" },
Implements: Array.Empty<string>()),
};
public static bool IsAllowed(string fromLayer, string toLayer)
{
if (!Layers.TryGetValue(fromLayer, out var info))
return false;
return info.DependsOn.Contains(toLayer);
}
}
public sealed record LayerInfo(
string Name,
string AssemblyName,
string[] DependsOn,
string[] Implements);// ── Generated: LayerRegistry.g.cs ──
[GeneratedCode("Cmf.Architecture.Generators", "1.0.0")]
public static class LayerRegistry
{
public static IReadOnlyDictionary<string, LayerInfo> Layers { get; } =
new Dictionary<string, LayerInfo>
{
["Domain"] = new LayerInfo(
Name: "Domain",
AssemblyName: "MyApp.Domain",
DependsOn: Array.Empty<string>(),
Implements: Array.Empty<string>()),
["Application"] = new LayerInfo(
Name: "Application",
AssemblyName: "MyApp.Application",
DependsOn: new[] { "Domain" },
Implements: Array.Empty<string>()),
["Infrastructure"] = new LayerInfo(
Name: "Infrastructure",
AssemblyName: "MyApp.Infrastructure",
DependsOn: new[] { "Domain", "Application" },
Implements: new[] { "Domain" }),
["Presentation"] = new LayerInfo(
Name: "Presentation",
AssemblyName: "MyApp.Presentation",
DependsOn: new[] { "Application" },
Implements: Array.Empty<string>()),
};
public static bool IsAllowed(string fromLayer, string toLayer)
{
if (!Layers.TryGetValue(fromLayer, out var info))
return false;
return info.DependsOn.Contains(toLayer);
}
}
public sealed record LayerInfo(
string Name,
string AssemblyName,
string[] DependsOn,
string[] Implements);What the Analyzer Enforces
The Roslyn analyzer runs at compile time -- not test time, not CI time, not "when someone remembers to run the architecture tests." It runs on every keystroke in the IDE and on every build.
// The analyzer detects four categories of violations:
// ═══════════════════════════════════════════════════════════════
// ARCH001: Illegal Layer Dependency
// ═══════════════════════════════════════════════════════════════
// In MyApp.Domain/Services/OrderService.cs:
using MyApp.Infrastructure.Persistence; // ← red squiggle, immediately
// Error ARCH001: Assembly 'MyApp.Domain' (Layer: Domain) references
// 'MyApp.Infrastructure' (Layer: Infrastructure), but Domain does not
// declare [DependsOn("Infrastructure")].
//
// Allowed dependencies for Domain: (none)
//
// Fix: Move the infrastructure concern behind an interface in Domain,
// and implement it in Infrastructure.
// ═══════════════════════════════════════════════════════════════
// ARCH002: Circular Dependency Between Layers
// ═══════════════════════════════════════════════════════════════
// If someone adds [DependsOn("Presentation")] to Application:
[assembly: Layer("Application")]
[assembly: DependsOn("Domain")]
[assembly: DependsOn("Presentation")] // ← red squiggle
// Error ARCH002: Circular dependency detected:
// Application → Presentation → Application
//
// Layer dependencies must form a directed acyclic graph (DAG).
// Remove one of the [DependsOn] declarations to break the cycle.
// ═══════════════════════════════════════════════════════════════
// ARCH003: Type in Wrong Layer
// ═══════════════════════════════════════════════════════════════
// In MyApp.Presentation/Models/Order.cs:
namespace MyApp.Presentation.Models;
[AggregateRoot] // ← red squiggle
public class Order : Entity<OrderId>
{
// ...
}
// Error ARCH003: Type 'Order' is marked with [AggregateRoot] but is
// defined in assembly 'MyApp.Presentation' (Layer: Presentation).
// Domain entities must be in a Domain layer.
//
// Fix: Move this type to the MyApp.Domain project.
// ═══════════════════════════════════════════════════════════════
// ARCH004: Missing Layer Declaration
// ═══════════════════════════════════════════════════════════════
// In a new project MyApp.Shared that has no [Layer] attribute:
// Warning ARCH004: Assembly 'MyApp.Shared' does not declare a
// [Layer] attribute. Architecture enforcement is disabled for
// this assembly. Add [assembly: Layer("SharedKernel")] to
// participate in layer dependency validation.// The analyzer detects four categories of violations:
// ═══════════════════════════════════════════════════════════════
// ARCH001: Illegal Layer Dependency
// ═══════════════════════════════════════════════════════════════
// In MyApp.Domain/Services/OrderService.cs:
using MyApp.Infrastructure.Persistence; // ← red squiggle, immediately
// Error ARCH001: Assembly 'MyApp.Domain' (Layer: Domain) references
// 'MyApp.Infrastructure' (Layer: Infrastructure), but Domain does not
// declare [DependsOn("Infrastructure")].
//
// Allowed dependencies for Domain: (none)
//
// Fix: Move the infrastructure concern behind an interface in Domain,
// and implement it in Infrastructure.
// ═══════════════════════════════════════════════════════════════
// ARCH002: Circular Dependency Between Layers
// ═══════════════════════════════════════════════════════════════
// If someone adds [DependsOn("Presentation")] to Application:
[assembly: Layer("Application")]
[assembly: DependsOn("Domain")]
[assembly: DependsOn("Presentation")] // ← red squiggle
// Error ARCH002: Circular dependency detected:
// Application → Presentation → Application
//
// Layer dependencies must form a directed acyclic graph (DAG).
// Remove one of the [DependsOn] declarations to break the cycle.
// ═══════════════════════════════════════════════════════════════
// ARCH003: Type in Wrong Layer
// ═══════════════════════════════════════════════════════════════
// In MyApp.Presentation/Models/Order.cs:
namespace MyApp.Presentation.Models;
[AggregateRoot] // ← red squiggle
public class Order : Entity<OrderId>
{
// ...
}
// Error ARCH003: Type 'Order' is marked with [AggregateRoot] but is
// defined in assembly 'MyApp.Presentation' (Layer: Presentation).
// Domain entities must be in a Domain layer.
//
// Fix: Move this type to the MyApp.Domain project.
// ═══════════════════════════════════════════════════════════════
// ARCH004: Missing Layer Declaration
// ═══════════════════════════════════════════════════════════════
// In a new project MyApp.Shared that has no [Layer] attribute:
// Warning ARCH004: Assembly 'MyApp.Shared' does not declare a
// [Layer] attribute. Architecture enforcement is disabled for
// this assembly. Add [assembly: Layer("SharedKernel")] to
// participate in layer dependency validation.Each diagnostic includes the violation, the reason, and the fix. The developer does not need to consult a wiki page to understand what went wrong. The error message IS the documentation. This is the Contention principle in its purest form: the tooling tells you what is wrong and how to fix it, in the same place where you write code.
The Developer Experience
The developer who tries to add an illegal reference sees the error before they finish typing:
// 1. Developer opens OrderService.cs in MyApp.Domain
// 2. Types: using MyApp.Infrastructure.
// 3. IDE shows red squiggle immediately — ARCH001
// 4. Error message explains what is wrong AND how to fix it
// 5. Developer never commits the violation
// 6. No test to run. No CI to wait for. No dashboard to check.// 1. Developer opens OrderService.cs in MyApp.Domain
// 2. Types: using MyApp.Infrastructure.
// 3. IDE shows red squiggle immediately — ARCH001
// 4. Error message explains what is wrong AND how to fix it
// 5. Developer never commits the violation
// 6. No test to run. No CI to wait for. No dashboard to check.Compare this to the Convention era:
// 1. Developer opens OrderService.cs in MyApp.Domain
// 2. Types: using MyApp.Infrastructure.
// 3. Code compiles fine. IDE shows nothing.
// 4. Developer writes 200 lines of code against the illegal reference
// 5. Developer commits, pushes, opens PR
// 6. CI runs architecture tests (5 minutes later)
// 7. Test fails: "Domain must not depend on Infrastructure"
// 8. Developer must undo the reference AND rewrite 200 lines
// 9. Total wasted time: 45 minutes// 1. Developer opens OrderService.cs in MyApp.Domain
// 2. Types: using MyApp.Infrastructure.
// 3. Code compiles fine. IDE shows nothing.
// 4. Developer writes 200 lines of code against the illegal reference
// 5. Developer commits, pushes, opens PR
// 6. CI runs architecture tests (5 minutes later)
// 7. Test fails: "Domain must not depend on Infrastructure"
// 8. Developer must undo the reference AND rewrite 200 lines
// 9. Total wasted time: 45 minutesThe difference is not just speed. It is the difference between prevention and detection. The analyzer prevents the violation from ever existing in the codebase. The test detects it after the damage is done.
There is a subtler benefit: the developer in the Contention era never builds mental models on top of the illegal reference. In the Convention era, the developer writes 200 lines of code that depend on the illegal reference. They build abstractions on top of it. They make design decisions that assume the reference exists. When the test fails, they must not only remove the reference but also rethink the design. The Contention era stops the chain at the first link.
This is particularly important for junior developers and new team members. They do not yet have the mental model of the architecture. They do not know which references are legal and which are not. The Convention era expects them to have read the wiki. The Contention era guides them with red squiggles. The compiler is the best onboarding tool ever written.
What Happens When You Add a New Layer?
In the Convention era, adding a new project (say, MyApp.BackgroundJobs) requires:
- Create the project
- Decide which layer it belongs to
- Update the ADR with the new layer's allowed dependencies
- Update the onboarding guide
- Add NetArchTest rules for the new project (at least 3 new test methods)
- Add the new project to the .csproj parsing test's
[InlineData]entries - Verify CI still runs the architecture tests for the new project
- Update the dependency diagram in the wiki
- Announce the new layer rules to the team
Nine steps. Five artifacts to update. At least one will be forgotten.
In the Contention era:
// src/MyApp.BackgroundJobs/AssemblyAttributes.cs
[assembly: Layer("BackgroundJobs")]
[assembly: DependsOn("Application")]
[assembly: DependsOn("Infrastructure")]// src/MyApp.BackgroundJobs/AssemblyAttributes.cs
[assembly: Layer("BackgroundJobs")]
[assembly: DependsOn("Application")]
[assembly: DependsOn("Infrastructure")]Three lines. The SG updates the generated documentation. The analyzer enforces the new layer's constraints immediately. The registry includes the new layer automatically. No wiki to update. No tests to write. No team announcement needed -- the compiler already knows.
If another project tries to reference MyApp.BackgroundJobs without declaring [DependsOn("BackgroundJobs")], the build fails with ARCH001. If BackgroundJobs tries to reference Presentation, the build fails with ARCH001. The rules are enforced from the moment the attribute is written.
In the Convention era, adding a new project is the moment architecture erosion starts. The project exists. The tests do not cover it yet. The window between "project created" and "architecture tests updated" is the window where violations enter. In the Contention era, that window does not exist. The [Layer] attribute is part of the project creation. If it is missing, ARCH004 warns immediately.
Convention Tax: Zero
📄 No wiki page needed — [Layer] and [DependsOn] ARE the documentation
📄 No ADR needed — the assembly attributes declare the decision
📄 No NetArchTest rules — the analyzer enforces at compile time
📄 No .csproj parsing tests — the SG validates project references
📄 No CI script for architecture tests — the build fails on ARCH001
────────────────────────────────────────
Total convention overhead: 0 lines
Total attribute declarations: 9 lines (4 [Layer] + 5 [DependsOn])
Total generated code: ~120 lines (registry, visibility, documentation)
Total analyzer diagnostics: 4 rules (ARCH001-ARCH004)📄 No wiki page needed — [Layer] and [DependsOn] ARE the documentation
📄 No ADR needed — the assembly attributes declare the decision
📄 No NetArchTest rules — the analyzer enforces at compile time
📄 No .csproj parsing tests — the SG validates project references
📄 No CI script for architecture tests — the build fails on ARCH001
────────────────────────────────────────
Total convention overhead: 0 lines
Total attribute declarations: 9 lines (4 [Layer] + 5 [DependsOn])
Total generated code: ~120 lines (registry, visibility, documentation)
Total analyzer diagnostics: 4 rules (ARCH001-ARCH004)The attributes are the documentation. The generated code is the implementation. The analyzer is the enforcement. Three artifacts, one source of truth, zero maintenance burden.
The Convention Tax Table
| Artifact | Era 1: Code | Era 2: Config | Era 3: Convention | Era 4: Contention |
|---|---|---|---|---|
| Architecture rules | None (honor system) | NDepend XML (~80 lines) | NetArchTest C# (~65 lines) | [Layer] + [DependsOn] (9 lines) |
| Documentation | None | NDepend README (~30 lines) | ADR + onboarding guide (~45 lines) | Generated ARCHITECTURE.g.md (0 manual lines) |
| Enforcement mechanism | None | NDepend dashboard | CI test suite + .csproj parsing (~23 lines) | Roslyn analyzer (built into compiler) |
| Feedback delay | Never (runtime failure) | After build (dashboard) | Test time (minutes) | Compile time (instant) |
| Who catches violations | Nobody (until production) | Tech lead (maybe) | CI pipeline | Compiler + IDE |
| New project checklist | "Remember the rules" | Update NDepend config | Update tests + docs + CI | Add [Layer("X")] |
| Total overhead | 0 lines (no protection) | ~110 lines | ~133 lines | 9 lines (attributes only) |
The progression is clear: as enforcement moves closer to the compiler, the overhead drops and the reliability increases. Era 3 (Convention) catches violations at test time with 133 lines of overhead. Era 4 (Contention) catches them at compile time with 9 lines of attributes.
Notice the "New project checklist" row. In every era except Contention, adding a new project requires updating something outside the project itself. In Era 2, update the NDepend config. In Era 3, update the tests, the docs, and the CI. In Era 4, add one attribute to the new project. The new project declares its own constraints. Nothing else needs to change. This is the principle of locality: the architecture rule lives where the code lives, not in a separate artifact maintained by a separate process.
Also notice that Era 1 has 0 lines of overhead but also 0 protection. The Convention Tax is not about minimizing lines -- it is about maximizing the ratio of protection to overhead. Era 4 achieves near-perfect protection with near-zero overhead. That is the Contention sweet spot.
Diagram: Architecture Erosion Timeline
How quickly does architecture degrade under each enforcement model?
Era 1 erodes immediately because there is no enforcement. Era 2 erodes slowly because the dashboard is optional. Era 3 holds for a year or more, but eventually test gaps appear -- someone adds a new assembly and forgets to update the test, or a transitive dependency slips through that NetArchTest does not catch. Era 4 does not erode because the compiler does not forget, does not get tired, and does not skip the check.
The Compounding Effect
Architecture erosion is not linear. It is exponential. Here is why:
- Month 1: Developer A adds one illegal reference from Presentation to Infrastructure. "Just this once, for the deadline."
- Month 3: Developer B sees Developer A's code, assumes it is the pattern, adds two more.
- Month 6: The architecture test is updated to exclude "known exceptions" because fixing the violations would take a sprint.
- Month 9: New hires learn from the codebase, not the wiki. They see Infrastructure references in Presentation and replicate the pattern.
- Month 12: The architecture test has so many exclusions that it passes on every build. It tests nothing. The wiki still shows clean layers.
This is not hypothetical. This is the lifecycle of every architecture convention that relies on test-time enforcement. The exclusion list grows until the test is meaningless. The [Layer] attribute has no exclusion list. It has no exceptions. The build fails or it does not.
Diagram: Compile-Time vs Test-Time Enforcement
The critical architectural difference between Convention and Contention is when the violation is caught.
The Convention path has six steps and wastes 45 minutes of developer time. The Contention path has four steps and wastes zero developer time. The violation never enters the codebase. The 200 lines of code built on the illegal reference are never written. The PR is never blocked. The CI pipeline is never red.
This is the fundamental difference: Convention detects violations after the fact. Contention prevents them from existing.
Prevention is always cheaper than detection. In security, this principle is well understood: a firewall that blocks malicious traffic is cheaper than an incident response team that investigates breaches. In architecture enforcement, the same principle applies: a compiler error that blocks an illegal dependency is cheaper than a test failure that requires rework.
The Convention era made architecture enforcement possible. The Contention era makes it effortless.
The Structural Problem
Architecture enforcement is a case of the structural problem described in Don't Put the Burden on Developers. When architecture rules are conventions, the burden is on the developer to know the rules, follow the rules, and not circumvent the rules under pressure. When architecture rules are attributes and analyzers, the burden is on the compiler -- which does not feel pressure, does not cut corners, and does not have a sprint deadline.
The DDD domain makes this concrete: every aggregate root, every value object, every domain event belongs in a specific layer. The DDD rules about aggregate boundaries, encapsulation, and dependency direction are all architecture rules. In the Convention era, they are wiki pages. In the Contention era, they are [AggregateRoot], [ValueObject], [DomainEvent] -- attributes that the SG reads and the analyzer enforces.
The From Mud to DDD series shows what happens in brownfield: the architecture has already eroded. The bounded context libraries are the mechanism for re-establishing layer boundaries. But re-establishing them as conventions means they will erode again. Re-establishing them as [Layer] attributes means the compiler holds the line permanently.
This is particularly relevant for brownfield migrations. When you spend a sprint establishing clean layer boundaries in a legacy codebase, the last thing you want is for those boundaries to erode in the next sprint. Convention-based enforcement requires vigilance. Attribute-based enforcement requires only that the [Layer] attributes stay in place -- and since they are assembly-level attributes in a single file per project, they are not the kind of code that gets accidentally deleted or refactored away.
What the Convention Era Gets Right
NetArchTest is excellent software. It brought architecture enforcement from a separate commercial tool (NDepend) into the test suite, where it runs in CI and fails the build. This was a massive improvement over the honor system (Era 1) and the dashboard nobody checks (Era 2).
The Convention era's insight is correct: architecture rules should be executable, not just documented. The Convention era's limitation is in the feedback loop. Test-time enforcement catches violations after they are committed. Compile-time enforcement catches them before the developer finishes typing.
The [Layer] approach does not replace NetArchTest for teams that cannot build Source Generators and Analyzers. NetArchTest remains the best option for teams working within the Convention paradigm. But for teams that have invested in the Contention infrastructure -- the SG pipeline, the analyzer framework, the attribute library -- [Layer] is the natural extension.
There is also a middle ground: using NetArchTest alongside [Layer] attributes during a migration. The attributes declare the architecture. The tests verify it. Over time, as the analyzer matures, the tests become redundant -- they pass on every build because the analyzer has already prevented every violation the tests would catch. At that point, the tests can be removed. The attributes remain. The architecture holds.
Convention over Configuration was progress. Contention over Convention is the destination -- specifically for architecture enforcement, where the cost of late detection is highest because violations compound. One illegal dependency today becomes three tomorrow and twelve next quarter. The compiler stops the first one. The test suite catches the twelfth.
Architecture enforcement is perhaps the strongest argument for Contention because the cost of erosion is exponential, not linear. A single illegal dependency creates a precedent. Other developers see it in the codebase and assume it is acceptable. They add their own illegal dependencies. The precedent becomes a pattern. The pattern becomes the architecture. By the time someone notices, the "clean architecture" exists only in the wiki diagram.
The [Layer] attribute eliminates the precedent. The first violation is a compile error. There is no precedent to set. There is no pattern to emerge. The architecture is not an aspiration -- it is a constraint enforced by the same tool that enforces type safety.
Types don't erode. Neither should layers.
The compiler has always enforced type safety: you cannot assign a string to an int. With [Layer] attributes and Roslyn analyzers, the compiler now enforces layer safety: you cannot reference Infrastructure from Domain. The same mechanism, the same feedback loop, the same certainty. Architecture rules become first-class citizens of the type system.
That is the promise of Contention: everything the compiler can check, the compiler should check. Architecture is no exception.
Next: Part VIII: Configuration and Options -- where the Convention Tax appears in a different form: the convention that every feature must have an Options class, registered in a specific way, bound to a specific configuration section, with a specific naming pattern that exists only in a wiki page.