.NET 8 Series Starting Soon! Join the Waitlist

18 min read

Modular Architecture in ASP.NET Core - Building Better Monoliths

#dotnet

In this article, we will discuss Modularizing Web Applications using Modular Architecture in ASP.NET Core. We will go through Monolith Architecture’s various cons and pros and work on how to build monolith applications in a better way. Let’s call it Modular Monolith Architecture. Towards the latter section of the article, we will also build a sample solution that follows Modular Architecture. I have also attached the repository link to the ongoing real-life implementation of Modular Architecture in an Open Source Point of Sales System later in this article. Let’s get started.

You can find the complete source code of this implementation here.

Background

Having quite a lot of experience with POS & Inventory Management systems, we set out to build a full-fledged open-source system using our favorite tech stack and tools. Modular development was a prime requirement for us when we got started. Adapting to a Microservice architecture was the first choice we had. But given the complexities of the mentioned architecture, we decided to stay away from it at least for the starting. We named our product fluentpos.

There actually was no real need to implement microservices. fluentpos was meant to help businesses in their day-to-day activities. For this, a well-designed monolith application would also do the trick. We were clear to have the API and UI separated, to give opportunities to multiple client apps in the future.

The WebAPI application had to be highly modular to improve the development experience. This needed breaking down the application to logical modules like Identity, Catalog, Sales, Inventory. Each of these modules has its own controllers/interfaces / dbContext. As for the DB providers, Postgres / MSSQL will be used. One module cannot directly talk to the other module nor modify its table. CrossCutting concerns would use interfaces/events. And yes, domain events are also included in the project using mediator Handler. Each of the modules follows a clean architecture design / Onion / Hex.

Essentially we would end up with a Solution that contains MultipleModules where-in each of the modules implements a variation of Clean / Onion Architecture.

This was the reason that I got started writing articles about this architecture. Let’s go further.

Monolith Architecture

Monolith Architecture is probably used for 80% of the applications in existence. There is a high chance that you are already using this in your ongoing projects. This is a very straightforward approach to architecting applications where there is exactly one point of entry into the application. This means that a single deployment is often enough to get things into production. Imagine the application as a single block of code, internally split into multiple concerns (Business, Data Access, Infrastructure).

Below is an illustration of a simple monolith architecture.

modular-architecture-in-aspnet-core

You can call it 3-Layer Architecture or N-Layer Architecture, it’s all the same. Ultimately this is a Monolith. It’s a single application code block without dependencies on other applications (exes).

Reasons to Avoid Microservices - For Now.

Microservices are definitely the best architecture you can implement for large-scale applications. But the trend we see nowadays is that even mid-scale applications tend to use it. Is it really required? Most of the time, the answer is NO.

Read about Microservice Architecture in ASP.NET Core with API Gateway here.

Although it’s a scalable architecture, it comes with a lot of cons. There is actually no ideal solution that exists without a con. It’s all about the requirement and how comfortable you feel with the implementation. Message buses, Consumers, Publishers, Multiple deployments, are a few of the complexities that arise with Microservices.

The Need to Build Better Monoliths

Transitioning to Microservices is a real painful process given that you educate the entire team of all the required basics at the practical level. Sticking to a well-built monolith is a viable solution for most of the products and code bases. When your product’s user base explodes, that is when you would actually need to transition to a Microservices Approach. But until then, design your application in such a way that it mimics Microservices yet keeps the simplicity and goodness of Monoliths. Makes sense, yeah?

What is Modular Monolithic Architecture?

Modular Monolith Architecture is a software design in which a monolith is made better and modular with importance to re-use components/modules. It’s often quite easy to transition from a Monolith Architecture to Modular Monolith.

Here is an illustration:

modular-architecture-in-aspnet-core

