跳到正文
DEV Community· Khalid BOURZAYQ·· 2 小時前AI 評分54

.NET 應用程式能編譯,就代表架構正確嗎?Architecture Guardian 如何檢查依賴邊界

Your .NET Application Compiles. Does That Mean Your Architecture Is Correct?

AI 導讀

Architecture Guardian 是作者開發的開源 .NET 工具,可用 YAML 政策定義架構邊界,並透過 Roslyn 分析器、CLI 和 MCP 伺服器檢查依賴規則。

正文

When we start developing a new application, one of the first things we usually discuss is its architecture.

Should we use Clean Architecture? A modular monolith? A traditional layered architecture?

We define our projects, organize dependencies, establish some rules, and everything looks good.

But what happens after six months? Or after several developers have worked on the same solution?

More importantly, who makes sure that the architecture we initially designed is still respected?

The compiler? Not really.

Our unit tests? Not necessarily.

Code reviews? Maybe, if someone notices the problem.

This is something I've been thinking about for a while, especially with the increasing adoption of AI coding agents.

Let's take a practical example to understand the problem, and then see how we can address it.

When valid C# becomes an architectural problem

Imagine we're developing an order management application using Clean Architecture.

Our solution contains the following projects:

Shop.slnx
│
├── Shop.Domain
│   ├── Order.cs
│   └── IOrderRepository.cs
│
├── Shop.Application
│   └── OrderService.cs
│
├── Shop.Infrastructure
│   └── SqlOrderRepository.cs
│
└── Shop.Api
    └── Program.cs

Nothing complicated here.

The Domain layer contains our business model and abstractions.

Application contains the use cases.

Infrastructure contains the technical implementations.

And API is our entry point and composition root.

According to the Dependency Rule, dependencies should point inward. Domain should not know about Infrastructure, and Application should not directly depend on Infrastructure implementations.

Now, imagine that we're implementing a use case to confirm an order.

A developer writes the following code:

using Shop.Domain;
using Shop.Infrastructure;

namespace Shop.Application;

public sealed class OrderService(
    SqlOrderRepository repository)
{
    public async Task ConfirmAsync(
        Guid orderId,
        CancellationToken cancellationToken)
    {
        var order = await repository.GetAsync(
            orderId,
            cancellationToken);

        order.Confirm();

        await repository.SaveAsync(
            order,
            cancellationToken);
    }
}

Assuming the project reference exists and the repository exposes these methods, this code is perfectly valid from the compiler's perspective.

It can compile successfully.

The unit tests can pass.

The feature can work exactly as expected.

So, what's the problem?

Well, our Application layer now directly depends on Infrastructure.

We have introduced an outward dependency that violates the architecture we initially agreed on.

And this is where things become interesting.

The compiler checks whether our code is valid C#. It doesn't know whether the dependencies respect our architectural decisions.

Of course, we could catch this during code review. But should we really depend entirely on code reviews to detect something that can be checked automatically?

I don't think so.

Let's refactor our code

The problem is not the functionality itself. The problem is the dependency.

Instead of injecting SqlOrderRepository, our Application layer should depend on an abstraction.

Let's start with our Domain project.

namespace Shop.Domain;

public interface IOrderRepository
{
    Task<Order> GetAsync(
        Guid id,
        CancellationToken cancellationToken);

    Task SaveAsync(
        Order order,
        CancellationToken cancellationToken);
}

We can now change our Application service:

using Shop.Domain;

namespace Shop.Application;

public sealed class OrderService(
    IOrderRepository repository)
{
    public async Task ConfirmAsync(
        Guid orderId,
        CancellationToken cancellationToken)
    {
        var order = await repository.GetAsync(
            orderId,
            cancellationToken);

        order.Confirm();

        await repository.SaveAsync(
            order,
            cancellationToken);
    }
}

And let Infrastructure implement the abstraction:

using Shop.Domain;

namespace Shop.Infrastructure;

public sealed class SqlOrderRepository
    : IOrderRepository
{
    // Persistence implementation
}

Finally, we register the implementation in our API project:

builder.Services.AddScoped<
    IOrderRepository,
    SqlOrderRepository>();

There it is!

Our Application layer no longer depends on Infrastructure.

We have restored the intended dependency direction.

But we haven't solved the original problem yet.

What prevents another developer from introducing the same violation tomorrow?

Nothing, unless we introduce a mechanism to enforce that boundary.

