.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 |
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.
|