Software Architecture/Engineering  

Building Architecture Drift Detection into .NET Pull Requests

Architecture problems rarely appear in a single pull request.

A developer adds a new dependency between two projects. Another feature introduces a database call into an application service. Later, a utility project starts referencing infrastructure code. Each change may look reasonable in isolation, but over time the architecture becomes different from what the team originally designed.

This is architecture drift.

For large .NET applications, architecture drift can be particularly difficult to detect because the solution may contain dozens or hundreds of projects, shared libraries, APIs, background workers, infrastructure components, and test projects.

Code reviews can catch some of these problems, but relying entirely on reviewers is not scalable.

A better approach is to turn important architectural rules into automated checks and run them as part of pull requests.

The goal is not to prevent developers from changing the architecture. The goal is to make architectural changes explicit, reviewable, and intentional.

What Is Architecture Drift?

Architecture drift occurs when the implementation gradually moves away from the intended architectural boundaries.

Consider a simple .NET solution:

MyCompany.Application
        |
        v
MyCompany.Domain

MyCompany.Infrastructure
        |
        v
MyCompany.Application
        |
        v
MyCompany.Domain

MyCompany.Api
        |
        v
MyCompany.Application

The intended dependency direction might be:

API
 |
 v
Application
 |
 v
Domain

Infrastructure ---> Application
Infrastructure ---> Domain

Now imagine a developer adds:

MyCompany.Domain
        |
        v
MyCompany.Infrastructure

The project still compiles.

Tests may still pass.

The application may still work.

But the architecture has changed.

That is exactly the type of problem automated architecture validation should catch.

Why Pull Requests Are a Good Enforcement Point

Architecture checks are particularly useful in pull requests because that is where changes are reviewed.

A typical workflow becomes:

Developer changes code
        |
        v
Pull Request
        |
        +--> Build
        |
        +--> Unit Tests
        |
        +--> Static Analysis
        |
        +--> Architecture Tests
        |
        v
Review
        |
        v
Merge

If an architectural rule fails, the pull request can be blocked before the violation reaches the main branch.

This is much cheaper than discovering the problem months later.

Architecture Rules Should Be Explicit

Before implementing automation, define the rules.

For example:

Domain must not reference Infrastructure
Domain must not reference Web
Application must not reference Web
API may reference Application
Infrastructure may reference Application
Controllers should not directly access DbContext

Not every rule needs to be automated.

Start with boundaries that are:

Important
Stable
Objective
Easy to verify

A good automated rule should produce a clear answer:

Allowed

or:

Violation

Project References Are a Strong Starting Point

In .NET, project references provide a useful architectural signal.

Suppose the solution contains:

src/
    MyApp.Api/
    MyApp.Application/
    MyApp.Domain/
    MyApp.Infrastructure/

The project graph can be represented as:

MyApp.Api
   |
   v
MyApp.Application
   |
   v
MyApp.Domain

MyApp.Infrastructure
   |
   +----> MyApp.Application
   |
   +----> MyApp.Domain

A dependency from Domain to Infrastructure should therefore be rejected.

Inspecting Project References

A simple approach is to inspect .csproj files.

For example:

<ProjectReference Include="..\MyApp.Domain\MyApp.Domain.csproj" />

The important information is:

Source Project
        |
        v
Referenced Project

You can build a dependency graph from those relationships.

For larger repositories, it is useful to normalize project names before evaluating rules.

Define Architecture Rules as Data

Avoid scattering architectural rules throughout scripts.

Instead, represent them explicitly.

For example:

{
  "rules": [
    {
      "source": "MyApp.Domain",
      "forbidden": [
        "MyApp.Infrastructure",
        "MyApp.Api"
      ]
    },
    {
      "source": "MyApp.Application",
      "forbidden": [
        "MyApp.Api"
      ]
    }
  ]
}

This has an important advantage.

Developers can review architecture rules independently from the validation engine.