The main idea here is to build a better Monolith Solution.

  • API / Host - A very thin Rest API / Host Application that is responsible for registering the controllers/services of other modules into the service container.
  • Modules - A logical block of the business unit. For example, Sales. Everything that is related to Sales can be found here. We will walk through the definition of a module in the next section.
  • Shared Infrastructure - Application-Specific Interfaces and implementations are found here for other modules to consume. This includes Middlewares, Data Access providers, and so on.
  • Finally a Database. Note that you have the flexibility to use multiple databases, i.e one database per module.But it ultimately depends on how you would want to design your application.

You can see that there is not much deviation from a standard Monolith implementation. The basic recipe is to Split your Application into multiple smaller applications/modules and make them follow clean architecture principles.

Definition of a Module

  • A module is a logical unit of the business requirement. In a Point of Sales application, sales, customers, and Inventory are a few examples of Modules.
  • Each module will have a DBContext and can access only the assigned table/entities.
  • One module should never depend on any other module. It can depend on Abstraction Interfaces that are present in Shared Application Projects.
  • Each module has to follow a domain-driven architecture
  • Every module will be further split into API, Core, and Infrastructure projects to enforce Clean Onion Architecture.
  • Cross Module communication can happen only via Interfaces/events/in-memory bus. Cross Module DB Writes should be kept minimal or avoided completely.

To get a better understanding, let’s see an actual module from the fluentpos project and examine its responsibility.

modular-architecture-in-aspnet-core

  • Modules.Catalog - Contains the API Controllers needed for the module.
  • Modules.Catalog.Core - Contains Entities, Abstractions, CQRS handlers, and everything needed for the module to function independently.
  • Modules.Catalog.Infrastructure - Consists of DbContexts and Migrations. This project depends on the Core for abstractions.

You will get more idea on this when to start to build an application later in this article.

Benefits of Modular Architecture in ASP.NET Core

  1. Clear Separation of Concerns
  2. Easily Scalable
  3. Lower complexity compared to Microservices
  4. Low operational / deployment costs.
  5. Reusability
  6. Organized Dependencies

Cons of Modular Architecture when compared to Microservices

  • Not Multi-technology compatible.
  • Horizontal Scaling can be a concern. But this can be managed via load balancers.
  • Since Interprocess Communication is used, messages may be lost during Application Termination. Microservices combat this issue by using external messaging brokers like Kafka, RabbitMQ. (You can still use message brokers in Monoliths, but let’s keep things simple)

Examining fluentpos Project Structure

modular-architecture-in-aspnet-core

  • API
    • Bootstrapper / Host
  • Modules
    • Catalog
      • Controllers
      • Core
        • Entities
        • Interfaces
        • Exceptions
        • CQRS Handlers - Commands & Queries
      • Infrastructure
        • Context
        • Migrations
    • Other Modules
  • Shared
    • Core
      • Interfaces
    • DTOs
    • Infrastructure
      • Middlewares
      • Persistence Registrations

Building Modular Architecture In ASP.NET Core

What we will build

We will be building a simple application that demonstrates the implementation of Modular Architecture in ASP.NET Core. We will not be building a full-blown application in this article, as it may require lots of explanation. I plan to build a framework / Nuget package later to help you generate Modular Solutions for your upcoming project (Any thoughts? Leave your comments below!). But for now, let’s build a very basic implementation. Here is what you can expect.

  • Controller registration from other Class libraries
  • CQRS using MediatR
  • MSSQL
  • Migrations
  • Catalog Module
  • Customer Module
  • Shared DTOs

Architectural Assumptions

To keep things simple, we will assume that Entity Framework Core will be our default DB Abstraction provider and will go strong for another 10+ years. In this way, we can avoid the Repository pattern that usually tends to make our codebase larger.

Getting Started

Let’s start by creating a new Blank Solution in Visual Studio. PS, I will be using Visual Studio 2019 Community for this demonstration.

modular-architecture-in-aspnet-core

Project Structure

