.NET Modulith

The backend’s main node is the ASP.NET Core Restful Web API, that is built based on the Modular Monolith Architecture Pattern.

Centralized Build Props

Whilst there’s certainly patterns that involve having a shared project that holds references to both NuGets and General Use classes for a whole solution. We can also leverage another type of pattern. And this involves making use of a Directory.build.props file. Files such as this one can abstract away centralized configurations both for build processes (separating concerns such as static analyzer warnings and errors), and other cross cutting concerns such as NuGet package versioning.

<Project>
    <PropertyGroup>
        <!--Default Project Setttings--> (1)
        <TargetFramework>net9.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>

        <!--Static Analyzers Settings-->
        <AnalysisLevel>latest</AnalysisLevel> (2)
        <AnalysisMode>All</AnalysisMode>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
        <CodeAnalysisTreatWarningsAsErrors>true</CodeAnalysisTreatWarningsAsErrors>
        <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
    </PropertyGroup>
</Project>
1 When building a project, all these properties wil be applied to all projects of the solution.
2 And also, when running a build on a project, we will have all these settings for static analyzers warnings to be painted as errors.

Centralized NuGet packages

Following the same idea at Centralized Build Props we can also use a file called Directory.Packages.props. It is at this file that we can then declare all sorts of NuGet packages that we want to be centralized, this brings us the benefit of having one version for all projects in one file, instead of having to go into each project and managing versions on their own. Now this should be definitely used with foresight, since there are use cases the definitely will not benefit from this at all.

<Project>
    <PropertyGroup>
        <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> (1)
    </PropertyGroup>
    <ItemGroup>
        <PackageVersion Include="SonarAnalyzer.CSharp" Version="10.6.0.109712"/> (2)
        <PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556"/>
    </ItemGroup>
</Project>
1 We have to set this setting so that all projects of the solution will refer to the package versions declared in this props file.
2 And the way to add the packages that will be exposed to projects is pretty straightforward, we will simply reference them as we would in a normal .csproj file

And now, if we want to reference the packages and have them available, we can simply declare them in any project file as such:

<Project Sdk="Microsoft.NET.Sdk.Web">

    <ItemGroup>
        <PackageReference Include="SonarAnalyzer.CSharp"/> (1)
        <PackageReference Include="StyleCop.Analyzers"/>

        <PackageReference Include="Microsoft.AspNetCore.OpenApi" VersionOverride="9.0.0"/> (2)
    </ItemGroup>

    <ItemGroup>
      <Content Include="..\..\.dockerignore">
        <Link>.dockerignore</Link>
      </Content>
    </ItemGroup>

</Project>
1 We have to simply reference centralized packages under a PackageReference tag, without versions.
2 If we want to install specific packages for the project, we can still do it by putting explicitly the name and version of it. And this way we connect projects with a centralized package versioning file and we get rid of the hassle of having to go through each project to update things whilst maintaining specific packages for specific projects.

It’s worth noting that by the NuGet team’s recommendation it’s recommended to have everything under the Directory.Packages.props file, but this defeats a bit of the purpose of centralizing without coupling dependencies. I might get back on this, but when it comes to importing things unnecesarily we should be safe. By actually referencing a package in the .csproj file it will then attempt to get a reference to the NuGet and we will have that dependency in our final bundle.

All projects will not have the NuGet package taking up space. It’s by declaring the reference (without the version) that the connection for the package procurement will then take place.

It’s on my own when testing the docker build process that I found two issues, NU1008 and even NU1604. Even though the IDEs might be smart enough to pick up on the Directory.* files and then reconcile that info with each project’s declaration of a specific package declaration, the tool itself forces us to not have floating packages. Unless we are really explicit, and thanks to the docs it is that I found that we can declare an override so that a specific project can declare its own package version there and IMHO abstract clutter from specific project use cases to them and not having it in our centralized package declaration file.

Global Usings

A feature that came all the way from .NET 8, is the usage of GlobalUsings, these were already present from some time now in the form of auto-generated files. However, it’s on this .NET version that it was introduced a pattern to keep files cleaner by abstracting commonly imported dependencies through a GlobalUsings.cs file.

So any type of import that we will be using across an application is a great candidate to add as a global using <namespace>.

There’s an issue (at least at the time of writing) with StyleCop.Analyzers, and that’s rule SA1200 on these global usings files. Info. Due to this still not working as intended, we will Turn off the rule for this file ONLY

Libraries

Since we will require some level of dependencies in order to connect to the Web API, plus our own technical concerns we will make sure to install only the neccesary Nuget packages as to keep our module lean and lightweight. This would be things such as:

  • Microsoft.Extensions.Configuration

  • Microsoft.Extensions.DependencyInjection

  • Microsoft.AspNetCore.Routing

  • Microsoft.AspNetCore.Http.Abstractions

HOWEVER: By enabling the ASP.NET Core abstractions in the project through the addition of a specific section in its .csproj:

<ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

Our class libraries will have access to all the types, APIs, and dependencies provided by the ASP.NET Core framework. Without the need of installing libraries individually.

Instead of copying referenced assemblies into the project’s output directory, it relies on the shared framework that is installed on the target machine or included in the runtime. Reducing the size of the output. (It also helps with consistent versioning).

BIG NOTE: We are taking this decision since our class libraries are specifically designted to work with ASP.NET Core. And since we are abstracting away endpoint registration, we need access to the framework’s specific types and features. But most importantly, we want to avoid bloating our projects with redundant copies of assemblies.

Module Architecture

