Enforcing Software Architecture With Architecture Tests

06 June, 2024

Introduction:
Software architecture is a blueprint for how you should structure your system. You can follow this blueprint strictly, or you can allow yourself varying levels of freedom.

Consider this scenario: You're part of a team developing a new .NET application. You've meticulously selected your software architecture. It could be monolithic structure, modular monolith, microservices, or something else entirely. You've also chosen your database and all the necessary tools. The team is enthusiastic, the code is being written smoothly, and features are being deployed.

However, after several months (or even years), the situation may have changed.

The codebase has expanded, and new features have been incorporated. Perhaps your team has evolved, with new developers joining. Adding new features has become cumbersome, and bugs are surfacing frequently. Developers are writing code in their own styles, leading to spaghetti code and various code smells.

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 occurs when teams opt for quick solutions to meet deadlines, implement temporary fixes, or lack a clear understanding of the system's architecture.

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?

Do developers not value maintaining clean code?

In truth, the majority of developers do care about code quality. If you're reading this, chances are you do too. However, developers frequently face pressure to deliver features promptly. At times, the quickest route to achieving this goal involves taking shortcuts.

Furthermore, not all developers possess a comprehensive 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. They don't consider how the next developer will work on this project. We should write code for humans, not just for computers.

Architecture Testing:
Fortunately, 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.

Through architecture testing, you can implement a "shift left" strategy. This allows for the early detection and resolution of issues during the development phase, when they are simpler and more cost-effective to address.

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.

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.

Let's see how to write some architecture tests.

Writing Architecture Tests:
You write architecture tests the same as any unit test in your application. There's an excellent library for writing architecture tests that already implements the boilerplate code we need to start writing tests.

We're going to use the NetArchTest.Rules and FluentAssertions libraries for writing architecture tests.

First, you have to install the following NuGet packages:

                         Install-Package NetArchTest.Rules --version 1.3.2

                         Install-Package FluentAssertions --version 6.12.0
                    

And now you can use it to write rules in your test project.

The starting point for writing architecture tests is the static Types class, which you can use to load a set of types.

Once you have loaded your types you can further filter them to find a more specific set of types.

Some of the available filtering methods:

                           ResideInNamespace

                           AreClasses

                           AreInterfaces

                           HaveNameStartingWith

                           HaveNameEndingWith
                       

Finally, when you are satisfied with your selection, you can write the rule you want to enforce by calling Should or ShouldNot and applying the condition you want to check.

Here's an example checking that all classes in the model assembly are sealed:

[Fact]
public void Model_Should_BeSealed()
    {
        TestResult result = Types.InAssembly(ModelAssembly)
                .That()
                .AreClasses()
                .Should()
                .BeSealed()
                .GetResult();

         result.IsSuccessful.Should().BeTrue();
}

Architecture Testing: Clean Architecture:
We can 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()
    {
        TestResult result = Types.InAssembly(DomainAssembly)
            .Should()
            .NotHaveDependencyOn(ApplicationAssembly.GetName().Name)
            .GetResult();

        result.IsSuccessful.Should().BeTrue();
     }

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()
                        {
                            TestResult result =  Types.InAssembly(ApplicationAssembly)
                                .Should()
                                .NotHaveDependencyOn(InfrastructureAssembly.GetName().Name)
                                .GetResult();

                                result.IsSuccessful.Should().BeTrue();
                        }
                    

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

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.

Design rules are more specific than project references, and focus on the implementation details of your classes.

Here are some design rules that you can enforce:

                             Services must be internal

                             Entities and Value objects must be sealed

                             Controllers can't depend on repositories directly

                             Command (or query) handlers must follow a naming convention

                             The possibilities are endless, and it's up to you how many design rules you want to enforce.
                         
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()
                            {
                              TestResult result =   Types.InAssembly(DomainAssembly)
                                    .That()
                                    .ImplementInterface(typeof(IDomainEvent))
                                    .Or()
                                    .Inherit(typeof(DomainEvent))
                                    .Should()
                                    .BeSealed()
                                    .GetResult();

                                    result.IsSuccessful.Should().BeTrue();
                            }
                          

