Insights & Blog

Modular Monolith Architecture in .NET: When It Beats Microservices

Build a .NET modular monolith with business module contracts, data ownership and a justified extraction strategy. Includes a locally tested C# example and troubleshooting checklist.

By Hetal KIT Solution

Modular monolith design: Ordering and Inventory own behavior and data behind public contracts, with an explicit seam for future service extraction.

Modular Monolith Architecture in .NET: When It Beats Microservices

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.

NeedModular monolith candidateService extraction candidate
Release coordinationOne team or compatible release cadenceIndependent release authority provides material value
ScalingWhole-application scaling is acceptableA capability has a distinct, costly scaling profile
Failure isolationShared process risk is acceptable and managedA separate process boundary is necessary
Data changesClear ownership within a coordinated deploymentIndependent data lifecycle can be sustained
OperationsCentral hosting and incident ownershipTeams 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 DbContext

Start 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

SymptomLikely boundary problemPractical correction
Every feature touches many modulesCapabilities are split along the wrong axisMap business changes and revisit cohesion
One module imports another’s entitiesImplementation has leaked through the contractReturn owned contract types and restrict references
Retries decrement stock twiceDuplicate handling is not durable or atomicDefine operation identity and persistence constraints
Events disappear on restartIn-memory delivery was assumed durableUse a tested persistence and delivery workflow
Reporting writes across module tablesRead access has become write ownershipSeparate projection needs from mutation authority
A shared utilities project contains business rulesOwnership is unclearAssign 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.

  1. State the operational reason for extraction and define its success criteria.
  2. Remove competing writes and clarify the module’s data ownership.
  3. Version the public contract and test consumer behavior.
  4. Introduce a remote adapter behind the existing boundary where suitable.
  5. Design timeouts, retries, duplicate handling and partial-failure states.
  6. Migrate data with reconciliation and a controlled cutover.
  7. 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.

From insight to implementation

Turn these ideas into a practical plan

Discuss how the ideas in this article apply to your business, systems and data. We’ll help you identify a focused first step.