Home / Blog / Engineering
Engineering

Modular Monolith in Practice: Enforced Module Boundaries (C# / .NET)

Building the Orders feature using Modular Monolith with C# and ASP.NET Core — project separation, the internal keyword, ArchUnit for architecture tests, and the right testing strategy.

Yudi Nugraha
May 3, 2026
8 min read

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 order
  • GET /orders/{id} — fetch order details
  • Validation: product stock must be available
  • Notification: send email after successful order
  • ---

    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

    AspectFeature-BasedModular Monolith (.NET)Microservices
    Module boundaryFolder conventioninternal + project separation + ArchUnitNetwork + deployment
    Boundary enforcementCode reviewCompiler + CINetwork by default
    Deployment1 unit1 unitN units
    DatabaseSharedShared, separate schemasSeparate per service
    Migration to microservicesHardEasy — just split the projectsN/A
    Contract testingNot neededNot neededRequired
    ---

    When to Use Modular Monolith in .NET

  • .NET teams that want stronger enforcement than folder conventions
  • Enterprise systems with multiple business domains that need strict separation
  • Applications planned to eventually migrate to microservices — project separation makes that split easier
  • Teams of 10–30 developers who still want a single deployment
  • ---

    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.

    Tags

    Software EngineeringArchitectureC#dotNETTesting
    Y

    Yudi Nugraha

    Software Engineer | Builder

    More Articles

    Explore more articles on similar topics

    View All Articles