Besides the functional requirements of a software product, which can be ensured through unit and integration tests, there are also a number of non-functional requirements. Aspects of security or the performance of an application can be covered by end-to-end tests. To ensure the maintainability and extensibility of software, in addition to static code analysis, automated architecture tests can be used. These tests can verify dependencies between components or the adherence to naming conventions.

Even when refactoring existing code, such architecture tests can prove to be meaningful.

With ArchUnitNET, architecture rules can be defined as code and tested automatically.

ArchUnitNET

ArchUnitNET is a .NET port of the well-established Java library ArchUnit. With ArchUnitNET, it is possible to define architecture rules as unit tests and continuously and automatically verify them. The rules are written in C# code and can be executed in any testing framework. To use ArchUnitNET, it is sufficient to add the corresponding NuGet package to your project. For easy integration with different test frameworks, packages are available for xUnit, NUnit, and MS-Test.

Architecture Rules

ArchUnitNET tests are structured similarly to other unit tests. There is a test class with a series of test methods. For better performance, one can create a static class property with the definition of the architecture.

To achieve this, you make the ArchLoader aware of a series of assemblies that contain the code related to your architecture. Marker classes, for example, can be used for this purpose. However, the ArchLoader also has a variety of other methods, such as loading assemblies from a directory.

private static readonly Assembly DataModuleAssembly = 
    typeof(DataModuleMarker).Assembly;
private static readonly Assembly BusinessModuleAssembly = 
    typeof(BusinessModuleMarker).Assembly;
private static readonly Assembly DesktopModuleAssembly = 
    typeof(DesktopModuleMarker).Assembly;

private static readonly Architecture Architecture = new ArchLoader()
    .LoadAssemblies(DataModuleAssembly, BusinessModuleAssembly, DesktopModuleAssembly)
    .Build();

Within the architecture, sets of classes, interfaces, or methods can be formed, for example, to group classes into layers. If you use a consistent naming scheme, you can also filter by name patterns and, for instance, aggregate all repository classes together.

The user-friendly Fluent API of ArchUnitNET helps defining these sets.

private readonly IObjectProvider<IType> DataLayer =
    Types().That().ResideInAssembly(DataModuleAssembly).As("Data layer");

private readonly IObjectProvider<IType> BusinessLayer =
    Types().That().ResideInAssembly(BusinessModuleAssembly).As("Business layer");

private readonly IObjectProvider<Class> RepositoryClasses =
    Classes().That().HaveNameEndingWith("Repository").As("Repository classes");

To test whether all repository classes are indeed located in the data layer, we write a test that defines a corresponding rule and checks our architecture against it. For ease of use, you can include ArchRuleDefinition as a static using.

using static ArchUnitNET.Fluent.ArchRuleDefinition;

[Fact]
public void RepositoryClassesShouldBeInDataLayer()
{
    var rule = Classes().That().Are(RepositoryClasses).Should().Be(DataLayer);
    rule.Check(Architecture);
}

Checking for intentional or unintentional dependencies between modules also requires only a few lines of code. The following test verifies whether any type from the desktop layer accesses a type from the data layer.

[Fact]
public void DesktopLayerShouldNotReferenceDataLayer()
{
    var rule = Types().That().Are(DesktopLayer).Should().NotDependOnAny(DataLayer);
    rule.Check(Architecture);
}

If ArchUnitNET detects a violation of our rules, the test fails and provides an appropriate error message.

FailedArchRuleException
"Types that are Desktop layer should not depend on Data layer" failed:
    DesktopModule.ViewModels.ProductListViewModel does depend on
    DataModule.ProductRepository

Preventing Code Usage

Not only can the organization of source code be enforced in this way, but the usage of specific classes or methods can also be regulated. To ensure that only the Data Layer has access to a database, we restrict access to classes from the Microsoft.Data.SqlClient assembly, which includes the SqlConnection class, among others. Access to the database classes is only allowed from our own assemblies (ProjectAssemblies), specifically from within the DataLayer. To make ArchUnitNET aware of the Microsoft.Data.SqlClient assembly, we need to explicitly include it in our architecture definition.

private static readonly Assembly[] ProjectAssemblies = {
    DataModuleAssembly,
    BusinessModuleAssembly,
    DesktopModuleAssembly
};

private static readonly Assembly SystemDataAssembly =
    typeof(SqlConnection).Assembly;

private static readonly Architecture Architecture = new ArchLoader()
    .LoadAssemblies(/* ... */ SystemDataAssembly)
    .Build();

[Fact]
public void OnlyDataLayerShouldUseDatabase()
{
    var types = Types()
        .That().ResideInAssembly(ProjectAssemblies[0], ProjectAssemblies[1..])
        .And()
        .AreNot(DataLayer);
    var typesNotToUse = Types().That().ResideInAssembly(SystemDataAssembly);

    var rule = types.Should().NotDependOnAny(typesNotToUse);
    rule.Check(Architecture);
}

Architecture Refactoring

When dealing with an existing codebase and intending to make changes to it, ArchUnitNET allows you to define the desired target state before implementing the corresponding changes. Subsequently, you have the option to gradually transform the existing architecture in the desired direction. At the beginning of the refactoring process, the tests that are still red indicate the areas that need attention. Rules that haven’t been implemented yet can be skipped for the time being. Once all tests are reactivated and green, the refactoring is complete.

[Fact(Skip = "Refactoring of DesktopLayer will be done in the next iteration")]
public void DesktopLayerShouldNotReferenceDataLayer()
{
    var rule = Types().That().Are(DesktopLayer).Should().NotDependOnAny(DataLayer);
    rule.Check(Architecture);
}

Finding System Resources

The use of non-deterministic system resources such as the current time can make writing tests challenging. If you don’t want to refactor directly towards Pure Functions, it can be helpful to use an abstraction like the TimeProvider introduced since .NET 8.

Here, too, a test can help us find all accesses to DateTime.Now or DateTime.UtcNow.

[Fact]
public void DateTimeNowShouldNotBeUsedInBusinessLayer()
{
    var types = Types().That().Are(BusinessLayer);

    var methodsNotToCall = MethodMembers()
        .That().AreDeclaredIn(typeof(DateTime))
        .And().AreStatic()
        .And().HaveNameEndingWith("get_UtcNow()")
        .Or().HaveNameEndingWith("get_Now()");

    var rule = types.Should().NotCallAny(methodsNotToCall);
    rule.Check(Architecture);
}

A failing test will once again indicate to us where we need to make adjustments in this case.

"Types that are Business layer should not call Method members that are declared
in "System.DateTime" and are static and have name ending with "get_UtcNow()"" failed:
    BusinessModule.ProductService does call System.DateTime
    System.DateTime::get_UtcNow()

Kudos to Andreas Lausen for his sample code from SEACON 2023.

Conclusion

ArchUnitNET provides a powerful way to define architecture and code usage rules in the form of unit tests and to automatically and continuously verify them. After a short learning curve, writing new rules becomes straightforward. Since these architecture rules behave like any other unit test, they can also be integrated into existing codebases. The best practice is, of course, to define and use them as early as possible.

The example code for this post can be found on GitHub: https://github.com/davull/demo-archunit