Build a Dependency Graph

The validator can convert project references into a graph:

Project A
   |
   +----> Project B
   |
   +----> Project C

For example:

Api
 |
 +--> Application
 |
 +--> Infrastructure

Application
 |
 +--> Domain

Infrastructure
 |
 +--> Application
 +--> Domain

Then compare the actual graph against the allowed dependency model.

A violation becomes a graph problem rather than a subjective code-review discussion.

A Simple C# Dependency Model

A small internal representation is enough for many repositories:

public sealed record ProjectDependency(
    string Source,
    string Target);

You can then build a collection:

var dependencies = new List<ProjectDependency>
{
    new("MyApp.Api", "MyApp.Application"),
    new("MyApp.Application", "MyApp.Domain"),
    new("MyApp.Infrastructure", "MyApp.Application")
};

The architecture validator can inspect this graph and identify forbidden edges.

Detecting Forbidden Dependencies

A simple validator could look like this:

public sealed record ArchitectureRule(
    string Source,
    IReadOnlySet<string> ForbiddenTargets);

public static IReadOnlyList<string> Validate(
    IEnumerable<ProjectDependency> dependencies,
    IEnumerable<ArchitectureRule> rules)
{
    var violations = new List<string>();

    foreach (var dependency in dependencies)
    {
        var rule = rules.FirstOrDefault(
            x => x.Source.Equals(
                dependency.Source,
                StringComparison.OrdinalIgnoreCase));

        if (rule is null)
            continue;

        if (rule.ForbiddenTargets.Contains(dependency.Target))
        {
            violations.Add(
                $"{dependency.Source} must not reference " +
                $"{dependency.Target}.");
        }
    }

    return violations;
}

The important part is not the implementation itself.

The important part is that the architecture rule becomes executable.

Fail the Pull Request on Violations

The validator should return a non-zero exit code when architecture rules fail.

For example:

var violations = Validate(dependencies, rules);

foreach (var violation in violations)
{
    Console.Error.WriteLine(
        $"ARCHITECTURE VIOLATION: {violation}");
}

return violations.Count == 0 ? 0 : 1;

The CI pipeline can then treat the result as a failed check.

This turns architecture from documentation into an enforceable engineering constraint.

Architecture Tests Inside the .NET Solution

Another approach is to implement architecture rules as automated tests.

For example:

[Fact]
public void Domain_should_not_reference_infrastructure()
{
    var result = ArchitectureAnalyzer
        .Analyze("MyApp.Domain");

    Assert.DoesNotContain(
        result.Dependencies,
        dependency =>
            dependency.Contains("MyApp.Infrastructure"));
}

The exact architecture-testing mechanism can vary, but the principle is the same:

Architecture rule
       |
       v
Executable test
       |
       v
CI

This approach works particularly well when architecture rules need more than simple project-reference checks.

Namespace-Level Architecture Rules

Project dependencies are only one layer.

A project can have internal architectural boundaries based on namespaces.

For example:

MyApp.Application.Commands
MyApp.Application.Queries
MyApp.Application.Services
MyApp.Application.Infrastructure

You may want to prevent:

Commands
   |
   X
Infrastructure

even though everything exists inside the same project.

Namespace-level rules require deeper analysis of source code and symbol references.

This is where static analysis becomes more useful than simply parsing project files.

API Boundary Rules

Another useful rule is preventing controllers from directly accessing persistence infrastructure.

For example, this may be considered a violation:

public class OrdersController : ControllerBase
{
    private readonly AppDbContext _db;

    public OrdersController(AppDbContext db)
    {
        _db = db;
    }
}

The preferred architecture might require:

Controller
    |
    v
Application Service
    |
    v
Repository / Persistence

The validator can detect references to restricted types or namespaces.

For example:

MyApp.Api
    X MyApp.Infrastructure.Persistence.AppDbContext

