Beranda / Blog / Engineering
Engineering

Modular Monolith dalam Praktik: Batas Modul yang Ditegakkan (C#/.NET)

Membangun fitur Orders menggunakan Modular Monolith dengan C# dan ASP.NET Core — project separation, internal keyword, ArchUnit untuk architecture test, dan strategi testing yang tepat.

Yudi Nugraha
3 Mei 2026
7 menit baca

Di post sebelumnya, kita membangun fitur Orders menggunakan lima arsitektur — dari Simple MVC hingga Microservices. Setiap arsitektur diuji dan dibandingkan secara langsung.

Tapi ada satu arsitektur yang belum dibahas: Modular Monolith.

Di .NET, Modular Monolith memiliki keunggulan unik: batas antar modul bisa ditegakkan pada level compiler menggunakan keyword internal dan project separation. Bukan hanya konvensi, bukan hanya ESLint — tapi error build jika ada yang melanggar aturan.

---

Masalah yang Dipecahkan

Di Feature-Based Structure, kita bisa menulis ini dan compiler tidak akan komplain:

// Features/Orders/OrdersService.cs
using Features.Notifications; // langsung ke internal modul lain

public class OrdersService
{
    private readonly NotificationsService _notifications; // coupling tersembunyi
}

Lambat laun, modul orders tahu terlalu banyak tentang internal modul notifications. Saat ingin memisahkan keduanya — entah untuk refactoring atau migrasi ke microservices — coupling ini yang jadi hambatan.

Modular Monolith memecahkan ini dengan memisahkan setiap modul menjadi project .csproj yang berbeda, dikombinasikan dengan keyword internal.

---

Skenario: Fitur Orders

Sama seperti seri sebelumnya:

  • POST /orders — buat order baru
  • GET /orders/{id} — ambil detail order
  • Validasi: stok produk harus tersedia
  • Notifikasi: kirim email setelah order berhasil
  • ---

    Struktur Project

    Setiap modul terdiri dari dua project .csproj:

    /src
      /Modules
        /Orders
          Orders.Contracts/             ← public API — boleh direferensikan siapa saja
            IOrderService.cs
            CreateOrderDto.cs
            OrderDto.cs
            OrderCreatedEvent.cs
            INotificationPort.cs
          Orders.Infrastructure/        ← implementasi — tidak boleh direferensikan langsung
            OrdersService.cs            ← internal
            OrdersRepository.cs         ← internal
            Order.cs                    ← internal
            OrdersDbContext.cs          ← internal
            OrdersRegistration.cs       ← public (extension method untuk DI)
        /Notifications
          Notifications.Contracts/
            INotificationService.cs
          Notifications.Infrastructure/
            NotificationsService.cs     ← internal
            NotificationAdapter.cs      ← public (implementasi 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/                         ← hanya referensikan *.Contracts dan Shared
          Program.cs
    

    Aturan referensi project:

  • WebApi boleh me-reference: semua *.Contracts, Shared
  • *.Infrastructure boleh me-reference: *.Contracts miliknya sendiri, Shared
  • *.Infrastructure tidak boleh me-reference *.Infrastructure modul lain
  • ---

    Keyword internal sebagai 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);
        }
    }
    

    Jika ada project lain yang mencoba menginstansiasi OrdersService secara langsung:

    // Di project lain — ini akan gagal saat build
    var service = new OrdersService(...); // Error: 'OrdersService' is inaccessible
    

    Compiler menolak. Tidak perlu code review untuk menangkapnya.

    ---

    Registrasi Modul via Extension Method

    Setiap modul mengekspos satu extension method publik untuk registrasi dependency — ini satu-satunya titik masuk ke *.Infrastructure.

    // Orders.Infrastructure/OrdersRegistration.cs — PUBLIC
    public static class OrdersRegistration
    {
        public static IServiceCollection AddOrdersModule(
            this IServiceCollection services,
            IConfiguration configuration)
        {
            // register semua internal services
            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);
    
    // wiring antar modul
    builder.Services.AddScoped<INotificationPort, NotificationAdapter>();
    

    ---

    Komunikasi Antar Modul

    Pola 1 — Interface Port

    orders module mendefinisikan INotificationPort di Orders.Contracts. Modul notifications mengimplementasikannya.

    // 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
            });
        }
    }
    

    Pola 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);
        }
    }
    

    ---

    Penegakan Batas dengan ArchUnit

    Meskipun compiler sudah memblokir internal class, ada risiko lain: seseorang menambahkan referensi *.Infrastructure project ke .csproj yang seharusnya tidak boleh. ArchUnit menangkap ini sebagai 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 Notifications_Must_Not_Depend_On_Orders_Infrastructure()
        {
            Types().That().ResideInAssembly("Notifications.Infrastructure")
                .Should()
                .NotDependOnAny(Types().That().ResideInAssembly("Orders.Infrastructure"))
                .Check(Architecture);
        }
    
        [Fact]
        public void Infrastructure_Projects_Must_Only_Depend_On_Their_Own_Contracts()
        {
            var allInfrastructure = Types().That()
                .ResideInAssemblyMatching(".*\\.Infrastructure");
    
            allInfrastructure
                .Should()
                .NotDependOnAny(
                    Types().That()
                        .ResideInAssemblyMatching(".*\\.Infrastructure")
                        .And()
                        .DoNotResideInAssembly(Assembly.GetExecutingAssembly()))
                .Check(Architecture);
        }
    }
    

    ---

    Database: Schema per Modul dengan 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"); // semua tabel di schema "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");
        }
    }
    

    Di database: tabel orders.orders, orders.order_items untuk modul Orders; tabel notifications.logs untuk modul Notifications. Satu database, schema terpisah per modul.

    ---

    Testing di Modular Monolith (.NET)

    Unit test dalam modul

    Karena OrdersService adalah internal, test project butuh akses khusus:

    // 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 lewat 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 hanya lewat IOrderService — tidak ada akses ke OrdersService internal
            var orderService = sp.GetRequiredService<IOrderService>();
            var result = await orderService.CreateOrderAsync(
                new CreateOrderDto { ProductId = "p1", Quantity = 2 });
    
            result.Id.Should().NotBeNullOrEmpty();
        }
    }
    

    Architecture test dengan ArchUnit

    Dijalankan sebagai bagian dari test suite reguler:

    dotnet test --filter "FullyQualifiedName~ArchitectureTests"
    

    Jika ada violation — misalnya Orders.Infrastructure tiba-tiba bergantung pada Notifications.Infrastructure — test ini akan gagal di CI sebelum PR bisa di-merge.

    ---

    Perbandingan

    AspekFeature-BasedModular Monolith (.NET)Microservices
    Batas modulKonvensi folderinternal + project separation + ArchUnitNetwork + deployment
    Penegakan boundaryCode reviewCompiler + CINetwork by default
    Deployment1 unit1 unitN unit
    DatabaseSharedShared, schema terpisahTerpisah per service
    Migrasi ke microservicesSulitMudah — tinggal pisah projectN/A
    Contract testingTidak perluTidak perluWajib
    ---

    Kapan Menggunakan Modular Monolith di .NET

  • Tim .NET yang ingin enforcement yang lebih kuat dari konvensi folder
  • Sistem enterprise dengan banyak domain bisnis yang perlu dipisahkan secara ketat
  • Aplikasi yang direncanakan akan dipecah ke microservices — project separation memudahkan pemisahan nantinya
  • Tim dengan 10–30 developer yang masih ingin single deployment
  • ---

    Kesimpulan

    Di .NET, Modular Monolith bukan sekadar pendekatan arsitektur — ia adalah keputusan teknis yang didukung oleh language dan compiler.

    Keyword internal memastikan implementasi tidak bisa diakses dari luar assembly. Project separation memastikan dependency antar modul eksplisit dan terkontrol. ArchUnit memastikan aturan ini tidak erosi seiring waktu.

    Hasilnya: sistem yang bisa tumbuh dengan tim yang besar, tapi tetap bisa di-maintain seperti monolith yang sehat.

    ---

    Penutup

    Pertanyaan yang tepat bukan:

    > "Kapan kita harus beralih ke microservices?"

    Tapi:

    > "Apakah batas-batas domain bisnis kita sudah cukup jelas untuk dipisahkan?"

    Modular Monolith — terutama di .NET dengan dukungan compiler — membantu menjawab pertanyaan itu jauh sebelum kamu harus menanggung biaya operasional distributed system.

    Tag

    Software EngineeringArchitectureC#dotNETTesting
    Y

    Yudi Nugraha

    Software Engineer | Builder

    Artikel Lainnya

    Jelajahi lebih banyak artikel dengan topik serupa

    Lihat Semua Artikel