What if architectural rules were executable?

We already automate a lot of things in software development.

We use analyzers to enforce coding conventions, tests to validate behavior, and CI pipelines to verify our changes before merging.

Why shouldn't we do the same with architecture?

Imagine being able to declare the following rules:

  • Application can depend on Domain.
  • Infrastructure can depend on Domain.
  • Domain cannot depend on Application or Infrastructure.
  • Modules can only access another module's explicitly exposed contracts.

And then automatically validate those rules against our solution.

This is the idea behind Architecture Guardian, an open-source .NET tool I've been working on.

It allows us to describe architectural boundaries using a YAML policy and validate them through Roslyn analyzers, a CLI, and MCP tools for AI coding agents.

Let's see how it works.

Defining our architecture

Instead of keeping our architecture rules only in documentation, we'll describe them in a file called architecture.guardian.yaml.

For our Shop solution, the policy can look like this:

version: 1

modules:
  - id: Shop
    projects: ["Shop.*/*.csproj"]

layers:
  - id: Domain
    projects: ["Shop.Domain/*.csproj"]

  - id: Application
    projects: ["Shop.Application/*.csproj"]
    allowedDependencies: [Domain]

  - id: Infrastructure
    projects: ["Shop.Infrastructure/*.csproj"]
    allowedDependencies: [Domain]

  - id: Api
    projects: ["Shop.Api/*.csproj"]
    allowedDependencies:
      - Domain
      - Application
      - Infrastructure

rules:
  AG002: error
  AG003: error

gate:
  failOn: error

Here, each project belongs to a layer, and each layer has an explicit list of allowed dependencies.

Notice that Application can only depend on Domain.

So, if someone introduces a dependency from Application to Infrastructure, it will violate our policy.

The important point is that Architecture Guardian doesn't impose Clean Architecture on your application.

You define your architecture. The tool verifies whether your code respects it.

For example, if we were using a different architectural style, we could define different boundaries and permissions.

The policy should describe the architecture we've chosen, not an architecture selected by the tool.

Let's break our architecture again

Remember our first implementation of OrderService?

Let's introduce the same problem again.

using Shop.Infrastructure;

namespace Shop.Application;

public sealed class OrderService(
    SqlOrderRepository repository)
{
    // ...
}

And suppose we add the corresponding project reference.

Our application can still compile.

But now we can validate the architecture.

First, install the Architecture Guardian CLI:

dotnet tool install --global \
  ArchitectureGuardian.Cli \
  --version 0.3.0

After restoring the solution, we can run:

archguard scan Shop.slnx \
  --policy architecture.guardian.yaml \
  --configuration Release \
  --trusted

For a repository we've authorized for MSBuild evaluation, --trusted allows the scan to proceed. It doesn't isolate untrusted build logic.

The scan can now detect our forbidden dependency and report an AG002 error.

An example diagnostic looks like this:

Shop.Application/OrderService.cs:
error AG002:
Layer 'Application' must not depend on
layer 'Infrastructure'.

Our code still compiles.

But our architecture validation fails.

That's exactly what we wanted.

Instead of depending on someone noticing an architectural violation during a pull request, we now have an explicit rule that can be checked automatically.

What about a modular monolith?

So far, we've looked at a fairly simple layered architecture.

Let's take a slightly more interesting case.

Imagine we're developing a modular monolith containing three business modules:

src/
├── Catalog/
│   ├── Catalog.Domain/
│   ├── Catalog.Application/
│   ├── Catalog.Contracts/
│   └── Catalog.Infrastructure/
│
├── Orders/
│   ├── Orders.Domain/
│   ├── Orders.Application/
│   └── Orders.Infrastructure/
│
└── Billing/
    ├── Billing.Contracts/
    ├── Billing.Application/
    └── Billing.Infrastructure/

Orders needs to retrieve some product information from Catalog.

A developer finds the following class:

namespace Catalog.Infrastructure;

public sealed class ProductRepository
{
    public Task<Product> GetAsync(Guid id)
    {
        // ...
    }
}

Since the class is public, the developer decides to inject it into Orders:

using Catalog.Infrastructure;

namespace Orders.Application;

public sealed class CreateOrderHandler(
    ProductRepository products)
{
    // ...
}

Again, the code can compile if the required references exist.

But we've introduced another problem.

Orders now knows about Catalog's internal persistence implementation.

What happens if Catalog changes its storage technology?