Within the newly created solution, let’s create a new folder Host and add in an ASP.NET Core 5.0 WebAPI Application. Remove all the boilerplate code that comes along with the WebAPI.

modular-architecture-in-aspnet-core

With that done, let me add a few other C# library projects. You can follow the similar folder structure as demonstrated in the screenshot below.

modular-architecture-in-aspnet-core

As mentioned,

  • API will hold all the service / controller registration logics and nothing else.
  • Module.Catalog & Module.People will contain the API controllers only,which will be picked up by the API Project.
  • Module.Catalog.Core & Module.People.Core will contain the Entity models, interfaces specific to the module, Mediatr Handlers and so on.
  • Module.Catalog.Infrastructure & Module.People.Infrastructure will mainly hold the module specific DBContext,Migrations, SeedData and Service implementation if any.
  • Shared.Core will have MediatR Behaviors, Common Service Implementations / Interfaces and basically everything that has to be shared across the application.
  • Shared.Models is where you will have to add in the Request /Response classes.Note that this project can be used for any C# Client applications as well.
  • Finally, Shared.Infrastructure is where you would want your middlewares, utilities and specify which Database Provider to use for the entire application.

With the structure ready, let’s add in the required extensions and controllers.

Controller Registrations

The first challenge is if you place the controllers in Module.Catalog & Module.People projects, how will the API project recognize it and add the routing required? Thus we need a way to make the API project use controllers that are in separate projects but uses the standard naming conventions of API Controllers.

Before all that, you would want to add the following to the Shared.Infrastructure project file to ensure that we have access to the AspNetCore Framework references and classes.

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

modular-architecture-in-aspnet-core

Next, create a Controllers folder under Shared.Infrastructure and add a new class InternalControllerFeatureProvider.

internal class InternalControllerFeatureProvider : ControllerFeatureProvider
{
protected override bool IsController(TypeInfo typeInfo)
{
if (!typeInfo.IsClass)
{
return false;
}
if (typeInfo.IsAbstract)
{
return false;
}
if (typeInfo.ContainsGenericParameters)
{
return false;
}
if (typeInfo.IsDefined(typeof(NonControllerAttribute)))
{
return false;
}
return typeInfo.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) ||
typeInfo.IsDefined(typeof(ControllerAttribute));
}
}

Now, this class will be responsible for adding controllers that are in different projects. We will have to register this class into the service container of our host ASP.NET Core application.

Create a new folder under Shared.Infrastructure, Extensions and add a new class ServiceCollectionExtensions.cs.

public static class ServiceCollectionExtensions
{
public static IServiceCollection AddSharedInfrastructure(this IServiceCollection services, IConfiguration config)
{
services.AddControllers()
.ConfigureApplicationPartManager(manager =>
{
manager.FeatureProviders.Add(new InternalControllerFeatureProvider());
});
return services;
}
}

Now, let’s navigate to the API project / Startup / ConfigureServices method and add the following.

public void ConfigureServices(IServiceCollection services)
{
services.AddSharedInfrastructure(Configuration);
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "API", Version = "v1" });
});
}

Make sure that the API Project has reference to the Shared Infrastructure, Module.Catalog, Module.People projects too. (Important)

modular-architecture-in-aspnet-core

Let’s add a controller to our Modules.Catalog. Create a new folder Controllers under the Modules.Catalog project and add in a new Controller, BrandsController. We are just adding this controller to ensure that the API project is able to detect the controller in the Module project.

[ApiController]
[Route("/api/catalog/[controller]")]
internal class BrandsController : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAllAsync()
{
return Ok();
}
}

IMPORTANT - You might be seeing a lot of unresolved dependencies now. It’s important to add the following project references for each module going forward.

  • Module.Catalog.Core should have a referece to Shared.Core
  • Module.Catalog.Infrastructure should have a referece to Shared.Infrastructure & Module.Catalog.Core
  • Module.Catalog should have a referece to Module.Catalog.Core and Module.Catalog.Infrastructure
  • Shared.Infrastructure should have a reference to Shared.Core
  • Shared.Core should depend on Shared.Models