This is a more precise architectural rule than simply checking project references.

Dependency Direction Is More Important Than Project Count

Teams sometimes create many projects and assume that means the application has a clean architecture.

It does not.

A solution with:

30 projects

can still have poor boundaries.

A better architecture check focuses on dependency direction.

For example:

Domain
  ^
  |
Application
  ^
  |
API

The exact structure can vary, but dependencies should reflect the intended design.

Detect Circular Dependencies

Architecture validation should also detect cycles.

For example:

Project A
   |
   v
Project B
   |
   v
Project C
   |
   v
Project A

This creates:

A -> B -> C -> A

Circular dependencies make systems harder to understand and evolve.

A simple depth-first search can identify cycles.

Conceptually:

Visit A
  |
  v
Visit B
  |
  v
Visit C
  |
  v
A already in current path
  |
  v
Cycle detected

The validator should report the complete path:

Circular dependency detected:

MyApp.A
 -> MyApp.B
 -> MyApp.C
 -> MyApp.A

That is much more useful than simply reporting:

Architecture failed.

Detect Dependency Growth

Architecture drift is not always an outright violation.

Sometimes the problem is uncontrolled dependency growth.

For example:

Application
  |
  +--> Domain
  +--> Contracts
  +--> Logging
  +--> Infrastructure
  +--> Messaging
  +--> Storage
  +--> ExternalApi
  +--> Reporting

The architecture may still technically work, but the Application project is becoming tightly coupled.

Track dependency count over time.

For example:

Pull RequestApplication Dependencies
PR #1014
PR #1255
PR #1707
PR #22011

A sudden increase should trigger architectural discussion even if no hard rule is violated.

Architecture Drift Can Be Measured

You can define a simple drift score based on violations and dependency changes.

For example:

Drift Score =
Critical Violations × 5
+ Normal Violations × 2
+ New Dependencies
+ Circular Dependencies × 5

The exact formula is less important than consistency.

Avoid presenting such a score as an objective measure of architecture quality. It is a signal for engineering review.

Pull Request Diff Analysis

A powerful improvement is to analyze only architectural changes introduced by the pull request.

Instead of scanning everything:

Entire Repository
       |
       v
Architecture Analyzer

you can compare:

Base Branch
    |
    v
Current Branch
    |
    v
Dependency Difference

For example:

Before:
Api -> Application
Application -> Domain

After:
Api -> Application
Application -> Domain
Domain -> Infrastructure

The validator can report:

New forbidden dependency:

MyApp.Domain
    ->
MyApp.Infrastructure

This makes the feedback highly relevant to the developer who opened the pull request.

CI Pipeline Integration

A generic CI pipeline might look like:

steps:
  - name: Restore
    run: dotnet restore

  - name: Build
    run: dotnet build --no-restore

  - name: Test
    run: dotnet test --no-build

  - name: Architecture Validation
    run: dotnet run --project tools/ArchitectureValidator

The architecture validator becomes another quality gate.

The exact CI syntax will depend on the platform being used.

Keep Architecture Failures Actionable

A bad error message:

Architecture validation failed.

A useful error message:

Architecture violation

Source:
MyApp.Domain

Forbidden dependency:
MyApp.Infrastructure

Detected in:
MyApp.Domain.csproj

Rule:
Domain projects must not depend on Infrastructure.

Suggested action:
Move the dependency behind an abstraction or
relocate the implementation to Infrastructure.

Good diagnostics reduce the time required to fix violations.

Allow Explicit Exceptions

Not every architectural exception is wrong.

A team may intentionally allow a dependency for a specific reason.

Do not encourage developers to bypass the validator globally.

Instead, support controlled exceptions.

For example:

{
  "exceptions": [
    {
      "source": "MyApp.Application",
      "target": "MyApp.Legacy",
      "expires": "2026-12-31",
      "reason": "Temporary migration dependency"
    }
  ]
}