All modules should follow a specific structure, by adhering to this structure we are making sure that we keep our code organized, predictable and more maintainable in the long run. Whilst our approach might delve a bit into too much abstraction. We have to emphasize the idea that the initial setup (and learning curve) might be the biggest hurdle, but once you are comfortable with the project, then things should fall into place without much issue.

Just know that everything starts from the Web API’s Program.cs side. We make two important calls there:

builder.Services.InstallModulesFromAssemblies( (1)
    builder.Configuration,
    Modules.Authentication.AssemblyReference.Assembly);

app.InstallEndpointsFromAssemblies( (2)
    app.Configuration,
    Modules.Authentication.AssemblyReference.Assembly);
1 We have different services, and things specific to a module that we have to "install" on our app. And it’s through this extension method that we can feed all the configurations to be loaded into the app, plus sending assembly references that will help us scan the whole project to then start registering the different components.
2 And just like the services use case, we also have to scan for different endpoints (since we will be using Minimal APIs), and this takes place after the app container has been built and we now deal with the HTTP pipeline.

With that in mind, each module should always have its own IModuleInstaller. If we don’t have this, then none of the methods we laid out before will pick up on the respective interface to then kickstart further registrations.

using Common.Library;
using Common.Library.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Modules.Authentication;

public class AuthenticationModuleInstaller : IModuleInstaller
{
    public void InstallModule(IServiceCollection serviceCollection, IConfiguration configuration) (1)
    {
        serviceCollection.InstallServicesFromAssemblies(configuration, AssemblyReference.Assembly); (2)
    }
}
1 The interface will have an InstallModule method to which we feed both the IServiceCollection and the IConfiguration objects coming straight from the app. And this method ideally should be used to make different additions of different types of services and extras to the application.
2 It’s from this specific place that we can then call another extension method called InstallServicesFromAssemblies. This method follows the same idea as the module approach, we can feed an (n) number of assembly targets to it so that it scans them and then runs the respective method specific to a IServiceInstaller.

One such IServiceInstaller would look like this:

using Common.Library.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Modules.Authentication.GoogleOAuth;

namespace Modules.Authentication.ServiceInstallers;

public class RepositoryServicesInstaller : IServiceInstaller
{
    public void Install(IServiceCollection app, IConfiguration configuration) (1)
    {
        app.AddScoped<IGoogleOAuthRepository, GoogleOAuthRepository>(); (2)
    }
}
1 Under an Install method we should have all the specific code that will start saving into the DI container of the app all the services that we want to register from the module (and that are specific to our sub-domain).
2 For example, we could have a RepositoryServicesInstaller in charge of only registering all the repositories that live in the module. And it will only take care of registering that.

In theory we could be creating tons of IServiceInstaller implementations each specialized on an specific type of service/logging/DI registration onto the app’s DI container.

And so the way the flow of module installation should follow:

app => IModuleInstaller => IServiceInstaller

app => IEndpointDefinition

Assembly Scanning is what we are leveraging a ton to make code more concise, and less boilerplaity. The key lies at registering the classes under the right interface. Once you do that, then everything should fall into place, since all the abstracted general purpose code is already there. And now integrating and extending should be a really mechanical process.

Endpoint versioning

We will leverage API Versioning straight out of the Asp.Versioning.Http and Asp.Versioning.Mvc.ApiExplorer Nugets. The integration with it is quite simple, in the sense that for the time being we can just declare API versioning and work as if we didn’t mind about it, however, if the need to start using versioning arises, we can immediately start leveraging the extension methods as we require.

builder.Services
    .AddApiVersioning(options =>
    {
        options.DefaultApiVersion = new ApiVersion(1); (1)
        options.ApiVersionReader = new UrlSegmentApiVersionReader(); (2)
    })
    .AddApiExplorer(options =>
    {
        options.GroupNameFormat = "'v'V"; (3)
        options.SubstituteApiVersionInUrl = true; (4)
    });
1 We will start with a default v1 for our application.
2 Out of the different versioning schemes that exist, we will use the url segment approach, meaning that prefixing each endpoint we will have its version stated /v1/, /v2/.
3 We will then specify how the template for the version will be constructed, in our case is a simple v character and then the number of the version right after V.
4 We will also enable the service to be able to replace the endpoint template with the respective version when registering endpoints.

And due to all the legwork we did with assembly scanning both for services and endpoints, applying versioning to any other endpoint became seamless since we can leverage the same interface in order to swap out the implementation with a pre-configured one:

ApiVersionSet apiVersionSet = app
    .NewApiVersionSet()
    .HasApiVersion(new ApiVersion(1)) (1)
    .ReportApiVersions() (2)
    .Build();
RouteGroupBuilder groupBuilder = app
    .MapGroup("api/v{apiVersion:apiVersion}")
    .WithApiVersionSet(apiVersionSet); (3)

// Add Modules Routes
groupBuilder.InstallEndpointsFromAssemblies( (4)
    app.Configuration,
    Modules.Authentication.AssemblyReference.Assembly);
1 We will only declare a v1 for now, this can easily be extended later through the fluent pattern.
2 We will configure our endpoints to have the ability to report back to the clients through headers and error messages (in case an invalid version is queried), so that the client is aware of deprecations and other available versions.
3 We are building a RouteGroupBuilder with a prefix, plus the route template for versioning pre-configured.
4 And because we setup our assembly scanning in such an extendable and seamless manner, we can simply swap out the previous app instance with the groupBuilder instance and now all the endpoints that end up registered will be prefixed with api/v<number>. And it’s that easy.

One thing to take into consideration is that if you introduce API versioning and you have integration tests or further set-up that’s dependent on the URL of the endpoint, those will break, and you will have to manually fix them.