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 baruGET /orders/{id} — ambil detail order---
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
| Aspek | Feature-Based | Modular Monolith (.NET) | Microservices |
|---|---|---|---|
| Batas modul | Konvensi folder | internal + project separation + ArchUnit | Network + deployment |
| Penegakan boundary | Code review | Compiler + CI | Network by default |
| Deployment | 1 unit | 1 unit | N unit |
| Database | Shared | Shared, schema terpisah | Terpisah per service |
| Migrasi ke microservices | Sulit | Mudah — tinggal pisah project | N/A |
| Contract testing | Tidak perlu | Tidak perlu | Wajib |
Kapan Menggunakan Modular Monolith di .NET
---
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.