Accepted
When building enterprise applications, teams often face a choice between monolithic and microservices architectures. Each has significant tradeoffs:
Traditional Monoliths:
- Tight coupling between features
- Shared database leading to implicit dependencies
- Difficult to extract features into services later
- All-or-nothing deployment
- Teams stepping on each other's code
Microservices:
- Operational complexity (distributed systems, service discovery, tracing)
- Network latency and partial failures
- Distributed transactions and data consistency challenges
- Infrastructure overhead for small/medium teams
- Premature optimization if domain boundaries are unclear
The application needed an architecture that:
- Provides isolation and clear boundaries between features
- Enables independent development by different teams/developers
- Maintains operational simplicity (single deployment unit)
- Supports future extraction to microservices if needed
- Scales development without scaling infrastructure complexity
Adopt a Modular Monolith architecture where the application is organized into self-contained modules (vertical slices), each deployed as a single application.
Each module (e.g., CoreModule) is a vertical slice containing:
- Domain Layer: Business logic specific to the module
- Application Layer: Use cases and workflows
- Infrastructure Layer: Persistence (own DbContext) and integrations
- Presentation Layer: API endpoints and module registration
src/Modules/<ModuleName>/
├── <Module>.Domain/ # Business logic
├── <Module>.Application/ # Commands, Queries, Handlers
├── <Module>.Infrastructure/ # DbContext, Repositories
└── <Module>.Presentation/ # Endpoints, Module registration
- Self-Contained: Each module has its own DbContext and database schema
- No Direct References: Modules cannot reference other modules' internal layers
- Communication: Modules communicate via:
- Contracts: Public interfaces (optional
.Contractsprojects) - Integration Events: Async communication through message bus
- Public APIs: HTTP endpoints if needed
- Contracts: Public interfaces (optional
- Independent Evolution: Modules can evolve independently
- Presentation.Web.Server: Composition root that wires all modules together
- Program.cs: Registers modules via
AddModules().WithModule<CoreModuleModule>() - Single Deployment: All modules deployed as one ASP.NET Core application
- Simplicity: Single deployment, single database, no distributed system complexity
- Clear Boundaries: Modules enforce boundaries like microservices but without network overhead
- Team Scalability: Teams can work on different modules with minimal conflicts
- Flexibility: Can extract modules to microservices later if needed (each has independent data store)
- Performance: In-process communication (no network latency, serialization overhead)
- Operational Simplicity: One application to deploy, monitor, and debug
- Transaction Support: Can use database transactions across modules if needed (same process)
- Cost Effective: Single infrastructure footprint for small/medium teams
- Clear module boundaries prevent coupling and tangled dependencies
- Each module can be developed, tested, and understood independently
- Single deployment simplifies CI/CD pipelines (no orchestration needed)
- In-process communication is fast and doesn't require distributed tracing initially
- Modules can be extracted to microservices when requirements justify it
- Architecture tests enforce module isolation boundaries
- Operational complexity remains low (no service mesh, API gateway, distributed tracing requirements)
- Still a shared runtime (one module crashing can affect others)
- Cannot scale modules independently (though can use separate instances with routing)
- Module discipline required (no direct references between modules)
- More projects to manage than traditional monolith
- Each module has its own database schema (via separate DbContext)
- Modules registered explicitly in
Program.cs - Host application (
Presentation.Web.Server) acts as composition root
-
Alternative 1: Traditional Monolith (Single Project)
- Rejected because boundaries erode over time without enforcement
- No clear separation between features
- Cannot extract features to services later
-
Alternative 2: Microservices from Day One
- Rejected because of operational complexity and infrastructure cost
- Premature optimization before domain boundaries are well understood
- Network latency and distributed transaction challenges
-
Alternative 3: Shared Database Monolith
- Rejected because shared database creates implicit coupling
- Difficult to extract modules later (shared schema)
- Schema migrations affect all features simultaneously
- ADR-0001: Layering within each module
- ADR-0005: In-process communication mechanism
- ADR-0006: Event-driven communication between modules
- bITdevKit Modules Documentation
- README - Modular Monolith Structure
- README - Module System
- CoreModule README - Overview
Each module implements WebModuleBase and registers its services:
public class CoreModuleModule : WebModuleBase("CoreModule")
{
public override IServiceCollection Register(
IServiceCollection services,
IConfiguration configuration,
IWebHostEnvironment environment)
{
// Register DbContext
services.AddSqlServerDbContext<CoreModuleDbContext>(...);
// Register repositories
services.AddEntityFrameworkRepository<Customer, CoreModuleDbContext>()
.WithBehavior<RepositoryTracingBehavior<Customer>>()
.WithBehavior<RepositoryLoggingBehavior<Customer>>();
// Register endpoints
services.AddEndpoints<CustomerEndpoints>();
return services;
}
}builder.Services.AddModules(builder.Configuration, builder.Environment)
.WithModule<CoreModuleModule>()
.WithModuleContextAccessors()
.WithRequestModuleContextAccessors();Synchronous (within same module):
// Command → Handler → Repository
await requester.SendAsync(new CustomerCreateCommand(model));Asynchronous (cross-module):
// Domain Event → Outbox → Integration Event → Other Module Handler
customer.DomainEvents.Register(new CustomerCreatedDomainEvent(customer));If a module needs to be extracted to a microservice:
- Module already has isolated database schema (own DbContext)
- Module already has independent layers (Domain, Application, Infrastructure, Presentation)
- Change in-process commands to HTTP/gRPC calls
- Change domain events to message bus (RabbitMQ, Azure Service Bus)
- Deploy module independently
- CoreModule: Customer management domain (demonstrated in this example)
- Future modules: Can add InventoryModule, OrderModule, etc. following the same pattern
- Module definition:
src/Modules/CoreModule/CoreModule.Presentation/CoreModuleModule.cs - Host registration:
src/Presentation.Web.Server/Program.cs - Module structure:
src/Modules/CoreModule/(Domain, Application, Infrastructure, Presentation)