EngineeringSenior DeveloperTechnical Sessions

Session 14: Modular Monolith Architecture

Mục tiêu: Thiết kế Modular Monolith - bước trung gian tối ưu trước khi chuyển sang Microservices.


1. Tại Sao Modular Monolith?

Monolith (Big Ball of Mud)     →     Modular Monolith        →     Microservices
┌─────────────────────┐      ┌─────────────────────────┐    ┌──────┐ ┌──────┐ ┌──────┐
│  Tất cả tightly     │      │ ┌──────┐ ┌──────┐      │    │Svc A │ │Svc B │ │Svc C │
│  coupled lại        │      │ │Mod A │ │Mod B │      │    │      │ │      │ │      │
│  với nhau           │      │ │      │ │      │      │    │ DB A │ │ DB B │ │ DB C │
│                     │      │ │  DB  │ │  DB  │      │    └──────┘ └──────┘ └──────┘
│         DB          │      │ └──────┘ └──────┘      │
└─────────────────────┘      │     Shared hosting      │
                             │     1 deployment        │
                             └─────────────────────────┘

✅ Modular Monolith cho bạn:
- Boundaries rõ ràng giữa modules (dễ hiểu, dễ maintain)
- 1 deployment unit (simple ops)
- Tốc độ development như monolith
- Dễ extract thành microservice SAU NÀY khi cần
- Transaction đơn giản (same process)
- Không cần distributed tracing, service mesh, etc.

⚠️ Microservices chỉ nên dùng khi:
- Team quá lớn (>20 devs, cần deploy independent)
- Scale requirements RẤT khác nhau giữa modules
- Technology diversity (module X dùng Python, module Y dùng .NET)

2. Module Structure

src/
├── Bootstrapper/                        ← Host application
│   ├── Program.cs                       ← DI registration cho tất cả modules
│   ├── appsettings.json
│   └── Bootstrapper.csproj              ← Reference tất cả modules
│
├── Modules/
│   ├── Orders/
│   │   ├── Orders.Application/
│   │   │   ├── Commands/
│   │   │   ├── Queries/
│   │   │   ├── IntegrationEvents/       ← Events gửi cho modules khác
│   │   │   └── ModuleInstaller.cs       ← DI cho module này
│   │   ├── Orders.Domain/
│   │   │   ├── Entities/
│   │   │   └── Interfaces/
│   │   ├── Orders.Infrastructure/
│   │   │   ├── Persistence/
│   │   │   │   ├── OrdersDbContext.cs   ← DB Context RIÊNG
│   │   │   │   └── Migrations/
│   │   │   └── ModuleInstaller.cs
│   │   └── Orders.Api/
│   │       ├── Controllers/
│   │       └── ModuleInstaller.cs
│   │
│   ├── Catalog/
│   │   ├── Catalog.Application/
│   │   ├── Catalog.Domain/
│   │   ├── Catalog.Infrastructure/
│   │   └── Catalog.Api/
│   │
│   └── Shipping/
│       └── ...
│
└── Shared/
    ├── Shared.Abstractions/             ← Interfaces chung
    │   ├── IIntegrationEvent.cs
    │   ├── IModuleInstaller.cs
    │   └── IEventBus.cs
    └── Shared.Infrastructure/           ← Implementation chung
        ├── InMemoryEventBus.cs
        └── Outbox/

3. Module Boundaries & Contracts

Rule: Modules Communicate qua Contracts, KHÔNG Reference Trực Tiếp

csharp
// ❌ BAD: Orders module reference trực tiếp Catalog domain
using Catalog.Domain.Entities;

public class OrderService
{
    public void CreateOrder(Product product) // 💥 Tight coupling!
    {
        // ...
    }
}

// ✅ GOOD: Communication qua contracts
// Shared/Shared.Abstractions/Modules/Catalog/
public interface ICatalogModule
{
    Task<ProductDto?> GetProductAsync(Guid productId);
    Task<bool> ReserveStockAsync(Guid productId, int quantity);
}

// Catalog module implements contract:
internal class CatalogModule(CatalogDbContext db) : ICatalogModule
{
    public async Task<ProductDto?> GetProductAsync(Guid productId)
    {
        var product = await db.Products.FindAsync(productId);
        return product?.ToDto();
    }

    public async Task<bool> ReserveStockAsync(Guid productId, int quantity)
    {
        var product = await db.Products.FindAsync(productId);
        if (product is null || product.Stock < quantity) return false;
        product.ReserveStock(quantity);
        await db.SaveChangesAsync();
        return true;
    }
}

// Orders module consumes contract:
public class CreateOrderHandler(ICatalogModule catalogModule)
{
    public async Task<Result<Guid>> Handle(CreateOrderCommand cmd, CancellationToken ct)
    {
        var product = await catalogModule.GetProductAsync(cmd.ProductId);
        if (product is null) return Result.Failure<Guid>("Product not found");

        var reserved = await catalogModule.ReserveStockAsync(cmd.ProductId, cmd.Quantity);
        if (!reserved) return Result.Failure<Guid>("Insufficient stock");

        // Create order...
    }
}