Here are some examples of details of architecture tests that we could use in your N-Layer Architecture:

   
    [Fact]
     public void Model_Should_BePublic()
     {
        TestResult result = Types.InAssembly(ModelAssembly)
                         .Should()
                         .BePublic()
                         .GetResult();

         result.IsSuccessful.Should().BeTrue();
    }

    [Fact]
    public void Dto_Endpoint_Should_NotBeStatic_And_HaveNameEndingWithDto()
    {
        TestResult result = Types.InAssembly(DtoAssembly)
                        .That()
                        .ResideInNamespaceContaining("SaddamHossainDotNetApp.Dto")
                        .And()
                        .AreClasses()
                        .And()
                        .AreNotStatic()
                        .Should()
                        .HaveNameEndingWith("Dto")
                        .GetResult();

        result.IsSuccessful.Should().BeTrue();
    }

    [Fact]
    public void Data_DatabaseContext_Should_BePublic_And_HaveNameEndingWithDbContext()
    {
        TestResult result = Types.InAssembly(SharedAssembly)
                        .That()
                        .ResideInNamespace("SaddamHossainDotNetApp.Data.Context")
                        .Should()
                        .BePublic()
                        .And()
                        .HaveNameEndingWith("DbContext")
                        .GetResult();

        result.IsSuccessful.Should().BeTrue();
    }

   [Fact]
   public void Repository_Interface_Should_HaveNameStartingWithI()
   {
       TestResult result = Types.InAssembly(RepositoryAssembly)
                      .That()
                      .ResideInNamespace("SaddamHossainDotNetApp.Repository.Contracts")
                      .And()
                      .AreInterfaces()
                      .Should()
                      .HaveNameStartingWith("I")
                      .GetResult();

       result.IsSuccessful.Should().BeTrue();
   }

   [Fact]
   public void Service_Interface_Should_HaveNameStartingWithI()
   {
       TestResult result = Types.InAssembly(ServiceAssembly)
                     .That()
                     .ResideInNamespace("SaddamHossainDotNetApp.Service.Contracts")
                     .And()
                     .AreInterfaces()
                     .Should()
                     .HaveNameStartingWith("I")
                     .GetResult();

       result.IsSuccessful.Should().BeTrue();
    }

   [Fact]
   public void Shared_Constants_Should_BeStatic_And_HaveNameEndingWithConstants()
   {
       TestResult result = Types.InAssembly(SharedAssembly)
                       .That()
                       .ResideInNamespace("SaddamHossainDotNetApp.Shared.Constants")
                       .Should()
                       .BeStatic()
                       .And()
                       .HaveNameEndingWith("Constants")
                       .GetResult();

       result.IsSuccessful.Should().BeTrue();
   }

  [Fact]
  public void Web_Controller_Should_NotBeStatic_And_HaveNameEndingWithController()
  {
      var result = Types.InAssembly(WebAssembly)
                  .That()
                  .ResideInNamespaceContaining("SaddamHossainDotNetApp.Web.Controllers")
                  .And()
                  .AreClasses()
                  .Should()
                  .NotBeStatic()
                  .And()
                  .HaveNameEndingWith("Controller")
                  .GetResult();

      result.IsSuccessful.Should().BeTrue();
  }

  [Fact]
  public void ModelLayer_ShouldNotHaveDependencyOn_WebLayer()
  {
     TestResult result = Types.InAssembly(ModelAssembly)
         .Should()
         .NotHaveDependencyOn(WebAssembly.GetName().Name)
         .GetResult();

     result.IsSuccessful.Should().BeTrue();
  }

  [Fact]
  public void DtoLayer_ShouldNotHaveDependencyOn_WebLayer()
  {
      TestResult result = Types.InAssembly(DtoAssembly)
          .Should()
          .NotHaveDependencyOn(WebAssembly.GetName().Name)
          .GetResult();

      result.IsSuccessful.Should().BeTrue();
  }


 [Fact]
 public void DataLayer_ShouldNotHaveDependencyOn_WebLayer()
 {
     TestResult result = Types.InAssembly(DataAssembly)
         .Should()
         .NotHaveDependencyOn(WebAssembly.GetName().Name)
         .GetResult();

     result.IsSuccessful.Should().BeTrue();
 }


 [Fact]
 public void RepositoryLayer_ShouldNotHaveDependencyOn_WebLayer()
 {
      TestResult result = Types.InAssembly(RepositoryAssembly)
          .Should()
          .NotHaveDependencyOn(WebAssembly.GetName().Name)
          .GetResult();

      result.IsSuccessful.Should().BeTrue();
 }
  

 [Fact]
 public void ServiceLayer_ShouldNotHaveDependencyOn_DataLayer()
 {
        TestResult result = Types.InAssembly(ServiceAssembly)
            .Should()
            .NotHaveDependencyOn(DataAssembly.GetName().Name)
            .GetResult();

        result.IsSuccessful.Should().BeTrue();
 }
	

  [Fact]
 public void SharedLayer_ShouldNotHaveDependencyOn_WebLayer()
 {
      TestResult result = Types.InAssembly(SharedAssembly)
          .Should()
          .NotHaveDependencyOn(WebAssembly.GetName().Name)
          .GetResult();

      result.IsSuccessful.Should().BeTrue();
 }

                    

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 tests are an easy way to enforce software architecture and design rules with automated tests.

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.

One of the best investments you can make as a software engineer is writing automated tests. You write the tests once, and use them to verify your system forever. Granted, you also have to maintain the tests over time as your system grows.

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.

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.

Thanks!


About the Blogs

As a dedicated .NET developer, I maintain a Patreon account where I share exclusive content related to .NET development. There, you will also gain access to the codebase of this blog post. By becoming a Patreon member, you will have the opportunity to explore and learn from my projects firsthand.

If you have found my contributions helpful in any way, I kindly ask you to consider becoming a Patreon supporter. Your support enables me to continue producing high-quality content, empowering developers like yourself to enhance their skills and stay up to date with the latest developments in the .NET ecosystem. Thank you for considering joining my Patreon community!


Recent Posts

Confidently Build Production-Ready CRUD Using N-Layer Architecture

25 February, 2024

Confidently Build Production-Ready CRUD Using Clean Architecture

25 February, 2024

An unhandled error has occurred. Reload 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please reload the page.