What if we replace SQL Server with another persistence mechanism?

Why should Orders care about that?

It shouldn't.

This is an example of an implementation detail leaking across module boundaries.

Refactoring through a public contract

Let's expose an abstraction through Catalog.Contracts.

namespace Catalog.Contracts;

public interface IProductReader
{
    Task<ProductDto?> GetAsync(
        Guid productId,
        CancellationToken cancellationToken);
}

Now, Orders only depends on the contract:

using Catalog.Contracts;

namespace Orders.Application;

public sealed class CreateOrderHandler(
    IProductReader products)
{
    // ...
}

Catalog can implement this interface internally.

Orders doesn't need to know whether the data comes from Entity Framework Core, an HTTP service, or something else.

We've created an explicit module boundary.

Architecture Guardian provides two complementary checks for this scenario:

AG001 validates whether a dependency between modules is allowed.

AG004 detects cross-module access to implementation projects instead of declared public contracts.

One important detail: a class being marked public in C# doesn't automatically make it a public architectural contract.

That distinction is particularly important in modular monoliths.

When the problem is bigger than one project

Now let's look at another interesting case.

Imagine we have two modules, Orders and Billing.

Orders needs some information from Billing, and Billing also needs some information from Orders.

Each module exposes its own Contracts project.

The dependencies look like this:

Orders.Application
    depends on Billing.Contracts

Billing.Application
    depends on Orders.Contracts

There is no circular project reference here.

The solution can build successfully because the contract projects don't reference each other.

However, at the logical module level, we have created a cycle.

Orders depends on Billing, and Billing depends on Orders.

Is this always a catastrophic problem?

Not necessarily. But if our architectural goal is to keep modules independent and prevent cycles, we need to detect it.

This is what AG003 is designed for.

It identifies cycles between logical modules using a complete solution analysis.

And this brings us to an important distinction between Roslyn analyzers and solution-level architecture checks.

Why Roslyn analyzers are not enough

Roslyn analyzers are extremely useful because they give us feedback directly while coding.

Architecture Guardian includes a Roslyn analyzer that can detect supported architectural violations within the compilation being analyzed.

This is valuable for developers because they don't have to run a separate command every time they modify a dependency.

But a Roslyn analyzer doesn't have the complete view of every relationship in a solution.

For example, the logical module cycle we just discussed requires examining dependencies across multiple projects.

That's why Architecture Guardian also provides a solution scanner.

The responsibilities are different:

  • Roslyn analyzer: immediate feedback for supported rules within the current compilation.
  • CLI: full solution analysis, including logical module cycles, with a gate suitable for automation.
  • MCP server: architectural context and analysis tools for AI coding agents.

The analyzer currently covers AG001, AG002, and AG004 through AG006 within its compilation scope.

AG003 requires the solution scan.

So, having no diagnostics in the editor is useful, but it doesn't mean the entire solution is architecturally compliant.

Enforcing the rules in CI

Now that we have defined our architecture, there's one more thing we should do.

Make sure that no change can be merged without validating it.

Since Architecture Guardian provides a CLI, we can execute the scan in an Azure DevOps pipeline, GitHub Actions workflow, or another CI environment.

For example:

steps:
  - script: |
      dotnet restore Shop.slnx \
        -p:Configuration=Release
    displayName: Restore solution

  - script: |
      archguard scan Shop.slnx \
        --policy architecture.guardian.yaml \
        --configuration Release \
        --trusted
    displayName: Validate architecture

This example assumes the agent already has the CLI and a compatible .NET SDK installed, and that the repository is trusted.

The scanner returns different exit codes depending on the result.

A successful scan passes the gate.

An architectural violation can fail the pipeline.

And if analysis cannot establish compliance, that is reported separately rather than being considered a successful architecture check.

This distinction is important.

A tool that couldn't analyze your solution shouldn't tell you that your architecture is valid.

For existing applications, Architecture Guardian also supports baselines and change-oriented analysis, which can help teams introduce enforcement progressively instead of fixing every historical violation immediately.

And what about AI coding agents?

This is probably one of the most interesting parts of the project for me.

We're increasingly using AI coding agents to implement features, refactor code, and work across entire repositories.

They can generate quite impressive code.

But they can also introduce architectural violations.

Let's return to our modular monolith.

Imagine giving an agent the following task:

"Implement a feature in Orders that retrieves product information from Catalog."