The expiration date is important.

Without expiration, temporary exceptions tend to become permanent architecture.

Treat Architecture Rules as Code

Architecture rules should be version controlled.

A change to:

Allowed dependencies
Forbidden namespaces
Exceptions
Thresholds

should go through the same pull-request process as application code.

This creates an important feedback loop:

Architecture changes
       |
       v
Pull Request
       |
       v
Architecture rules reviewed
       |
       v
Validator updated
       |
       v
New architecture becomes enforceable

The architecture therefore evolves intentionally.

Common Mistakes

Automating Every Rule

Not every architectural decision can be expressed as a static rule.

Automate stable and objective boundaries first.

Blocking All Exceptions

Real systems sometimes require transitional architecture.

Use explicit, documented exceptions instead.

Ignoring Legacy Code

If an existing repository has hundreds of violations, immediately blocking every pull request may make the system unusable.

A better migration strategy is:

Existing violations
        |
        v
Baseline
        |
        v
Block new violations
        |
        v
Reduce baseline gradually

Reporting Only the Number of Violations

Developers need the source, target, rule, and location.

Checking Only Project References

Some important architecture violations happen at namespace, type, or API levels.

Making Architecture Validation Too Slow

Pull-request checks should be fast enough to run frequently.

Cache where appropriate and avoid unnecessarily analyzing unrelated projects.

A Practical Migration Strategy

For an existing .NET repository, start with project-level dependencies.

Phase 1: Discover

Generate the current dependency graph.

Projects
   |
   v
References
   |
   v
Dependency Graph

Phase 2: Identify

Find:

Circular dependencies
Unexpected dependencies
High-dependency projects
Layer violations

Phase 3: Baseline

Document existing violations without immediately blocking the team.

Phase 4: Enforce New Changes

Fail pull requests only when they introduce new violations.

Phase 5: Reduce Existing Drift

Create separate engineering work to remove baseline violations.

This approach avoids turning architecture enforcement into a large migration project.

Best Practices

Keep architectural rules small and understandable.

Prefer:

Domain -> must not reference Infrastructure

over a complicated rule that nobody can explain.

Run architecture validation on every pull request.

Keep rules in source control.

Make failures actionable.

Use temporary exceptions with expiration dates.

Track dependency growth.

Detect circular dependencies.

Separate architectural enforcement from subjective architectural review.

Most importantly, treat architecture validation as a guardrail rather than a replacement for engineering judgment.

Frequently Asked Questions

Can architecture drift be detected without analyzing source code?

Yes. Project references alone can detect many important dependency violations. More advanced rules require namespace or symbol-level analysis.

Should architecture tests run on every pull request?

For stable rules, yes. Pull-request execution provides fast feedback before architectural changes reach the main branch.

What should happen with existing violations?

Create a baseline and prevent new violations first. Then gradually reduce the existing debt.

Can architecture rules change?

Absolutely. Architecture evolves as systems evolve. Changes to architectural rules should themselves be reviewed carefully because they modify the boundaries enforced by the development process.

Should architecture validation replace code review?

No. Automated checks handle objective constraints. Human review is still required for architectural trade-offs, design quality, maintainability, and business context.

Conclusion

Architecture drift is usually gradual. A single unwanted dependency rarely breaks a production application, which is why these problems can remain invisible for a long time. The real cost appears later when boundaries become unclear, components become tightly coupled, and changes require touching more parts of the system.

.NET projects provide enough structural information to automate many of these checks. Project references, namespaces, type dependencies, circular-reference detection, and dependency growth can all become useful architecture signals.

The most effective approach is to move architectural rules into the pull-request workflow. Start with a small set of stable rules, establish a baseline for existing problems, block new violations, and provide clear diagnostics when something fails.

When architecture becomes an automated quality gate, developers do not have to rely on memory or documentation alone. The repository itself helps enforce the design decisions the team has agreed to maintain.