What is a Modular Monolith?
A modular monolith combines the deployment simplicity of a monolithic application with the organizational benefits of microservices. The codebase is split into independent modules with clear boundaries, but everything runs as a single deployable unit.
Benefits
Simple Deployment One deployment unit, no distributed system complexity
Clear Boundaries Modules communicate through contracts, not internal types
Easier Refactoring Extract to microservices later if needed
Team Scalability Teams can own modules independently
The IModule Interface
All modules implement the IModule interface located in BuildingBlocks/Web/Modules/:
src/BuildingBlocks/Web/Modules/IModule.cs
using Microsoft . AspNetCore . Routing ;
using Microsoft . Extensions . Hosting ;
namespace FSH . Framework . Web . Modules ;
public interface IModule
{
// DI/Options/Health/etc. — don't depend on ASP.NET types here
void ConfigureServices ( IHostApplicationBuilder builder );
// HTTP wiring — Minimal APIs only
void MapEndpoints ( IEndpointRouteBuilder endpoints );
}
Two Responsibilities
ConfigureServices
Register services, configure options, add health checks, register DbContext, etc.
This runs during application startup.
MapEndpoints
Register HTTP endpoints using Minimal APIs.
This runs after the app is built but before it starts.
The interface deliberately keeps these concerns separate. ConfigureServices doesn’t depend on ASP.NET Core types like HttpContext.
Example: Identity Module
Here’s how the Identity module implements IModule:
src/Modules/Identity/IdentityModule.cs (ConfigureServices)
src/Modules/Identity/IdentityModule.cs (MapEndpoints)
public class IdentityModule : IModule
{
public void ConfigureServices ( IHostApplicationBuilder builder )
{
ArgumentNullException . ThrowIfNull ( builder );
var services = builder . Services ;
// Register services
services . AddScoped < ICurrentUserService , CurrentUserService >();
services . AddScoped < ICurrentUser >( sp => sp . GetRequiredService < ICurrentUserService >());
services . AddScoped < ITokenService , TokenService >();
// User services - focused single-responsibility services
services . AddTransient < IUserRegistrationService , UserRegistrationService >();
services . AddTransient < IUserProfileService , UserProfileService >();
services . AddTransient < IUserStatusService , UserStatusService >();
services . AddTransient < IUserRoleService , UserRoleService >();
// Register DbContext
services . AddHeroDbContext < IdentityDbContext >();
// Add eventing support
services . AddEventingCore ( builder . Configuration );
services . AddEventingForDbContext < IdentityDbContext >();
// Health checks
builder . Services . AddHealthChecks ()
. AddDbContextCheck < IdentityDbContext >(
name : "db:identity" ,
failureStatus : HealthStatus . Unhealthy );
// ASP.NET Core Identity
services . AddIdentity < FshUser , FshRole >( options =>
{
options . Password . RequiredLength = IdentityModuleConstants . PasswordLength ;
options . Password . RequireDigit = false ;
options . User . RequireUniqueEmail = true ;
})
. AddEntityFrameworkStores < IdentityDbContext >()
. AddDefaultTokenProviders ();
// JWT Authentication
services . ConfigureJwtAuth ();
}
// MapEndpoints shown in next section...
}
Module Loading
Modules are discovered and loaded automatically using the ModuleLoader:
src/BuildingBlocks/Web/Modules/ModuleLoader.cs
public static class ModuleLoader
{
private static readonly List < IModule > _modules = new ();
private static bool _modulesLoaded ;
public static IHostApplicationBuilder AddModules (
this IHostApplicationBuilder builder ,
params Assembly [] assemblies )
{
ArgumentNullException . ThrowIfNull ( builder );
lock ( _lock )
{
if ( _modulesLoaded ) return builder ;
// Register FluentValidation validators from all modules
builder . Services . AddValidatorsFromAssemblies ( assemblies );
// Discover modules using [FshModule] attribute
var moduleRegistrations = assemblies
. SelectMany ( a => a . GetCustomAttributes < FshModuleAttribute >())
. Where ( r => typeof ( IModule ). IsAssignableFrom ( r . ModuleType ))
. DistinctBy ( r => r . ModuleType )
. OrderBy ( r => r . Order )
. ThenBy ( r => r . ModuleType . Name )
. Select ( r => r . ModuleType );
foreach ( var moduleType in moduleRegistrations )
{
if ( Activator . CreateInstance ( moduleType ) is not IModule module )
{
throw new InvalidOperationException (
$"Unable to create module { moduleType . Name } ." );
}
module . ConfigureServices ( builder );
_modules . Add ( module );
}
_modulesLoaded = true ;
}
return builder ;
}
public static IEndpointRouteBuilder MapModules ( this IEndpointRouteBuilder endpoints )
{
foreach ( var m in _modules )
m . MapEndpoints ( endpoints );
return endpoints ;
}
}
Module loading is thread-safe and happens only once. Modules are executed in order specified by the [FshModule(Order = X)] attribute.
Using Modules in Program.cs
The host application wires up modules in Program.cs:
src/Playground/Playground.Api/Program.cs
var builder = WebApplication . CreateBuilder ( args );
// Register Mediator with module assemblies
builder . Services . AddMediator ( o =>
{
o . ServiceLifetime = ServiceLifetime . Scoped ;
o . Assemblies = [
typeof ( GenerateTokenCommand ),
typeof ( GenerateTokenCommandHandler ),
typeof ( GetTenantStatusQuery ),
typeof ( GetTenantStatusQueryHandler )
];
});
// Define module assemblies
var moduleAssemblies = new Assembly []
{
typeof ( IdentityModule ). Assembly ,
typeof ( MultitenancyModule ). Assembly ,
typeof ( AuditingModule ). Assembly
};
// Add Hero platform (building blocks)
builder . AddHeroPlatform ( o =>
{
o . EnableCaching = true ;
o . EnableMailing = true ;
o . EnableJobs = true ;
});
// Load modules
builder . AddModules ( moduleAssemblies );
var app = builder . Build ();
// Use multi-tenancy middleware
app . UseHeroMultiTenantDatabases ();
// Use Hero platform middleware
app . UseHeroPlatform ( p =>
{
p . MapModules = true ; // Maps all module endpoints
p . ServeStaticFiles = true ;
});
await app . RunAsync ();
Module Structure
Each module follows a consistent structure:
Modules/
└── Identity/
├── Modules.Identity/ ← Implementation
│ ├── IdentityModule.cs ← IModule implementation
│ ├── Features/ ← Feature slices
│ │ └── v1/
│ │ ├── Users/
│ │ ├── Roles/
│ │ └── Groups/
│ ├── Domain/ ← Domain entities
│ │ ├── FshUser.cs
│ │ ├── FshRole.cs
│ │ └── Group.cs
│ ├── Data/ ← DbContext & configurations
│ │ ├── IdentityDbContext.cs
│ │ └── Configurations/
│ ├── Services/ ← Domain services
│ └── Authorization/ ← Permissions & policies
│
└── Modules.Identity.Contracts/ ← Public contracts
├── v1/
│ ├── Users/
│ │ └── RegisterUser/
│ │ └── RegisterUserCommand.cs
│ └── Roles/
├── DTOs/
│ ├── UserDto.cs
│ └── RoleDto.cs
└── Services/
└── IIdentityService.cs
Module Contracts
Modules expose contracts through separate .Contracts projects:
Command Contract
DTO Contract
// In Modules.Identity.Contracts/v1/Groups/CreateGroup/
using FSH . Modules . Identity . Contracts . DTOs ;
using Mediator ;
namespace FSH . Modules . Identity . Contracts . v1 . Groups . CreateGroup ;
public sealed record CreateGroupCommand (
string Name ,
string ? Description ,
bool IsDefault ,
List < string >? RoleIds ) : ICommand < GroupDto >;
Critical Rule : Other modules must never reference module implementations (e.g., Modules.Identity), only their contracts (e.g., Modules.Identity.Contracts).
Benefits of This Approach
Explicit Dependencies Module boundaries are enforced at compile-time through project references
Independent Testing Each module can be tested in isolation with its own test project
Parallel Development Teams can work on different modules without conflicts
Flexible Evolution Modules can be extracted to microservices if scaling requires it
Module Communication Patterns
// In another module, inject IMediator
public class SomeService
{
private readonly IMediator _mediator ;
public async Task DoSomething ()
{
// Call Identity module command
var result = await _mediator . Send (
new CreateGroupCommand ( "Admins" , "Administrator group" , false , null ));
}
}
2. Integration Events (Async)
// Publish event from Identity module
public sealed record UserRegisteredEvent : DomainEvent
{
public Guid UserId { get ; init ; }
public string Email { get ; init ; } = default ! ;
}
// Subscribe in another module
public class SendWelcomeEmailHandler : IIntegrationEventHandler < UserRegisteredEvent >
{
public async Task Handle ( UserRegisteredEvent @event , CancellationToken ct )
{
// Send welcome email
}
}
Next Steps
Vertical Slices Learn how features are organized within modules
CQRS & Mediator Understand the command and query pattern