In the previous post, we built the Orders feature using five architectures — from Simple MVC to Microservices. Each one was tested and compared side by side.
But there's one architecture we haven't covered: Modular Monolith.
In .NET, Modular Monolith has a unique advantage: module boundaries can be enforced at the compiler level using the internal keyword and project separation. Not just convention, not just a linting rule — but an actual build error if anyone tries to break the rules.
---
The Problem It Solves
In Feature-Based Structure, this is perfectly valid and the compiler won't complain:
// Features/Orders/OrdersService.cs
using Features.Notifications; // direct import into another module's internals
public class OrdersService
{
private readonly NotificationsService _notifications; // hidden coupling
}
Over time, the orders module knows too much about the internals of notifications. When you want to separate them — for refactoring or migration to microservices — this hidden coupling is what gets in the way.
Modular Monolith solves this by splitting each module into a separate .csproj project, combined with the internal keyword.
---
The Scenario: Orders Feature
Same as the previous series:
POST /orders — create a new orderGET /orders/{id} — fetch order details---
Project Structure
Each module consists of two .csproj projects:
/src
/Modules
/Orders
Orders.Contracts/ ← public API — anyone may reference this
IOrderService.cs
CreateOrderDto.cs
OrderDto.cs
OrderCreatedEvent.cs
INotificationPort.cs
Orders.Infrastructure/ ← implementation — must not be referenced directly
OrdersService.cs ← internal
OrdersRepository.cs ← internal
Order.cs ← internal
OrdersDbContext.cs ← internal
OrdersRegistration.cs ← public (DI extension method)
/Notifications
Notifications.Contracts/
INotificationService.cs
Notifications.Infrastructure/
NotificationsService.cs ← internal
NotificationAdapter.cs ← public (implements INotificationPort)
NotificationsRegistration.cs
/Products
Products.Contracts/
IProductService.cs
ProductDto.cs
Products.Infrastructure/
ProductsService.cs ← internal
ProductsRegistration.cs
/Shared
/Kernel
BaseEntity.cs
/Events
IEventBus.cs
InProcessEventBus.cs
/Presentation
WebApi/ ← only references *.Contracts and Shared
Program.cs
Project reference rules:
WebApi may reference: all *.Contracts, Shared*.Infrastructure may reference: its own *.Contracts, Shared*.Infrastructure must not reference another module's *.Infrastructure---
The internal Keyword as a Compiler Guard
// Orders.Contracts/IOrderService.cs — PUBLIC
public interface IOrderService
{
Task<OrderDto> CreateOrderAsync(CreateOrderDto dto);
}
// Orders.Infrastructure/OrdersService.cs — INTERNAL
internal sealed class OrdersService : IOrderService
{
private readonly IOrdersRepository _repo;
private readonly INotificationPort _notifier;
internal OrdersService(IOrdersRepository repo, INotificationPort notifier)
{
_repo = repo;
_notifier = notifier;
}
public async Task<OrderDto> CreateOrderAsync(CreateOrderDto dto)
{
var product = await _repo.FindProductByIdAsync(dto.ProductId);
if (product is null || product.Stock < dto.Quantity)
throw new InvalidOperationException("Insufficient stock");
var order = new Order(dto.ProductId, dto.Quantity);
var saved = await _repo.SaveAsync(order);
await _notifier.SendOrderConfirmationAsync(saved.Id);
return new OrderDto(saved.Id, saved.ProductId, saved.Quantity);
}
}
If any other project tries to instantiate OrdersService directly:
// In another project — this will fail at build time
var service = new OrdersService(...); // Error: 'OrdersService' is inaccessible
The compiler rejects it. No code review needed to catch it.
---
Module Registration via Extension Method
Each module exposes a single public extension method for dependency registration — the only controlled entry point into *.Infrastructure.
// Orders.Infrastructure/OrdersRegistration.cs — PUBLIC
public static class OrdersRegistration
{
public static IServiceCollection AddOrdersModule(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddScoped<IOrdersRepository, OrdersRepository>();
services.AddScoped<IOrderService, OrdersService>();
services.AddDbContext<OrdersDbContext>(opts =>
opts.UseNpgsql(configuration.GetConnectionString("Orders")));
return services;
}
}
// WebApi/Program.cs
builder.Services.AddOrdersModule(builder.Configuration);
builder.Services.AddNotificationsModule(builder.Configuration);
builder.Services.AddProductsModule(builder.Configuration);
// cross-module wiring
builder.Services.AddScoped<INotificationPort, NotificationAdapter>();
---
Inter-Module Communication
Pattern 1 — Interface Port
The orders module defines INotificationPort in Orders.Contracts. The notifications module implements it.
// Orders.Contracts/INotificationPort.cs
public interface INotificationPort
{
Task SendOrderConfirmationAsync(string orderId);
}
// Notifications.Infrastructure/NotificationAdapter.cs — PUBLIC
public class NotificationAdapter : INotificationPort
{
private readonly NotificationsService _service;
public NotificationAdapter(NotificationsService service) =>
_service = service;
public async Task SendOrderConfirmationAsync(string orderId)
{
await _service.SendAsync(new NotificationRequest
{
Type = "order_confirmed",
OrderId = orderId
});
}
}
Pattern 2 — In-Process Event Bus via MediatR
// Orders.Contracts/OrderCreatedEvent.cs
public record OrderCreatedEvent(string OrderId, string CustomerEmail) : INotification;
// Orders.Infrastructure/OrdersService.cs
public async Task<OrderDto> CreateOrderAsync(CreateOrderDto dto)
{
var order = new Order(dto.ProductId, dto.Quantity);
var saved = await _repo.SaveAsync(order);
await _mediator.Publish(new OrderCreatedEvent(saved.Id, dto.CustomerEmail));
return new OrderDto(saved.Id, saved.ProductId, saved.Quantity);
}
// Notifications.Infrastructure/OrderCreatedHandler.cs
internal sealed class OrderCreatedHandler : INotificationHandler<OrderCreatedEvent>
{
private readonly NotificationsService _service;
internal OrderCreatedHandler(NotificationsService service) =>
_service = service;
public async Task Handle(OrderCreatedEvent notification, CancellationToken ct)
{
await _service.SendAsync(notification.CustomerEmail, notification.OrderId);
}
}
---
Boundary Enforcement with ArchUnit
While the compiler already blocks access to internal classes, there's another risk: someone accidentally adds a *.Infrastructure project reference to a .csproj that shouldn't have it. ArchUnit catches this as an architecture test.
// ArchitectureTests/ModuleBoundaryTests.cs
public class ModuleBoundaryTests
{
private static readonly Architecture Architecture =
new ArchLoader()
.LoadAssemblies(
Assembly.Load("Orders.Infrastructure"),
Assembly.Load("Notifications.Infrastructure"),
Assembly.Load("Products.Infrastructure"))
.Build();
[Fact]
public void Orders_Must_Not_Depend_On_Notifications_Infrastructure()
{
Types().That().ResideInAssembly("Orders.Infrastructure")
.Should()
.NotDependOnAny(Types().That().ResideInAssembly("Notifications.Infrastructure"))
.Check(Architecture);
}
[Fact]
public void Infrastructure_Projects_Must_Not_Depend_On_Each_Other()
{
var allInfra = Types().That()
.ResideInAssemblyMatching(".*\\.Infrastructure");
allInfra
.Should()
.NotDependOnAny(
Types().That()
.ResideInAssemblyMatching(".*\\.Infrastructure")
.And()
.DoNotResideInAssembly(Assembly.GetExecutingAssembly()))
.Check(Architecture);
}
}
---
Database: Schema per Module with EF Core
// Orders.Infrastructure/OrdersDbContext.cs
internal sealed class OrdersDbContext : DbContext
{
internal DbSet<Order> Orders { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("orders");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrdersDbContext).Assembly);
}
}
// Notifications.Infrastructure/NotificationsDbContext.cs
internal sealed class NotificationsDbContext : DbContext
{
internal DbSet<NotificationLog> Logs { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("notifications");
}
}
One PostgreSQL database, two schemas: orders and notifications. The orders module must not query notifications.* tables directly.
---
Testing in Modular Monolith (.NET)
Unit test within the module
Because OrdersService is internal, the test project needs explicit access:
// Orders.Infrastructure/Orders.Infrastructure.csproj
<ItemGroup>
<InternalsVisibleTo Include="Orders.Tests" />
</ItemGroup>
// Orders.Tests/OrdersServiceTests.cs
public class OrdersServiceTests
{
private readonly Mock<IOrdersRepository> _mockRepo = new();
private readonly Mock<INotificationPort> _mockNotifier = new();
private readonly OrdersService _service;
public OrdersServiceTests()
{
_service = new OrdersService(_mockRepo.Object, _mockNotifier.Object);
}
[Fact]
public async Task CreateOrderAsync_ThrowsWhenStockInsufficient()
{
_mockRepo.Setup(r => r.FindProductByIdAsync("p1"))
.ReturnsAsync(new Product { Stock = 0 });
var act = () => _service.CreateOrderAsync(
new CreateOrderDto { ProductId = "p1", Quantity = 2 });
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("Insufficient stock");
}
[Fact]
public async Task CreateOrderAsync_SendsNotificationAfterSuccess()
{
_mockRepo.Setup(r => r.FindProductByIdAsync("p1"))
.ReturnsAsync(new Product { Stock = 10 });
_mockRepo.Setup(r => r.SaveAsync(It.IsAny<Order>()))
.ReturnsAsync((Order o) => o);
await _service.CreateOrderAsync(
new CreateOrderDto { ProductId = "p1", Quantity = 2 });
_mockNotifier.Verify(
n => n.SendOrderConfirmationAsync(It.IsAny<string>()),
Times.Once);
}
}
Module contract test — test only through the public interface
// Orders.IntegrationTests/OrdersModuleContractTests.cs
public class OrdersModuleContractTests
{
[Fact]
public async Task CreateOrder_ViaPublicIOrderService_Succeeds()
{
var services = new ServiceCollection();
services.AddOrdersModule(new ConfigurationBuilder().Build());
services.AddSingleton<INotificationPort>(Mock.Of<INotificationPort>());
var sp = services.BuildServiceProvider();
// test only through IOrderService — no direct access to OrdersService
var orderService = sp.GetRequiredService<IOrderService>();
var result = await orderService.CreateOrderAsync(
new CreateOrderDto { ProductId = "p1", Quantity = 2 });
result.Id.Should().NotBeNullOrEmpty();
}
}
Architecture test with ArchUnit
Run as part of the regular test suite:
dotnet test --filter "FullyQualifiedName~ArchitectureTests"
If any violation exists — like Orders.Infrastructure suddenly depending on Notifications.Infrastructure — this test fails in CI before the PR can be merged.
---
Comparison
| Aspect | Feature-Based | Modular Monolith (.NET) | Microservices |
|---|---|---|---|
| Module boundary | Folder convention | internal + project separation + ArchUnit | Network + deployment |
| Boundary enforcement | Code review | Compiler + CI | Network by default |
| Deployment | 1 unit | 1 unit | N units |
| Database | Shared | Shared, separate schemas | Separate per service |
| Migration to microservices | Hard | Easy — just split the projects | N/A |
| Contract testing | Not needed | Not needed | Required |
When to Use Modular Monolith in .NET
---
Conclusion
In .NET, Modular Monolith isn't just an architectural choice — it's a technical decision backed by the language and compiler.
The internal keyword ensures implementation can't be accessed from outside the assembly. Project separation ensures inter-module dependencies are explicit and controlled. ArchUnit ensures the rules don't erode over time.
The result: a system that can grow with a large team, while remaining maintainable like a healthy monolith.
---
Closing Thoughts
The right question isn't:
> "When should we move to microservices?"
It's:
> "Are our domain boundaries clear enough to be separated?"
Modular Monolith — especially in .NET with compiler support — helps answer that question long before you have to pay the operational cost of a distributed system.