Back to blog
Backend Systemsadvanced

Modular Monolith — Microservices Benefits Without the Distributed Systems Tax

Build a single deployable .NET app with hard module boundaries, in-process event communication, and per-module schemas — ready to extract into microservices when you actually need to.

LearnixoApril 14, 20265 min read
.NETC#ArchitectureModular MonolithDDDASP.NET CoreEF Core
Share:𝕏

Why Not Microservices From Day One?

Microservices trade deployment independence for significant operational complexity. You pay upfront:

  • Network latency on every cross-service call
  • Distributed transactions (saga pattern, eventual consistency)
  • Per-service CI/CD pipelines, health checks, service discovery
  • Distributed tracing across services to debug a single request
  • Integration testing becomes a multi-service orchestration problem

Most teams don't need this — yet. The modular monolith gives you the bounded context separation of microservices inside a single deployable binary.


What a Modular Monolith Looks Like

One application, multiple self-contained modules. Each module owns its domain, its data access, and its public API. Modules communicate through well-defined interfaces — not direct class references.

src/
  Modules/
    Orders/
      OrdersModule.cs          ← module registration
      Domain/
        Order.cs
        OrderStatus.cs
      Application/
        PlaceOrder/
          PlaceOrderCommand.cs
          PlaceOrderHandler.cs
      Infrastructure/
        OrdersDbContext.cs
        OrderRepository.cs
      Contracts/
        IOrderService.cs       ← public interface for other modules
        OrderPlacedEvent.cs    ← integration event
    Inventory/
      InventoryModule.cs
      Domain/
        Product.cs
        StockLevel.cs
      Application/
        ReserveStock/
          ReserveStockCommand.cs
          ReserveStockHandler.cs
      Infrastructure/
        InventoryDbContext.cs
      Contracts/
        IInventoryService.cs
        StockReservedEvent.cs
    Notifications/
      NotificationsModule.cs
      ...
  Shared/
    Events/
      IIntegrationEvent.cs
      IEventPublisher.cs
    Persistence/
      SharedDbContext.cs       ← optional — only if modules share a DB
  Program.cs

Enforcing Module Boundaries With internal

The most powerful tool here is C#'s internal access modifier. Everything inside a module that isn't meant to be called from outside is internal:

C#
// Orders/Infrastructure/OrderRepository.cs
// internal — no other module can reference this directly
internal class OrderRepository : IOrderRepository
{
    private readonly OrdersDbContext _db;
    public OrderRepository(OrdersDbContext db) => _db = db;

    public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
        => await _db.Orders.FindAsync(new object[] { id }, ct);
}

// Orders/Domain/Order.cs — also internal to the module
internal class Order
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public List<OrderLine> Lines { get; private set; } = new();
    // ... domain methods
}

Only the Contracts/ folder is public:

C#
// Orders/Contracts/IOrderService.cs — public interface
public interface IOrderService
{
    Task<OrderSummary> PlaceOrderAsync(PlaceOrderRequest request, CancellationToken ct = default);
    Task<OrderDetails?> GetOrderAsync(Guid orderId, CancellationToken ct = default);
}

// Orders/Contracts/OrderPlacedEvent.cs — public integration event
public record OrderPlacedEvent(
    Guid OrderId,
    Guid CustomerId,
    decimal Total,
    DateTime PlacedAt) : IIntegrationEvent;

Module Registration

Each module registers its own services:

C#
// Orders/OrdersModule.cs
public static class OrdersModule
{
    public static IServiceCollection AddOrdersModule(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddDbContext<OrdersDbContext>(options =>
            options.UseNpgsql(configuration.GetConnectionString("Orders")));

        // internal implementations registered against public interfaces
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<IOrderService, OrderService>();

        services.AddMediatR(cfg =>
            cfg.RegisterServicesFromAssemblyContaining<OrdersModule>());

        return services;
    }
}
C#
// Program.cs
builder.Services
    .AddOrdersModule(builder.Configuration)
    .AddInventoryModule(builder.Configuration)
    .AddNotificationsModule(builder.Configuration);

Cross-Module Communication: Interfaces and In-Process Events

Option 1: Interface injection — one module depends on another's public contract:

C#
// InventoryModule — needs to know about Orders to reserve stock
// It takes a dependency on the Orders contract, not the implementation
internal class ReserveStockHandler : IRequestHandler<ReserveStockCommand>
{
    private readonly IInventoryRepository _inventory;

    public ReserveStockHandler(IInventoryRepository inventory)
        => _inventory = inventory;

    public async Task Handle(ReserveStockCommand request, CancellationToken ct)
    {
        await _inventory.ReserveAsync(request.ProductId, request.Quantity, ct);
    }
}

Option 2: In-process events — looser coupling, no direct dependency:

C#
// Shared event publisher interface
public interface IEventPublisher
{
    Task PublishAsync<T>(T @event, CancellationToken ct = default) where T : IIntegrationEvent;
}

// Simple in-process publisher using MediatR notifications
public class InProcessEventPublisher : IEventPublisher
{
    private readonly IMediator _mediator;
    public InProcessEventPublisher(IMediator mediator) => _mediator = mediator;

    public Task PublishAsync<T>(T @event, CancellationToken ct = default) where T : IIntegrationEvent
        => _mediator.Publish(@event, ct);
}

Orders publishes:

C#
// Inside PlaceOrderHandler
await _eventPublisher.PublishAsync(new OrderPlacedEvent(
    order.Id, order.CustomerId, order.Total, order.CreatedAt), ct);

Inventory listens without knowing about Orders:

C#
// Inventory/Application/OrderPlacedHandler.cs
internal class OrderPlacedHandler : INotificationHandler<OrderPlacedEvent>
{
    private readonly IInventoryRepository _inventory;

    public OrderPlacedHandler(IInventoryRepository inventory) => _inventory = inventory;

    public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
    {
        // Reserve stock when an order is placed
        foreach (var line in notification.Lines)
            await _inventory.ReserveAsync(line.ProductId, line.Quantity, ct);
    }
}

Shared Database vs Per-Module Schema

Two valid approaches — each with trade-offs:

Per-module schema (recommended for clean boundaries):

SQL
-- Orders module owns this schema
CREATE TABLE orders.orders (...);
CREATE TABLE orders.order_lines (...);

-- Inventory module owns this schema
CREATE TABLE inventory.products (...);
CREATE TABLE inventory.stock_levels (...);
C#
// OrdersDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.HasDefaultSchema("orders");
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrdersDbContext).Assembly);
}

Modules cannot join across schemas — they must use events or contract interfaces. This boundary is enforced at the database level.

Shared database, shared schema — simpler to start, but modules can start reaching into each other's tables. Acceptable for early stages, a liability as the team grows.


Migrating to Microservices Later

When a module is ready to extract:

  1. Its DbContext already maps to its own schema — extract it to a new database with minimal migration effort
  2. Its IIntegrationEvents already define the async contract — swap the in-process publisher for a message broker (MassTransit, Azure Service Bus)
  3. Its public IModuleService interface becomes an HTTP or gRPC contract
  4. Deploy the extracted module as a separate service, update the caller to use the network version

The modular monolith is the prep work. Done right, extraction is refactoring — not a rewrite.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.