Shift Left With Architecture Testing in .NET

Shift Left With Architecture Testing in .NET

6 min read ·

Thank you to our sponsors who keep this newsletter free to the reader:

Access the largest network of public APIs on the planet with the Postman Public API Network and Verified Teams. Find public APIs faster and expand your API reach. Those who have verified have seen API traffic increase by as much as 100%! Try it out today.

Looking to build interactive Blazor apps with WebAssembly? Explore the latest features introduced in .NET 8, dive into the mechanics of Blazor WebAssembly, and create your first Blazor interactive WASM app. Get started here!

Picture this: You're part of a team building a shiny new .NET application. You've carefully chosen your software architecture. It could be microservices, a modular monolith, or something else entirely. You've decided which database you will use and all the other tools you need. Everyone's excited, the code is flowing, and features are getting shipped.

Fast forward a few months (or years), and things might look different.

The codebase has grown, and new features have been added. Maybe your team has even changed, with new developers coming on board. Adding new features becomes a pain, and bugs are popping up left and right.

And slowly but surely, the neat architecture you started with has turned into a big ball of mud. What went wrong? And more importantly, what can we do about it?

Today, I want to show you how architecture testing can prevent this problem.

Technical Debt

Technical debt is the consequence of prioritizing development speed over well-designed code. It happens when teams cut corners to meet deadlines, make quick fixes, or don't understand the architecture clearly.

Each shortcut or hack adds to the pile, making the code harder to understand, change, and maintain. But why do developers take these shortcuts in the first place?

Don't developers care about keeping the code clean?

Well, the truth is, most developers do care. If you're reading this newsletter, odds are you also care. But, developers are often under pressure to deliver features quickly. Sometimes, the quickest way to do that is to take a shortcut.

Plus, not everyone has a deep understanding of software architecture, or they might disagree on what the "right" architecture is. And let's be honest: some developers want to get their code working and move on to the next thing.

Architecture Testing

Luckily, there's a way to enforce software architecture on your project before things get out of hand. It's called architecture testing. These are automated tests that check whether your code follows the architectural rules you've set up.

With architecture testing, you can "shift left". This enables you to find and fix problems early in the development process when they're much easier and cheaper to deal with.

Think of it like a safety net for your software architecture and design rules. If someone accidentally breaks a rule, the test will catch it and alert you. Bonus points if you integrate architecture testing into your CI pipeline.

There are a few libraries you can use for architecture testing. I prefer working with the NetArchTest library, which I'll use for the examples.

You can check out this article to learn the fundamentals of architecture testing.

Let's see how to write some architecture tests.

Architecture Testing: Modular Monolith

You built an application using the modular monolith architecture. But how can you maintain the constraints between the modules?

  • Modules aren't allowed to reference each other
  • Modules can only call the public API of other modules

Here's an architecture test that enforces these module constraints. The Ticketing module is not allowed to reference the other modules directly. However, it can reference the public API of other modules (integration events in this example). The entry point is the Types class, which exposes a fluent API to build the rules you want to enforce. NetArchTest allows us to enforce the direction of dependencies between modules.

[Fact]
public void TicketingModule_ShouldNotHaveDependencyOn_AnyOtherModule()
{
    string[] otherModules = [
        UsersNamespace,
        EventsNamespace,
        AttendanceNamespace];

    string[] integrationEventsModules = [
        UsersIntegrationEventsNamespace,
        EventsIntegrationEventsNamespace,
        AttendanceIntegrationEventsNamespace];

    List<Assembly> ticketingAssemblies =
    [
        typeof(Order).Assembly,
        Modules.Ticketing.Application.AssemblyReference.Assembly,
        Modules.Ticketing.Presentation.AssemblyReference.Assembly,
        typeof(TicketingModule).Assembly
    ];

    Types.InAssemblies(ticketingAssemblies)
        .That()
        .DoNotHaveDependencyOnAny(integrationEventsModules)
        .Should()
        .NotHaveDependencyOnAny(otherModules)
        .GetResult()
        .ShouldBeSuccessful();
}

If you want to learn how to build robust and scalable systems using this architectural approach, check out Modular Monolith Architecture.

Architecture Testing: Clean Architecture

We can also write architecture tests for Clean Architecture. The inner layers aren't allowed to reference the outer layers. Instead, the inner layers define abstractions and the outer layers implement these abstractions.