The agent explores the codebase and discovers Catalog.Infrastructure.ProductRepository.

The class is public.

The method already does what the agent needs.

So, it adds the reference and implements the feature.

The result can be functional.

But the agent has completely bypassed the architectural contract we wanted to preserve.

Was the agent wrong?

From a functional perspective, perhaps not.

From an architectural perspective, yes.

The problem is that the agent may not know our architectural decisions, especially when those decisions are only documented in a README or an architecture diagram.

Giving agents architectural context

Architecture Guardian provides an MCP server that exposes architectural information to compatible AI coding agents.

Instead of simply asking an agent to respect Clean Architecture, we can give it tools to inspect the actual architectural policy.

The MCP integration exposes operations such as:

  • get_architecture to discover modules, layers, and dependency permissions.
  • list_rules to understand available rules.
  • analyze_solution to validate changes against the solution.
  • explain_violation to understand a reported problem and its possible remediation.
  • analyze_changes to inspect findings relative to Git changes.

Let's imagine a different workflow.

Before implementing the feature, the agent asks Architecture Guardian for the solution's architectural context.

It discovers that Orders is allowed to access Catalog through its contract project, but not through Infrastructure.

The agent implements the feature using IProductReader.

Then it runs the architecture analysis.

If a violation is reported, it can inspect the diagnostic and correct its implementation.

The difference is that the agent no longer has to rely only on natural-language instructions to understand our architectural boundaries.

It can query them.

Of course, MCP doesn't guarantee that an agent will always make the right architectural decision.

And it doesn't replace the CI gate.

But it gives agents access to the same architecture policy that developers and pipelines use.

This is the direction I wanted to explore with Architecture Guardian.

Does this replace architecture tests or code reviews?

No, and I think it's important to clarify this.

We already have libraries and approaches for writing architecture tests in .NET.

They're useful, and Architecture Guardian isn't intended to make them irrelevant.

The goal is to make architectural rules available throughout the development workflow, instead of limiting validation to tests that someone must remember to run.

It also doesn't replace code reviews.

An architectural rule can verify that Application doesn't depend on Infrastructure.

But it cannot decide whether your abstraction is well designed, whether your aggregates make sense, or whether you've selected the appropriate architecture for your business requirements.

Those decisions still require engineering judgment.

The tool is there to enforce the boundaries we've explicitly declared.

And we should be careful not to create unnecessary restrictions.

The goal is to protect our architecture, not to make development more complicated.

Let's try it

Architecture Guardian is available on GitHub and distributed through NuGet.

You can start by installing the CLI:

dotnet tool install --global \
  ArchitectureGuardian.Cli \
  --version 0.3.0

Create your architecture.guardian.yaml policy, restore the solution, and run the scanner.

There is also a runnable Clean Architecture example in the documentation, containing both compliant and intentionally violating configurations.

You can experiment with different dependency violations and observe the diagnostics without modifying your own application.

If you want to integrate the checks into your IDE, the Roslyn analyzer package is also available.

And if you're experimenting with AI coding agents, you can explore the MCP integration.

The full setup instructions and examples are available here:

Documentation: https://kbourzayq.github.io/arch-guard/

GitHub: https://github.com/kbourzayq/arch-guard

Final Thoughts

To conclude, I think we sometimes spend a lot of effort designing software architectures without putting the same effort into preserving them.

We discuss dependency rules, module boundaries, separation of concerns, and all the principles that should help us build maintainable software.

But as the application grows, it's surprisingly easy to introduce dependencies that contradict those decisions.

And most of the time, the compiler won't complain.

My intention with Architecture Guardian is not to introduce another architectural framework or tell developers how they should design their applications.

It's to provide a way to define architectural decisions once and continuously verify them.

A last piece of advice: don't try to enforce every possible architectural rule from the first day, especially if you're working on a large existing application.

Start with the boundaries that matter most, understand your existing dependencies, and progressively introduce stricter validation.

Otherwise, the tool intended to protect your architecture might become an unnecessary obstacle for your team.

That's it!

Architecture Guardian is open source, and feedback is more than welcome.

I'm particularly interested in hearing how other teams manage architectural boundaries in large .NET solutions, modular monoliths, and AI-assisted development workflows.

Feel free to try it, challenge the approach, open an issue, or contribute.

See you soon!

References

DEV.to tags: #dotnet #csharp #architecture #opensource

來源:DEV Community · dev.to