Make sure to add similar dependencies for the People Module as well.

PS, these are few crucial dependencies stated by Clean Architecture principles (Onion). So make sure that you get them all right.

modular-architecture-in-aspnet-core

With that done, let’s run the project and check if the BrandController comes up in Swagger.

modular-architecture-in-aspnet-core

So, that’s done. Now let’s connect the application to a database, in a modular way.

Persistence

As mentioned earlier, we will be using Entity Framework Core as the DB Abstraction in this project.

Let’s get started by adding the Brand Model Entity. Open up the Modules.Catalog.Core and add a new folder, Entities. Here create a new class and name it Brand.

public class Brand
{
public int Id { get; set; }
public string Name { get; set; }
public string Detail { get; set; }
}

Since we decided to add separate DBContexts for each module, it makes sense to add in a common DBContext first, then inherit it as the base class, yeah?

First, Navigate to Shared.Infrastructure Project and add a new Folder, Persistence. Here let’s add a new class named ModuleDbContext. Remember, this will be the base of all the DBContext classes that you will be creating moving forward in each and every module.

public abstract class ModuleDbContext : DbContext
{
protected abstract string Schema { get; }
protected ModuleDbContext(DbContextOptions options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
if (!string.IsNullOrWhiteSpace(Schema))
{
modelBuilder.HasDefaultSchema(Schema);
}
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly);
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return (await base.SaveChangesAsync(true, cancellationToken));
}
}

It’s important to note that we are using Schemas to make a logical separation between the database tables as well. For example, tables associated with Catalog module will be named Catalog.Brand , Catalog.Products and so on. You get the idea, yeah?

Next, let’s add the DBContext specific to this Module. Remember, no other modules can have access to the Brand Table other than the Catalog Module. This is made sure by creating separate DbContexts for each Module.

Navigate to Module.Catalog.Core and create a new folder Abstractions. Here is where you will have to place the interfaces to achieve Dependency Inversion. This is the whole essence of Onion Architecture, yeah? In this folder, let’s add a new interface and name it ICatalogDbContext.

public interface ICatalogDbContext
{
public DbSet<Brand> Brands { get; set; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}

Next, Navigate to Modules.Catalog.Infrastructure and add a new folder, Persistence. Here, add in a new class, CatalogDbContext which inherits from the catalog DB context interface and the module Dbcontext base class.

Note that anything related to the data access of the Catalog module will have to be put here.

public class CatalogDbContext : ModuleDbContext, ICatalogDbContext
{
protected override string Schema => "Catalog";
public CatalogDbContext(DbContextOptions<CatalogDbContext> options): base(options)
{
}
public DbSet<Brand> Brands { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
}
}

Note that we specify the schema name as Catalog here. Also, make sure to inherit from our common ModuleDbContext and the interface specific to our current module.

Let’s install the required packages now.

For Shared.Infrastructure project, install the following packages.

Install-Package Microsoft.EntityFrameworkCore
Install-Package Microsoft.EntityFrameworkCore.Relational
Install-Package Microsoft.EntityFrameworkCore.SqlServer
Install-Package Microsoft.EntityFrameworkCore.Tools
Install-Package MediatR

For the API Project, install the following.

Install-Package Microsoft.EntityFrameworkCore.Design

Now comes the interesting part of adding Database Provider extensions. We know that we will be using MSSQL as the DB provider in this implementation. But let’s build a somewhat flexible system, where it can be switched to PostgreSQL or other providers easily. Ideally, this solution should be present in a common project for other modules to use easily. That’s right, navigate to Shared.Infrastructure project and open up Extensions/ServiceCollectionExtensions.cs file. Here, add in the following Extension methods.

public static IServiceCollection AddDatabaseContext<T>(this IServiceCollection services, IConfiguration config) where T : DbContext
{
var connectionString = config.GetConnectionString("Default");
services.AddMSSQL<T>(connectionString);
return services;
}
private static IServiceCollection AddMSSQL<T>(this IServiceCollection services, string connectionString) where T : DbContext
{
services.AddDbContext<T>(m => m.UseSqlServer(connectionString, e => e.MigrationsAssembly(typeof(T).Assembly.FullName)));
using var scope = services.BuildServiceProvider().CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<T>();
dbContext.Database.Migrate();
return services;
}
  • Line 3 - Get the connection string defined in the appsettings.json of the API project. Note that we will be adding the connection string in the next section.
  • Line 4 - Calls the extension method specific for MSSQL. You could write a new extension for other DB Providers as well. You get the idea, yeah?
  • Line 9 - Adds the passed DbContext to the service container using the MSSQL package of EFCore. Make sure that you have already installed it.
  • Line 12 - Updates the database using the latest available Migrations.

Open up the appsettings.json of the API Project and add in the following.

"ConnectionStrings": {
"Default": "Data Source=(localdb)\\mssqllocaldb;Initial Catalog=monolithSample;Integrated Security=True;MultipleActiveResultSets=True"
}

Next, we need to make sure that each of the modules uses these extensions. Navigate to Module.Catalog.Infrastructure and add a new Folder, Extensions. Here add a new static class, ServiceCollectionExtensions

public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCatalogInfrastructure(this IServiceCollection services, IConfiguration config)
{
services
.AddDatabaseContext<CatalogDbContext>(config)
.AddScoped<ICatalogDbContext>(provider => provider.GetService<CatalogDbContext>());
return services;
}
}