4. Integration Events - Async Communication

csharp
// Shared event interface
public interface IIntegrationEvent
{
    Guid Id { get; }
    DateTime OccurredOn { get; }
}

// Event definition (trong Shared hoặc publisher module)
public record OrderSubmittedIntegrationEvent(
    Guid OrderId,
    Guid CustomerId,
    decimal TotalAmount,
    List<OrderItemEvent> Items) : IIntegrationEvent
{
    public Guid Id { get; } = Guid.NewGuid();
    public DateTime OccurredOn { get; } = DateTime.UtcNow;
}

// In-Process Event Bus (Modular Monolith)
public class InMemoryEventBus(IServiceProvider serviceProvider) : IEventBus
{
    public async Task PublishAsync<T>(T @event, CancellationToken ct = default)
        where T : IIntegrationEvent
    {
        using var scope = serviceProvider.CreateScope();
        var handlers = scope.ServiceProvider.GetServices<IIntegrationEventHandler<T>>();

        foreach (var handler in handlers)
        {
            try
            {
                await handler.HandleAsync(@event, ct);
            }
            catch (Exception ex)
            {
                // Log error, optionally retry or send to dead letter
                // QUAN TRỌNG: 1 handler fail KHÔNG ảnh hưởng handlers khác
            }
        }
    }
}

// Handler trong Shipping module:
internal class OrderSubmittedHandler(ShippingDbContext db)
    : IIntegrationEventHandler<OrderSubmittedIntegrationEvent>
{
    public async Task HandleAsync(OrderSubmittedIntegrationEvent evt, CancellationToken ct)
    {
        var shipment = Shipment.CreateFromOrder(evt.OrderId, evt.Items);
        db.Shipments.Add(shipment);
        await db.SaveChangesAsync(ct);
    }
}

5. Database per Module

csharp
// Mỗi module có DbContext RIÊNG
// Cùng 1 SQL Server instance, nhưng schema/prefix khác nhau

// Orders module
public class OrdersDbContext : DbContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("orders"); // Schema prefix
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrdersDbContext).Assembly);
    }
}

// Catalog module
public class CatalogDbContext : DbContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("catalog");
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly);
    }
}

// SQL tables:
// [orders].[Orders]
// [orders].[OrderItems]
// [catalog].[Products]
// [catalog].[Categories]

// ⚠️ KHÔNG cross-schema joins!
// Modules communicate qua contracts hoặc integration events

6. Evolution: Monolith → Modular → Microservices

Step 1: Monolith (start here)
  - 1 project, 1 DB, 1 deployment
  - Nhanh để develop MVP

Step 2: Modular Monolith (khi codebase lớn lên)
  - Tách modules với clear boundaries
  - Database schemas riêng
  - In-process event bus
  - VẪN 1 deployment

Step 3: Extract Microservice (chỉ khi cần)
  - Module nào cần scale riêng → extract thành service
  - Thay InMemoryEventBus → RabbitMQ/Azure Service Bus
  - Thay ICatalogModule call → gRPC/HTTP call
  - Module contract KHÔNG đổi → extraction smooth

Ví dụ thực tế:
  - Orders module: 80% traffic → Extract để scale riêng
  - Catalog module: ít traffic → Giữ trong monolith
  - Notification module: burst traffic → Extract + Azure Functions
csharp
// Khi extract: chỉ đổi implementation, interface giữ nguyên
// Trước (in-process):
internal class CatalogModule : ICatalogModule
{
    public Task<ProductDto?> GetProductAsync(Guid id)
        => _db.Products.FindAsync(id).Map(p => p.ToDto());
}

// Sau (HTTP call to microservice):
internal class CatalogModuleClient(HttpClient httpClient) : ICatalogModule
{
    public async Task<ProductDto?> GetProductAsync(Guid id)
    {
        var response = await httpClient.GetAsync($"/api/products/{id}");
        if (!response.IsSuccessStatusCode) return null;
        return await response.Content.ReadFromJsonAsync<ProductDto>();
    }
}
// → KHÔNG CẦN sửa OrderService vì nó depend vào ICatalogModule interface!

Tổng Kết Session 14

ConceptKey Takeaway
Modular MonolithBest of both: boundaries + simple deployment
Module ContractsInterface-based communication, KHÔNG direct reference
Integration EventsAsync, in-process event bus
DB per ModuleSchema separation, no cross-schema joins
Evolution PathMonolith → Modular → Extract microservice khi cần

Bài tập: Tách 1 monolith project thành 2 modules (Orders, Catalog) với schema riêng, in-process event bus, và module contracts. Verify cross-module communication hoạt động.