A modular monolith in .NET keeps business capabilities behind explicit contracts while deploying the application as one unit. It can be a better fit than microservices when a team needs clear ownership and maintainable boundaries without separate service operations. The benefit comes from enforced module and data ownership, not from putting existing code into new folders.
Key takeaways
- Organize modules around business capabilities and owned behavior.
- Expose narrow contracts rather than internal entities or database contexts.
- Single deployment simplifies some operations but preserves shared process and release risks.
- Extract a service only when an independent operational boundary has a clear value.
When a modular monolith can beat microservices
Choose one deployment when the team can release together, the workload can scale acceptably as a unit and the cost of distributed operations outweighs the value of independent services. This is a workload decision, not a claim that microservices are inherently wrong.
Separate services can help when capabilities need independent deployment, scaling, isolation or organizational ownership. They also require network failure handling, contract evolution, monitoring and data consistency across process boundaries. Do not introduce that work solely to make a diagram appear modern.
| Need | Modular monolith candidate | Service extraction candidate |
|---|---|---|
| Release coordination | One team or compatible release cadence | Independent release authority provides material value |
| Scaling | Whole-application scaling is acceptable | A capability has a distinct, costly scaling profile |
| Failure isolation | Shared process risk is acceptable and managed | A separate process boundary is necessary |
| Data changes | Clear ownership within a coordinated deployment | Independent data lifecycle can be sustained |
| Operations | Central hosting and incident ownership | Teams can own service operations and integration failures |
Measure coupling and operating needs before deciding. A single application with explicit modules can be easier to change than multiple services that share tables and require synchronized releases.
A modular monolith .NET architecture
Microsoft’s domain-boundary guidance emphasizes meaningful business capabilities and cohesion rather than the smallest possible components. Apply that boundary thinking before deciding whether a capability needs a separate process.
ASP.NET Core host / composition root
├─ Ordering module → Inventory public contract
│ └─ Ordering-owned state and rules
├─ Inventory module
│ └─ Inventory-owned stock and reservation rules
└─ Billing module
└─ Billing-owned invoices and integration workflow
Cross-cutting platform: identity, telemetry, configuration
No direct imports of another module's internal entities or DbContextStart with modules such as Ordering and Inventory, not horizontal buckets named Controllers, Services and Repositories across the entire business. Each module can organize its internals with layers or feature slices according to its needs.
Publish small application contracts containing stable request and response types. Avoid returning ORM entities, mutable internal collections or provider-specific query objects. Those expose implementation decisions and make later changes propagate across module boundaries.
Use separate projects where compile-time references help enforce boundaries. A namespace named Internal inside a single assembly does not provide cross-module compiler isolation. Architecture checks and review can help, but separate assemblies with restricted references provide a stronger enforcement mechanism.
Data ownership and transaction boundaries
Give each module authority over its tables or collections and migration scripts. A shared physical database can still have logical ownership, but do not let every module update every table. Cross-module joins and shared entity models should be deliberate exceptions with an owner and migration plan.
Use a module contract for an operation that requires another capability’s rule. Read models can be designed for reporting without granting arbitrary write access. Record how projections are refreshed and what staleness consumers may observe.
Decide whether a business action is one local transaction or a workflow with intermediate states. A shared process does not automatically make two database contexts and an external action atomic. Include failure recovery and reconciliation in the contract.
If an event must survive a process crash after a database write, ordinary in-memory dispatch is insufficient. A durable outbox and reliable consumer design may be appropriate, depending on the persistence and delivery requirements. Merely naming an object DomainEvent does not provide durability.
A locally tested module-contract example
The following single-file .NET 10 console demonstration makes Ordering depend on an Inventory contract. Inventory owns reservation checks. It accepts an identical retry for the same order and rejects a conflicting one.
Verification: compiled and executed locally using SDK 10.0.401. Seven checks passed: valid reservation, insufficient stock, zero quantity, missing product, identical retry, conflicting retry and unchanged stock after retry.
Scope: this is in-memory, single-process teaching code. It has no durable database, concurrent access control, authorization or complete order lifecycle. Namespaces illustrate responsibility; they do not enforce assembly isolation here. A production implementation needs those controls and transaction-safe persistence.
using Inventory.Contracts;
using Inventory.Internal;
using Ordering;
var stock = new StockModule();
var orders = new OrderModule(stock);
Check(orders.Submit("order-1", "sku-1", 2), "valid order");
Check(!orders.Submit("order-2", "sku-1", 2), "insufficient stock denied");
Check(!orders.Submit("order-3", "sku-1", 0), "zero quantity denied");
Check(!orders.Submit("order-4", "missing", 1), "unknown product denied");
Check(orders.Submit("order-1", "sku-1", 2), "same order retry accepted");
Check(!orders.Submit("order-1", "sku-1", 1), "conflicting retry denied");
Check(stock.Available("sku-1") == 1, "retry did not reserve twice");
Console.WriteLine("7 checks passed; in-memory single-process demonstration only.");
static void Check(bool value, string name) { if (!value) throw new Exception(name); }
namespace Inventory.Contracts {
public interface IStockReservations {
bool TryReserve(string orderId, string sku, int quantity);
}
}
namespace Inventory.Internal {
internal sealed class StockModule : IStockReservations {
private readonly Dictionary<string, int> stock = new() { ["sku-1"] = 3 };
private readonly Dictionary<string, (string Sku, int Quantity)> reservations = new();
public bool TryReserve(string orderId, string sku, int quantity) {
if (string.IsNullOrWhiteSpace(orderId) || string.IsNullOrWhiteSpace(sku) || quantity <= 0) return false;
if (reservations.TryGetValue(orderId, out var existing)) return existing == (sku, quantity);
if (!stock.TryGetValue(sku, out var available) || available < quantity) return false;
stock[sku] = available - quantity;
reservations[orderId] = (sku, quantity);
return true;
}
public int Available(string sku) => stock.GetValueOrDefault(sku);
}
}
namespace Ordering {
internal sealed class OrderModule(IStockReservations stock) {
public bool Submit(string orderId, string sku, int quantity) => stock.TryReserve(orderId, sku, quantity);
}
}
To reproduce, create a .NET 10 console project, place the example in Program.cs and run dotnet run. It uses the base class library and no external packages. The expected output is 7 checks passed; in-memory single-process demonstration only.
For a real ASP.NET Core application, move public contracts and implementations into appropriately referenced assemblies and register the implementation at the composition root. Add request authorization before exposing a reservation endpoint. Replace the dictionaries with persistence designed for atomic reservation and duplicate detection.
The retry behavior is intentionally narrow: order ID identifies one reservation and changed payloads are rejected. It does not define how amendments, cancellation, reservation expiry or tenant scoping should work. Those belong in the business contract and tests.
Troubleshooting common modularity failures
| Symptom | Likely boundary problem | Practical correction |
|---|---|---|
| Every feature touches many modules | Capabilities are split along the wrong axis | Map business changes and revisit cohesion |
| One module imports another’s entities | Implementation has leaked through the contract | Return owned contract types and restrict references |
| Retries decrement stock twice | Duplicate handling is not durable or atomic | Define operation identity and persistence constraints |
| Events disappear on restart | In-memory delivery was assumed durable | Use a tested persistence and delivery workflow |
| Reporting writes across module tables | Read access has become write ownership | Separate projection needs from mutation authority |
| A shared utilities project contains business rules | Ownership is unclear | Assign authoritative capability contracts and maintainers |
Test boundaries in CI: permitted project references, contract compatibility, module integration and critical end-to-end journeys. Add concurrency and failure tests where state changes need them. The simple lab above does not cover those production risks.
Track actual maintenance friction. A module with many inbound dependencies and frequent cross-module migrations may need redesign, not immediate extraction. Moving a poorly defined boundary across a network often makes the same coupling more expensive.
An extraction strategy that preserves ownership
The Strangler Fig pattern describes incremental replacement through a routing or facade boundary. For a modular monolith, an existing public module contract can provide a useful seam, but it does not eliminate migration work.
- State the operational reason for extraction and define its success criteria.
- Remove competing writes and clarify the module’s data ownership.
- Version the public contract and test consumer behavior.
- Introduce a remote adapter behind the existing boundary where suitable.
- Design timeouts, retries, duplicate handling and partial-failure states.
- Migrate data with reconciliation and a controlled cutover.
- Retire the old implementation after the recovery boundary is understood.
A local method call can fail differently from a network request. Do not hide timeouts and partial completion behind an interface and pretend the semantics are unchanged. Revisit the workflow explicitly when the deployment boundary changes.
Keep the monolith when it continues to meet business and operating needs. “Extractable” is a useful design property, not an obligation to create microservices later. Relate internal organization to the Clean Architecture and vertical slice comparison.
Common questions
How do you build a modular monolith in .NET?
Define business modules, narrow contracts, owned data and restricted dependencies inside one deployment. Test boundaries and failure behavior rather than relying on folder names.
Does each module need a separate database?
Not necessarily. Logical ownership can exist in one database, but other modules should not bypass the owner’s mutation rules. Choose physical separation from operating requirements.
When does a modular monolith beat microservices?
When coordinated deployment and scaling meet requirements and separate service operations add more cost than value. Reassess when independent isolation or ownership becomes necessary.
Do in-memory events guarantee delivery?
No. Process failure can lose pending work. Durable workflows require explicit persistence, delivery and recovery design.
Can a module be extracted later?
Yes, but remote communication, data migration and partial failure require new engineering. A clean contract helps without making extraction automatic.
Modernize boundaries before distributing the system
Bring your dependency map, data ownership and release constraints. Discuss your .NET modernization roadmap to assess modular boundaries and a justified extraction path.