Next, in the Module.Catalog.Core project, add an Extensions folder and add ServiceCollectionExtensions.cs. We will need this while implementing the MediatR handlers.

public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCatalogCore(this IServiceCollection services)
{
services.AddMediatR(Assembly.GetExecutingAssembly());
return services;
}
}

Next, we need an extension for each module, that can be read by the API Project for registering the required services. Navigate to Module.Catalog and add a new class, ModuleExtensions. Here we will be adding the other extensions like AddCatalogCore and AddCatalogInfrastructure.

public static class ModuleExtensions
{
public static IServiceCollection AddCatalogModule(this IServiceCollection services, IConfiguration configuration)
{
services
.AddCatalogCore()
.AddCatalogInfrastructure(configuration);
return services;
}
}

Finally, go to the API Project / Startup / ConfigureServices method and add in the following. Make sure that you have added the reference to Module.Catalog as well.

services.AddCatalogModule(Configuration);

That’s everything you need to do to set up Database Access in a Modular fashion. As the last step, let’s add the required migrations and check if the tables are created as expected.

On Visual Studio, Right-click the Module.Catalog.Infrastructure and click on ‘Open in Terminal’. Run the following command.

dotnet ef migrations add "initial" --startup-project ../API -o Persistence/Migrations/ --context CatalogDbContext

modular-architecture-in-aspnet-core

This would create the Migrations in the following folder.

modular-architecture-in-aspnet-core

As per our code, as soon as the application runs, the required tables will be created. Let’s test.

modular-architecture-in-aspnet-core

There you go, the tables are created exactly as we wanted.

Adding MediatR Handlers and Controllers

As the final part of this implementation, we will be adding the required MediatR handlers and API controllers. To keep the article minimal, we will just be adding the ‘GetAll’ and ‘Register’ endpoints for the Brands Entity.

Let’s start by adding 2 new folders under the Module.Catalog.Core project and name it Queries & Commands respectively.

Under the Queries folder, add in a new class and name it GetAllBrandsQuery.