For example, the Domain layer isn't allowed to reference the Application layer. Here's an architecture test enforcing this rule:

[Fact]
public void DomainLayer_ShouldNotHaveDependencyOn_ApplicationLayer()
{
    Types.InAssembly(DomainAssembly)
        .Should()
        .NotHaveDependencyOn(ApplicationAssembly.GetName().Name)
        .GetResult()
        .ShouldBeSuccessful();
}

It's also simple introduce a rule that the Application layer isn't allowed to reference the Infrastructure layer. The architecture test will fail whenever someone in the team breaks the dependency rule.

[Fact]
public void ApplicationLayer_ShouldNotHaveDependencyOn_InfrastructureLayer()
{
    Types.InAssembly(ApplicationAssembly)
        .Should()
        .NotHaveDependencyOn(InfrastructureAssembly.GetName().Name)
        .GetResult()
        .ShouldBeSuccessful();
}

We can introduce more architecture tests for the Infrastructure and Presentation layers, if needed.

Ready to learn more about building production-ready applications using this architectural approach? You should check out Pragmatic Clean Architecture.

Architecture Testing: Design Rules

Architecture testing is also useful for enforcing design rules in your code. If your team has coding standards everyone should follow, architecture testing can help you enforce them.

For example, we want to ensure that all domain events are sealed types. You can use the BeSealed method to enforce a design rule that types implementing IDomainEvent or DomainEvent should be sealed.

[Fact]
public void DomainEvents_Should_BeSealed()
{
    Types.InAssembly(DomainAssembly)
        .That()
        .ImplementInterface(typeof(IDomainEvent))
        .Or()
        .Inherit(typeof(DomainEvent))
        .Should()
        .BeSealed()
        .GetResult()
        .ShouldBeSuccessful();
}

An interesting design rule could be requiring all domain entities not to have a public constructor. Instead, you would create an Entity instance through a static factory method. This approach improves the encapsulation of your Entity.

Here's an architecture test enforcing this design rule:

[Fact]
public void Entities_ShouldOnlyHave_PrivateConstructors()
{
    IEnumerable<Type> entityTypes = Types.InAssembly(DomainAssembly)
        .That()
        .Inherit(typeof(Entity))
        .GetTypes();

    var failingTypes = new List<Type>();
    foreach (Type entityType in entityTypes)
    {
        ConstructorInfo[] constructors = entityType
            .GetConstructors(BindingFlags.Public | BindingFlags.Instance);

        if (constructors.Any())
        {
            failingTypes.Add(entityType);
        }
    }

    failingTypes.Should().BeEmpty();
}

Another thing you can do with architecture tests is enforce naming conventions in your code. Here's an example of requiring all command handlers to have a name ending with CommandHandler:

[Fact]
public void CommandHandler_ShouldHave_NameEndingWith_CommandHandler()
{
    Types.InAssembly(ApplicationAssembly)
        .That()
        .ImplementInterface(typeof(ICommandHandler<>))
        .Or()
        .ImplementInterface(typeof(ICommandHandler<,>))
        .Should()
        .HaveNameEndingWith("CommandHandler")
        .GetResult()
        .ShouldBeSuccessful();
}

Summary

Even the most well-planned software projects decay because of technical debt. Most developers have good intentions. However, time pressure, misunderstandings, and resistance to rules all contribute to this problem.

Architecture testing acts as a safeguard. It prevents your codebase from turning into a big ball of mud. By catching architectural violations early on, you can shift left. Short feedback loops avoid costly rework and improve developer productivity. It also ensures the long-term health of your project.

A few key takeaways:

  • Technical debt is inevitable: It slows down development, introduces bugs, and frustrates developers.
  • Architecture testing is your safety net: It helps you catch architectural violations before they become problematic.
  • Start small and iterate: You don't have to test everything at once. Focus on the most critical rules first.
  • Make it part of your workflow: Integrate architecture tests into your CI/CD pipeline so they run automatically.

Action point: Start by exploring popular .NET architecture testing libraries like ArchUnitNET or NetArchTest. Experiment with writing tests for common architectural rules and gradually integrate them into your development workflow.

That's all for today.

See you next week.


Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 2,900+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Patreon Community: Join a community of 1,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.
  4. Promote yourself to 53,000+ subscribers by sponsoring this newsletter.

Become a Better .NET Software Engineer

Join 53,000+ engineers who are improving their skills every Saturday morning.