Building Modular Monoliths with .NET 8: A Complete Guide to Scalable Architecture
The software architecture landscape has evolved dramatically over the past decade, with many teams swinging from traditional monolithic applications to complex microservice architectures. However, there's a compelling middle ground that's gaining significant traction among experienced developers: modular monoliths. This architectural pattern combines the operational simplicity of monoliths with the organizational benefits of microservices, making it particularly attractive for .NET Core applications.
Overview
What Are Modular Monoliths and Why Should You Care?
A modular monolith is an architectural pattern that structures an application into independent modules or components with well-defined boundaries, while maintaining a single deployable unit. Think of it as organizing your apartment into distinct rooms each room serves a specific purpose and has clear boundaries, but they all exist within the same building and share common infrastructure.Monolith vs Microservices vs Modular Monolith
Traditional Monoliths
Key Advantages Over Traditional Monoliths
- Enhanced Development Velocity: Teams can work on different modules simultaneously without stepping on each other's toes. The clear boundaries reduce merge conflicts and allow for independent development cycles within the same codebase.
- Improved Performance: Unlike microservices, inter-module communication occurs in-process, eliminating network latency and serialization overhead. This results in significantly better performance for operations that span multiple business domains.
- Simplified Transaction Management: Managing data consistency across modules becomes much easier when everything runs in the same process and can share database transactions.
- Lower Operational Complexity: You maintain the operational simplicity of a monolith, single deployment, unified logging, simplified monitoring—while gaining the organizational benefits of modular design.
Microservices
Microservices break functionality into independent services (separate processes, often different stacks). They excel at independent scaling and team autonomy: each service has a single responsibility. Teams can pick their own tech stack per service. And each service can be scaled or deployed independently. However, microservices come with significant overhead: distributed communication, data consistency, DevOps and operational complexity.
Advantages Over Microservices
While microservices offer benefits like independent deployment and technology diversity, they come with significant complexity costs. Modular monoliths provide many of the same organizational benefits without the operational overhead:
- No Distributed System Complexity: You avoid challenges like network partitions, distributed transactions, service discovery, and eventual consistency.
- Easier Debugging and Testing: All code runs in the same process, making it much easier to debug cross-module interactions and write integration tests.
- Simplified Infrastructure: No need for container orchestration, service meshes, or complex deployment pipelines.
Modular Monolith
A modular monolith is a middle path. It keeps one runtime/host (simpler DevOps, one CI pipeline) but enforces clean boundaries in code. We treat each module almost as a mini-service: it has its own models, data layer, and business logic. A modular monolith requires discipline, you design explicit interfaces or events between modules, but spares you the heavy-weight deployment choreography of microservices.
The Core Principles of Modular Monolith
The foundation of modular monolith architecture rests on several key principles that make it particularly well-suited for .NET Core development:
- Strong Module Boundaries: Each module encapsulates related functionality and maintains clear interfaces for external communication. This ensures high cohesion within modules and loose coupling between them.
- Data Encapsulation: Modules own their data and business logic exclusively. One module cannot directly access another module's database tables or internal state.
- Communication Through Contracts: Modules interact through well-defined APIs, events, or messaging patterns rather than direct method calls.
- Single Deployment Unit: Despite logical separation, all modules are deployed together as one application, simplifying operational concerns.
Why Modular Monoliths Are Perfect for .NET Core Applications
.NET Core's architecture and tooling make it exceptionally well-suited for building modular monoliths. The framework's built-in dependency injection container, hosting model, and assembly loading capabilities provide natural support for modular design patterns.
Designing .NET Core Modular Monolith
Project Structure and Organization
The foundation of a successful modular monolith lies in its project structure. Here's a proven approach that leverages .NET Core's project system effectively:
.NET Core Modular Project Structure
- Apps: e.g. /Apps/WebAPI (the ASP.NET Core host project).
- Modules: e.g. /Modules/Inventory, /Modules/Orders. Each module folder contains its own subprojects.
- Shared: common types (usually small interfaces) and base libraries.
YourApplication.sln ├── src/ │ ├── BuildingBlocks/ # Shared infrastructure │ │ ├── Common.Domain/ # Shared domain concepts │ │ ├── Common.Application/ # Shared application services │ │ └── Common.Infrastructure/ # Cross-cutting concerns │ ├── Modules/ │ │ ├── Identity/ │ │ │ ├── Identity.API/ # HTTP endpoints │ │ │ ├── Identity.Application/ # Business logic │ │ │ ├── Identity.Domain/ # Domain models │ │ │ ├── Identity.Infrastructure/ # Data access │ │ │ └── Identity.Contracts/ # Public interfaces │ │ ├── Catalog/ │ │ │ ├── Catalog.API/ │ │ │ ├── Catalog.Application/ │ │ │ ├── Catalog.Domain/ │ │ │ ├── Catalog.Infrastructure/ │ │ │ └── Catalog.Contracts/ │ │ └── Orders/ │ │ ├── Orders.API/ │ │ ├── Orders.Application/ │ │ ├── Orders.Domain/ │ │ ├── Orders.Infrastructure/ │ │ └── Orders.Contracts/ │ └── API/ │ └── YourApplication.API/ # Main host application
This structure provides several important benefits:
- Clear module boundaries with no cross-module project references except through Contracts
- Shared infrastructure in BuildingBlocks for common concerns
- Self-contained modules that can evolve independently
- Easy migration path to microservices if needed later
Module Design Patterns
Each module in your modular monolith should follow clean architecture principles, typically organized into layers:
- API Layer: Contains HTTP endpoints, controllers, and external interfaces. This layer handles HTTP concerns and delegates business logic to the Application layer.
- Application Layer: Implements use cases and application services using patterns like CQRS with MediatR. This layer orchestrates domain operations and coordinates with external services.
- Domain Layer: Contains business entities, value objects, and domain services. This is the heart of your module where business rules live.|
- Infrastructure Layer: Handles data persistence, external service integration, and technical concerns like caching or messaging.
- Contracts Layer: Defines public interfaces and DTOs that other modules can depend on, providing a stable contract for inter-module communication.
Implementing Modules in .NET Core
In ASP.NET Core, you can leverage the framework’s flexibility to set up a modular monolith:
- Projects and Namespaces: Create a solution with multiple projects. For example, an Inventory class library and an Orders class library, alongside an Host Web API project. In the host’s Startup (or Program.cs in .NET 6+), you reference the module projects. Each module can register its own services (via extension methods or a known interface).
- Dependency Injection (DI): Use the built-in DI container to wire up services. Each module should register its own repositories, domain services, etc.
- Internal Access: We mentioned making classes internal. By default, ASP.NET Core only finds public controllers. If you mark controllers internal, you’ll need to customize controller discovery.
- Routing Conventions: To avoid API route collisions between modules, you can prefix routes with the module name. One technique is to define a custom IActionModelConvention that prepends a [module] segment to each route based on the controller’s assembly[25].
- Shared Libraries: If modules need to share only contracts, create small “shared” projects.
- Hosting: In .NET 6+ you might have a WebApplication.CreateBuilder() in Program.cs. After building the app, loop through your modules and call any module-specific startup logic (e.g. module.Configure(app)). Essentially, you’re doing dynamic “AddModule<TStartup>()” calls for each module assembly, as shown in many sample modular monolith implementations.
By following these steps, your .NET solution still builds into one deployable (one Docker image, one App Service, etc.), but the runtime behavior is already modular.
Module Communication Patterns
Even within one process, communication between modules should be deliberate:
- Direct Calls: The simplest pattern is direct method calls via DI. Module A might inject a service interface from Module B.
- Domain/Integration Events: A powerful technique is to use events. The publisher (Module X) raises a domain event or integration event when something important happens. The consuming module (Module Y) handles that event.
- Database/Reports: If modules share a database, beware direct cross-DB table queries. Prefer that modules only update their own tables. If you need data from another module (e.g. show products when listing orders), use an explicit read model or call the other module’s API. Or consider a dedicated “Reporting” context that synchronizes needed data via events.
- Handling Dependencies: When Module A depends on Module B’s interface, consider versioning carefully.
Data Storage Strategies
Shared Database with Schema Separation
You can start with one shared database. Within it, ensure each module’s tables are logically separated (e.g. naming conventions or separate schemas). This simplifies dev/ops (one connection string, one backup).
Advantages of Shared Database with Schema Separation
This approach offers several benefits:
- Simplified transactions across modules when needed
- Easier deployment with database migrations
- Cost-effective resource utilization
- Straightforward backup and disaster recovery
Per-Module Databases
Alternatively, give each module its own database instance. This is closer to microservices practice and guarantees isolation. It lets you pick optimal DB tech per module (e.g. one uses SQL Server, another a document DB). It also means you eventually practice distributed transactions or event-driven consistency.
Advantages of Per-Module Databases
This approach provides:
- Complete data isolation between modules
- Independent scaling of database resources
- Easier migration to microservices later
- Clear ownership of data
Choose the approach based on your needs. For many modular monoliths, a single DB with careful separation is a pragmatic first choice. You can always refactor to separate DBs later using an event-driven approach.
Source Control and Deployment
Since it’s one application, you have options for code repositories:
- Monorepo: All modules in one Git repo (common for monoliths).
Monorepos work best when a single team or small set of teams owns many modules. It encourages rapid changes and avoiding backward-compatibility hassles, if you break an interface, just fix the dependent module in the same commit.
- Multi-Repo (Manyrepo) or Multi-Solution: If modules are very independent and mature, you could store each in its own repo. Then you’d use package references (e.g. private NuGet) between them. This is more like microservice development.
- Deployment: The entire modular monolith is deployed together – one web app/service. You benefit from simple deployment processes (one docker image, one App Service, etc.). All modules run in the same process, so no distributed deployment hassle.
In short, treat your modular monolith like any other .NET solution: commit often, build once, deploy once.
Performance Optimization and Monitoring
In-Process Performance Benefits
One of the major advantages of modular monoliths is the performance benefit of in-process communication. Here are key optimization strategies:
- Efficient Memory Usage: Share common objects between modules rather than serializing/deserializing data across network boundaries.
- Reduced Latency: Method calls between modules are orders of magnitude faster than HTTP calls in microservice architectures.
- Simplified Caching: Implement shared caching strategies across modules using .NET Core's built-in memory caching.
Benefits and Trade-offs
Benefits
A well-crafted .NET modular monolith brings many gains:
- Encapsulation: Business logic for each domain is contained in its module. It’s easier to find code and avoid “spaghetti” references.
- Simpler operations: You still have one codebase to build and one deployment artifact. No service discovery or inter-service API calls over the network. Monitoring, logging, and debugging are simpler.
- Consistent stack: All modules run on the same .NET runtime (no mix of languages or framework versions). You can use .NET 6/7 features (dependency injection, records, minimal APIs, etc.) everywhere.
- Team productivity: Teams can work on different modules without stepping on each other’s toes, as long as contracts are clear. It’s easier to onboard new devs because there’s one solution to open.
- Gradual migration path: If the system grows huge, modules can be extracted to microservices later. You’ll have already defined clear interfaces/events in the monolith, easing the split.
Trade-offs
However, modular monoliths are not silver bullets. You still have a single-point-of-failure: one crash takes down everything. You can’t scale parts independently (the whole app scales). And you lose some language/tech diversity advantages of microservices. Code-wise, you need discipline: accidental tight coupling can undo the benefits.
Best Practices and Patterns
- Use DDD & Vertical Slices: Treat each module as a domain or bounded context. Avoid horizontal layering that cuts across domains. Instead, slice vertically by business feature within each module.
- Test per Module: Each module should have its own unit/integration tests. You might have a test project per module. This ensures you can validate modules in isolation. The Apps test folder can contain end-to-end tests that span modules (e.g. calling the Web API).
- Metadata and Configuration: To avoid “stringly-typed” wiring, consider having each module provide metadata: a route prefix, a set of services, etc.
- Logging and Health: Even though there are modules, the app is single-host. You can tag logs with module names.
- Migration Path: If you foresee moving to microservices, design interactions as if they were remote calls.
- Documentation: Keep a high-level diagram in your docs. Include a module dependency diagram. Document module responsibilities and data ownership. This will prevent “accidental coupling” where someone bypasses the module interfaces.
- Review and Refactor: Periodically review the boundaries. Module splits can change as business evolves. If one module becomes too large, consider breaking it into sub-modules.
Conclusion
Modular monoliths represent a pragmatic approach to software architecture that's particularly well-suited to .NET Core's strengths. They provide the organizational benefits of microservices clear boundaries, independent development, and focused responsibilities while maintaining the operational simplicity that makes monoliths attractive.
By following the patterns and practices outlined in this guide, you can build .NET Core applications that are maintainable, scalable, and positioned for future growth. Whether you're starting a new project or refactoring an existing monolith, the modular monolith pattern offers a proven path to sustainable software architecture.
The key is to start with clear module boundaries, implement proper communication patterns, and remain focused on business value rather than architectural complexity. With .NET Core's excellent tooling and framework support, building modular monoliths has never been more straightforward or powerful.
Remember, architecture should serve your business needs, not the other way around. Modular monoliths excel when you need the benefits of modularity without the operational overhead of distributed systems, making them an excellent choice for many .NET Core applications.