namespace Module.Catalog.Core.Queries.GetAll
{
public class GetAllBrandsQuery : IRequest<IEnumerable<Brand>>
{
}
internal class BrandQueryHandler : IRequestHandler<GetAllBrandsQuery, IEnumerable<Brand>>
{
private readonly ICatalogDbContext _context;
public BrandQueryHandler(ICatalogDbContext context)
{
_context = context;
}
public async Task<IEnumerable<Brand>> Handle(GetAllBrandsQuery request, CancellationToken cancellationToken)
{
var brands = await _context.Brands.OrderBy(x => x.Id).ToListAsync();
if (brands == null) throw new Exception("Brands Not Found!");
return brands;
}
}
}

This is the simplified version of the MediatR handler. For more details, you can refer to CQRS with MediatR in ASP.NET Core – Ultimate Guide, where we go in-depth about MediatR Handlers and CQRS.

Similarly, add a new class, RegisterBrandCommand under the Register folder.

namespace Module.Catalog.Core.Commands.Register
{
public class RegisterBrandCommand : IRequest<int>
{
public string Name { get; set; }
public string Detail { get; set; }
}
internal class BrandCommandHandler : IRequestHandler<RegisterBrandCommand, int>
{
private readonly ICatalogDbContext _context;
public BrandCommandHandler(ICatalogDbContext context)
{
_context = context;
}
public async Task<int> Handle(RegisterBrandCommand command, CancellationToken cancellationToken)
{
if (await _context.Brands.AnyAsync(c => c.Name == command.Name, cancellationToken))
{
throw new Exception("Brand with the same name already exists.");
}
var brand = new Brand { Detail = command.Detail, Name = command.Name };
await _context.Brands.AddAsync(brand, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return brand.Id;
}
}
}

Now that we have taken care of the Handlers, let’s wire them up with our BrandsController. Open up the BrandsController and add in the following.

namespace Module.Catalog.Controllers
{
[ApiController]
[Route("/api/catalog/[controller]")]
internal class BrandsController : ControllerBase
{
private readonly IMediator _mediator;
public BrandsController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet]
public async Task<IActionResult> GetAllAsync()
{
var brands = await _mediator.Send(new GetAllBrandsQuery());
return Ok(brands);
}
[HttpPost]
public async Task<IActionResult> RegisterAsync(RegisterBrandCommand command)
{
return Ok(await _mediator.Send(command));
}
}
}

That’s it for the implementation. Let’s run the application and verify the swagger endpoints.

The POST method allows you to add in new brands.

modular-architecture-in-aspnet-core

And using the Get method, we can fetch all the brands records from the DB.

modular-architecture-in-aspnet-core

That’s it for the article. Do you want me to write another article to build upon this same solution and add extra infrastructure like Middlewares, Logging, and so on? Do let me know in the comments section. Modularizing application is definitely the way to go for a cleaner and scalable project.

fluentPOS - Modular Architecture Real Life Implementation

Moving forward, fluentpos will be our next full-fledged open-source implementation of Modular Architecture along with Angular Material Frontend and probably a Mobile App with MAUI too! Here is the repository - https://github.com/fluentpos/fluentpos. This project should be completed in about 2-3 months’ time from now. Do leave behind your stars for this repository as well.

Summary

In this article, we have learned about Modular Monolith Architecture in ASP.NET Core and also learned how to build it up right from scratch! We also learned about how it compares to Microservices and Monolith Architecture. You can also find the complete source code on my Github here. Have any suggestions or questions? Feel free to leave them in the comments section below. Thanks and Happy Coding! 😀

Source Code ✌️
Grab the source code of the entire implementation by clicking here. Do Follow me on GitHub .
Support ❤️
If you have enjoyed my content and code, do support me by buying a couple of coffees. This will enable me to dedicate more time to research and create new content. Cheers!
Share this Article
Share this article with your network to help others!
What's your Feedback?
Do let me know your thoughts around this article.

Boost your .NET Skills

I am starting a .NET 8 Zero to Hero Series soon! Join the waitlist.

Join Now

No spam ever, we are care about the protection of your data. Read our Privacy Policy