Modular Monolith với .NET 10 - Kiến trúc trung dung giữa Monolith và Microservices với Vertical Slice, Wolverine và Bounded Context

Posted on: 4/16/2026 10:09:05 PM

1. Con lắc đang dao động về phía Modular Monolith

Từ năm 2014, khi Sam Newman xuất bản cuốn Building Microservices, ngành phần mềm đã đặt cược lớn vào một ý tưởng: chia hệ thống thành hàng chục service nhỏ độc lập để dễ scale, dễ deploy, dễ phát triển song song. Mười năm sau, nhiều kỹ sư đã tỉnh giấc giữa đống Kubernetes YAML, distributed tracing, saga bị lỗi giữa chừng, latency từ một request HTTP biến thành sáu hop mạng, và những cuộc họp tuần nói về consistency model. Câu hỏi đặt lại không phải "monolith hay microservices" mà là "khi nào thực sự cần microservices". Và câu trả lời thực tế với đa số sản phẩm có đội dưới 30 kỹ sư là: chưa cần — hãy bắt đầu bằng một modular monolith được thiết kế tử tế.

Shopify, Stack Overflow, GitHub, Basecamp là những minh chứng quen thuộc cho việc một monolith tốt có thể phục vụ lượng user cực lớn. Sang 2026, con lắc đã dao động rõ: Amazon Prime Video công khai chuyển dịch vụ audio/video monitoring từ microservices về monolith để giảm 90% chi phí, DHH tiếp tục đẩy mạnh "majestic monolith", .NET community đón nhận các template modular monolith chính thức từ Microsoft. Modular Monolith không phải là sự thỏa hiệp — đó là lựa chọn kiến trúc có chủ đích, giữ được tính đơn giản trong vận hành mà vẫn bảo toàn ranh giới domain.

Bài viết này đi sâu vào Modular Monolith dưới lăng kính .NET 10: nguyên lý thiết kế, cách tổ chức bounded context thành module, giao tiếp in-process với Wolverine và MediatR, cô lập persistence theo schema, chiến lược test, và đặc biệt là con đường migration hai chiều — vừa từ legacy monolith sang modular, vừa từ modular lên microservices khi thật sự cần.

90%Chi phí Amazon Prime Video tiết kiệm khi chuyển về monolith
1Process, 1 deployment, nhưng N module có ranh giới rõ ràng
0msLatency giữa các module khi gọi in-process qua Wolverine
.NET 10LTS nền tảng lý tưởng cho Modular Monolith 2026

2. Cái bẫy của microservices áp dụng từ sớm

Microservices giải quyết ba bài toán rất cụ thể: scale độc lập từng bounded context có pattern tải khác nhau, cho phép nhiều team lớn deploy song song mà không dẫm chân, và cô lập sự cố trong một service không lan sang service khác. Khi tổ chức chưa đạt điều kiện đó — team nhỏ, deploy pipeline chung, domain chưa ổn định — chia sớm thành services sẽ trả giá đắt bởi các chi phí ẩn mà monolith không phải chịu.

Năm lớp chi phí ẩn của microservices sớm

Network reliability: mọi call giờ có thể fail, phải retry, circuit breaker, timeout. Distributed transaction: saga, outbox, eventual consistency thay vì một BEGIN TRAN. Observability: distributed tracing, correlation id xuyên hop. Schema evolution: đổi model phải versioning API backwards compatible. Operational overhead: Kubernetes, service mesh, CI/CD per service, secrets rotation nhân lên theo số service.

Với một team 10 người đang build SaaS B2B, năm chi phí trên có thể ngốn 40% thời lượng kỹ sư mà không tạo ra giá trị nghiệp vụ. Modular monolith đánh đổi tính độc lập khi deploy lấy sự đơn giản khi phát triển: deploy một file duy nhất, debug được trong một process, transaction ACID trên một database, log tập trung, latency giữa module bằng không.

Điều quan trọng: monolith không có nghĩa là spaghetti. Một monolith tồi là khi code từ mọi domain trộn lẫn trong cùng namespace, reference vòng, dùng chung DbContext khổng lồ, thay đổi một nơi vỡ ba nơi. Một modular monolith tốt áp dụng cùng các nguyên lý Domain-Driven Design giống microservices — chỉ khác là ranh giới được bảo vệ bằng compilerquy ước project thay vì bằng network.

3. Biên niên sử: từ N-tier đến Modular Monolith

2003 — Domain-Driven Design
Eric Evans công bố cuốn sách "xanh", giới thiệu Bounded Context, Aggregate, Ubiquitous Language. Đây là nền tảng tư tưởng cho mọi mô hình chia hệ thống sau này.
2006-2010 — N-tier Monolith
Kiến trúc phổ biến: Presentation → Business → Data Access → Database. Chia theo tầng kỹ thuật chứ không theo domain, dẫn tới mỗi feature chạm đủ ba tầng và dễ vỡ lan tỏa.
2012 — Onion / Clean Architecture
Jeffrey Palermo và Uncle Bob phổ cập ý tưởng domain là trung tâm, phụ thuộc hướng vào trong. Vẫn chia theo tầng nhưng bảo vệ domain khỏi framework. Giới hạn: không quy định cách chia module theo domain.
2014 — Microservices bùng nổ
Sam Newman, Netflix OSS, Spring Cloud, Docker. Ngành công nghiệp tin rằng chia nhỏ = tốt hơn. Nhiều team chia sớm, trả giá bằng "distributed monolith".
2016 — Vertical Slice Architecture
Jimmy Bogard đề xuất chia hệ thống theo "lát cắt" ứng với use case chứ không theo tầng. Mỗi slice cầm cả presentation, business, data của riêng mình. Đây là bước đệm kỹ thuật quan trọng cho modular monolith.
2019 — Modular Monolith tái xuất
Kamil Grzybek, Jimmy Bogard, David Fowler cùng phổ cập khái niệm: monolith có module rõ ràng, có ranh giới, giao tiếp qua message in-process. EventStoreDB, Modular Monolith Sample của Kamil trở thành reference.
2023 — Amazon Prime Video khai tử một dịch vụ microservices
Bài post công khai của team Prime Video về việc chuyển audio/video monitoring về monolith, tiết kiệm 90% chi phí. Cộng đồng kỹ sư coi đây là dấu mốc đảo chiều xu hướng.
2024 — .NET 9 + Wolverine + FastEndpoints
Hệ sinh thái .NET có đầy đủ mảnh ghép: Wolverine cho in-process messaging có persistence, FastEndpoints cho vertical slice, .NET Aspire orchestration, EF Core interceptors. Modular monolith trở thành mặc định cho dự án mới.
2025 Q4 — .NET 10 LTS
.NET 10 LTS phát hành: Native AOT hoàn thiện cho ASP.NET Core, keyed services production-ready, Result pattern chuẩn hóa, Primary constructors trên class, Assembly-level metadata cải thiện. Nền tảng chín muồi cho modular monolith nhiều assembly.
2026 — Modular Monolith là default
Microsoft Learn đưa modular monolith làm template chính cho dự án business. Cộng đồng chuyển hướng mạnh từ "microservices-first" sang "monolith-first, chia khi cần".

4. Modular Monolith là gì — định nghĩa chính xác

Modular Monolith là một hệ thống được triển khai như một đơn vị deploy duy nhất (một process, một binary, một container) nhưng bên trong được cấu thành từ nhiều module độc lập về logic, mỗi module tương ứng với một Bounded Context, có API công khai rõ ràng và không truy cập trực tiếp vào nội bộ của module khác. Ba đặc điểm bắt buộc:

  • High cohesion bên trong module: mọi code của một domain (entity, use case, persistence, validation, event) nằm cùng chỗ, không rải ra các tầng kỹ thuật.
  • Loose coupling giữa các module: module chỉ biết về API công khai của module khác qua contract (interface, message), không reference trực tiếp class nội bộ, không join SQL qua lãnh thổ của nhau.
  • Ranh giới được bảo vệ bằng compiler: tách thành nhiều project csproj, chỉ expose những type public cần thiết, dùng InternalsVisibleTo có chọn lọc, tối đa một project "Host" duy nhất reference tất cả module.

Điểm phân biệt với microservices: modular monolith chạy trong một process. Gọi giữa module là gọi hàm in-process, không có mạng, không có serialization JSON, không có timeout. Database có thể là một instance SQL Server duy nhất — nhưng từng module sở hữu schema riêng để giữ ranh giới dữ liệu.

graph TB
    subgraph HOST["Host ASP.NET Core (.NET 10) - 1 process, 1 deploy"]
        subgraph M1["Module Ordering"]
            O1["Endpoints"]
            O2["Commands/Queries"]
            O3["Domain"]
            O4["Persistence"]
        end
        subgraph M2["Module Billing"]
            B1["Endpoints"]
            B2["Commands/Queries"]
            B3["Domain"]
            B4["Persistence"]
        end
        subgraph M3["Module Catalog"]
            C1["Endpoints"]
            C2["Commands/Queries"]
            C3["Domain"]
            C4["Persistence"]
        end
        BUS[Wolverine Bus in-process]
        O2 --> BUS
        B2 --> BUS
        C2 --> BUS
        BUS --> B2
        BUS --> O2
    end
    DB[(SQL Server - schemas: ordering, billing, catalog)]
    O4 --> DB
    B4 --> DB
    C4 --> DB
Một process, nhiều module có ranh giới rõ, giao tiếp qua message bus in-process

5. Nguyên lý thiết kế: Bounded Context và Public API

Module đầu tiên và quan trọng nhất cần xác định là ranh giới. Một lỗi hay gặp là chia theo "entity" (Module User, Module Product) — đó là chia theo dữ liệu chứ không phải theo nghiệp vụ. Cách chia đúng là theo bounded context: đơn vị tổ chức nơi một khái niệm có cùng một nghĩa. Một Customer trong context Billing là pháp nhân có hoá đơn; cũng cùng người đó, trong context Support là một ticket reporter. Hai khái niệm giống tên nhưng nội hàm khác.

Sau khi xác định bounded context, mỗi module phát hành một Public Contract — tập hợp các type mà module khác được phép biết. Thường gồm ba loại:

Loại contractMục đíchVí dụ
CommandsYêu cầu thay đổi trạng thái, đồng bộ, expect responsePlaceOrder, IssueInvoice
QueriesLấy dữ liệu read-only, đồng bộ, không side-effectGetOrderById, GetCustomerBalance
Integration EventsPhát tin sau khi hoàn tất nghiệp vụ, bất đồng bộ, không chờOrderPlaced, InvoiceIssued

Các type này đặt trong project {Module}.Contracts — public và rất mỏng. Mọi thứ còn lại (aggregate, value object, repository, handler, DbContext) ở trong {Module}.Domain, {Module}.Application, {Module}.Infrastructure và đánh dấu internal. Quy tắc cứng: chỉ Host và chính module đó được phép reference non-Contracts assembly.

Vì sao phải tách Contracts project riêng

Nếu Contracts nằm cùng Domain, khi Module Billing muốn gửi IssueInvoiceCommand tới Module Ordering, nó buộc phải reference toàn bộ Ordering.Domain để lấy một DTO. Điều đó kéo theo mọi entity nội bộ của Ordering vào Billing — ranh giới sụp đổ. Contracts riêng là "hộ chiếu" mỏng mà module khác cầm theo.

6. Vertical Slice Architecture bên trong mỗi module

Trong ngành .NET, Clean Architecture (Onion) từng được xem là chuẩn mực: Domain ở giữa, Application bao quanh, Infrastructure và Presentation ở ngoài, phụ thuộc một chiều. Cấu trúc này tốt về lý thuyết nhưng tạo ra ba vấn đề thực tế khi áp cho module có hàng trăm use case:

  • Mỗi feature phải sửa file ở bốn tầng khác nhau — navigation kém, PR review dài.
  • Interface abstraction nhiều khi không cần (IUserRepository chỉ có một implementation duy nhất).
  • Domain model bị ép thành "anemic" vì phần logic thực chất nằm ở service/handler.

Vertical Slice Architecture (Jimmy Bogard) chia ngược lại: mỗi use case là một lát cắt dọc đi từ endpoint tới database. Một feature = một folder = một vài file. Các lát có thể chia sẻ Domain layer nếu cần, nhưng không bị ép phải đi qua tầng "Application Services" chung.

graph LR
    subgraph CLEAN["Clean Architecture"]
        C_P[Presentation]
        C_A[Application Services]
        C_D[Domain]
        C_I[Infrastructure]
        C_P --> C_A
        C_A --> C_D
        C_I --> C_D
    end
    subgraph VSA["Vertical Slice"]
        V1["Slice: PlaceOrder
(endpoint + handler + validator + persistence)"] V2["Slice: CancelOrder
(endpoint + handler + validator + persistence)"] V3["Slice: GetOrders
(endpoint + handler + query)"] VD[Shared Domain + Infrastructure primitives] V1 -. chia sẻ .-> VD V2 -. chia sẻ .-> VD V3 -. chia sẻ .-> VD end
Clean Architecture chia ngang theo tầng, Vertical Slice chia dọc theo use case

Khi kết hợp Modular Monolith với Vertical Slice, ta được cấu trúc hai cấp: cấp ngoài là module theo bounded context, cấp trong mỗi module là slice theo use case. Cấu trúc này scale đều từ 5 slice lên 500 slice mà không phá kiến trúc.

7. Tổ chức project .NET 10 trong thực tế

Đây là bố cục đã chứng minh hiệu quả trên nhiều dự án production. Giả sử hệ thống SaaS có ba module: Ordering, Billing, Catalog.

src/
├── Host/
│   └── App.Host.csproj              (ASP.NET Core entry, reference tất cả module)
├── Modules/
│   ├── Ordering/
│   │   ├── App.Ordering.Contracts.csproj    (public commands/queries/events)
│   │   ├── App.Ordering.Domain.csproj       (internal, không ref gì)
│   │   ├── App.Ordering.Application.csproj  (internal, ref Domain + Contracts)
│   │   └── App.Ordering.Infrastructure.csproj (internal, ref Application + EF)
│   ├── Billing/
│   │   └── ... (cùng cấu trúc)
│   └── Catalog/
│       └── ... (cùng cấu trúc)
├── BuildingBlocks/
│   ├── App.Bus.csproj               (Wolverine abstraction)
│   ├── App.Abstractions.csproj      (Result, DomainEvent, IUnitOfWork)
│   └── App.Observability.csproj     (OpenTelemetry helpers)
└── Tests/
    ├── Ordering.Tests/
    ├── Billing.Tests/
    └── Architecture.Tests/          (NetArchTest - bảo vệ ranh giới)

Vài quy tắc cứng áp dụng trong mỗi csproj:

  • Project {Module}.Domain không reference bất kỳ thứ gì ngoài .NET BCL và BuildingBlocks.Abstractions. Không EF, không ASP.NET.
  • Project {Module}.Contracts chỉ chứa record/interface, cũng không reference Domain.
  • Project Host là nơi duy nhất reference tất cả {Module}.Infrastructure để đăng ký DI.
  • Mỗi module có file ModuleExtensions.cs với AddOrderingModule(IServiceCollection).

Bảo vệ ranh giới bằng ArchTest

Đừng chỉ dựa vào review. Viết một test duy nhất chạy trên CI bằng NetArchTest: "Ordering.Domain không được reference Billing.*", "Ordering.Infrastructure không được reference Catalog.*", "Chỉ Host được reference Infrastructure". Một test bảo vệ cả kiến trúc.

8. Giao tiếp giữa module với Wolverine và MediatR

In-process messaging là xương sống của modular monolith. Có hai công cụ chính trong hệ sinh thái .NET:

  • MediatR: thư viện nhẹ của Jimmy Bogard, chỉ làm dispatcher trong cùng process. Lý tưởng cho commands/queries đồng bộ.
  • Wolverine: framework messaging đầy đủ của Jeremy D. Miller, hỗ trợ cả in-process handler lẫn transport ngoài (Rabbit, Kafka, Azure Service Bus), có transactional outbox tích hợp với EF Core. Lý tưởng cho integration events bất đồng bộ.

Trong modular monolith production, công thức hay dùng là: MediatR cho trong module (slice-to-slice), Wolverine cho giữa các module (integration events có outbox). Ví dụ minh hoạ:

// Module Ordering - Application layer, sau khi lưu DB
public sealed class PlaceOrderHandler(
    OrderingDbContext db,
    IMessageBus bus) : IRequestHandler<PlaceOrder, Result<OrderId>>
{
    public async Task<Result<OrderId>> Handle(PlaceOrder cmd, CancellationToken ct)
    {
        var order = Order.Create(cmd.CustomerId, cmd.Items);

        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);

        // Integration event — Wolverine ghi vào outbox trong cùng transaction
        await bus.PublishAsync(new OrderPlaced(
            order.Id, order.CustomerId, order.Total));

        return order.Id;
    }
}

// Module Billing - handler, nằm ở project khác, kết nối qua Wolverine
public sealed class OnOrderPlacedHandler
{
    public async Task Handle(
        OrderPlaced evt,
        BillingDbContext db,
        CancellationToken ct)
    {
        var invoice = Invoice.For(evt.OrderId, evt.CustomerId, evt.Total);
        db.Invoices.Add(invoice);
        await db.SaveChangesAsync(ct);
    }
}

Key point: OrderPlaced nằm trong Ordering.Contracts — project duy nhất mà Billing reference. Billing không biết gì về Order aggregate, OrderingDbContext, hay internal validator của Ordering. Khi Ordering thay đổi cấu trúc nội bộ, Billing không cần build lại.

sequenceDiagram
    autonumber
    participant API as API Endpoint
    participant OH as Ordering Handler
    participant DB as SQL Server
    participant OB as Wolverine Outbox
    participant BH as Billing Handler

    API->>OH: PlaceOrder command
    OH->>DB: BEGIN TRAN
    OH->>DB: Insert Order (schema ordering)
    OH->>OB: Enqueue OrderPlaced
    OH->>DB: COMMIT TRAN
    Note over DB,OB: Order + outbox row atomic
    OB->>BH: Dispatch OrderPlaced (in-process)
    BH->>DB: Insert Invoice (schema billing)
    BH-->>OB: Ack
Flow end-to-end: commit order và enqueue event atomic qua transactional outbox của Wolverine

9. Persistence theo module — một database, nhiều schema

Một câu hỏi lớn: mỗi module có database riêng không? Câu trả lời thực tế cho modular monolith là không cần. Dùng chung một instance SQL Server, PostgreSQL, hoặc SQL Azure, nhưng mỗi module sở hữu một schema riêng:

  • Schema ordering: Orders, OrderItems, OrderHistory.
  • Schema billing: Invoices, Payments, Refunds.
  • Schema catalog: Products, Categories.

Mỗi module có DbContext riêng config MigrationsHistoryTable riêng schema, HasDefaultSchema riêng:

public sealed class OrderingDbContext(DbContextOptions<OrderingDbContext> opt)
    : DbContext(opt)
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder mb)
    {
        mb.HasDefaultSchema("ordering");
        mb.ApplyConfigurationsFromAssembly(typeof(OrderingDbContext).Assembly);
    }
}

// Host/Program.cs
builder.Services.AddDbContext<OrderingDbContext>(o =>
    o.UseSqlServer(cs, sql => sql.MigrationsHistoryTable(
        "__MigrationsHistory", "ordering")));

Quy tắc vàng: không query cross-schema. Billing cần biết thông tin order? Nó có projection riêng (billing.OrderSnapshot) được cập nhật qua event OrderPlaced. Không có JOIN ordering.Orders từ Billing DbContext. Vi phạm quy tắc này là bước đầu trở về đống bùi nhùi — và nó phải được chặn bằng code review hoặc một ArchTest riêng (grep FROM ordering. trong Billing assembly là fail build).

Khi dữ liệu thực sự dùng chung

Một vài bảng như Tenant, Currency, Country mang tính tra cứu chung. Hai cách xử lý: (1) gom vào một module "Shared Reference Data" với read-only contracts, các module khác đọc qua query; (2) duplicate bảng tra cứu sang từng schema và đồng bộ qua event. Cách (1) đơn giản hơn cho modular monolith. Cách (2) là bước chuẩn bị cho migration lên microservices sau này.

10. .NET 10 cung cấp gì cho Modular Monolith

.NET 10 không sinh ra modular monolith, nhưng bộ tính năng của nó khiến pattern này dễ triển khai hơn hẳn so với hai phiên bản trước:

Tính năng .NET 10Ứng dụng cho Modular Monolith
Primary constructors cho classHandler, service bớt boilerplate, tập trung vào nghiệp vụ
Keyed services DI (ổn định từ .NET 8, production-ready ở 10)Mỗi module có DbContext, IConnectionFactory riêng cùng interface nhưng key khác
Minimal APIs + Endpoint groupsMỗi module map một RouteGroupBuilder, filter riêng, tag riêng trong OpenAPI
Native AOT cải thiệnCho phép build modular monolith cold-start dưới 200ms, phù hợp scale-to-zero
Typed OpenAPI document (thay Swagger/Swashbuckle)Tự động tách OpenAPI doc per module, frontend typing chính xác theo module
.NET Aspire 10 orchestrationDev-loop chạy host + SQL + Redis + Seq/Jaeger cục bộ chỉ với aspire run
Result pattern chính thức (ProblemDetails + TypedResults)Handler trả Result<T>, endpoint map sang TypedResults.Ok/Problem thống nhất

Một minh hoạ keyed services giúp cô lập connection per module:

// Host/Program.cs
builder.Services.AddKeyedSingleton<SqlConnectionFactory>("ordering",
    (sp, _) => new SqlConnectionFactory(cfg["ConnectionStrings:Ordering"]));
builder.Services.AddKeyedSingleton<SqlConnectionFactory>("billing",
    (sp, _) => new SqlConnectionFactory(cfg["ConnectionStrings:Billing"]));

// Module Billing - nhận đúng instance của mình, không thấy của Ordering
public sealed class InvoiceRepository(
    [FromKeyedServices("billing")] SqlConnectionFactory factory) { /*...*/ }

11. Chiến lược test theo module

Modular monolith có ưu thế lớn về test: không cần spin nhiều container để test end-to-end cross-module. Cấu trúc test phân tầng:

  • Unit test: aggregate, value object, domain service trong Domain layer. Không cần DB, không cần DI container. Chạy mili giây.
  • Module integration test: dùng Testcontainers spin SQL Server thật trong Docker, chạy test cho một module từ endpoint tới DB. Mỗi module một test project.
  • System test: spin toàn host bằng WebApplicationFactory, test vài kịch bản E2E xuyên module (PlaceOrder → OrderPlaced → Invoice).
  • Architecture test: NetArchTest bảo vệ quy ước reference. Một file, chạy dưới 500ms.

Testcontainers cho SQL Server 2022 trong .NET 10 dùng container Alpine mới chạy cold-start < 5s, đủ nhanh để chạy integration test trong CI mà không cần DB cache state giữa các case.

12. Observability và deployment

Dù chạy một process, modular monolith vẫn nên có telemetry per module để quan sát health riêng. OpenTelemetry .NET 10 hỗ trợ ActivitySource per module:

// Mỗi module có ActivitySource riêng
internal static class OrderingTelemetry
{
    public const string SourceName = "App.Ordering";
    public static readonly ActivitySource Source = new(SourceName);
}

// Host đăng ký tất cả
builder.Services.AddOpenTelemetry()
    .WithTracing(t => t
        .AddSource("App.Ordering", "App.Billing", "App.Catalog")
        .AddAspNetCoreInstrumentation()
        .AddEntityFrameworkCoreInstrumentation()
        .AddOtlpExporter());

Khi nhìn dashboard, mỗi span có attribute service.module cho phép filter. Alert SLO cũng định nghĩa per module: "p95 latency của module Ordering < 300ms", không gộp chung cho toàn host.

Deployment: một Dockerfile, một image, một container. Tuy nhiên vẫn có thể scale theo feature flag — nếu module Catalog đột ngột tăng tải, chưa cần tách service, có thể scale số instance của host lên và bật read-only replica DB. Chỉ khi pattern tải của một module quá khác biệt (ví dụ Catalog cần 20 instance trong khi phần còn lại chỉ cần 2) thì mới là tín hiệu cần xem xét tách.

13. Con đường migration hai chiều

Modular Monolith là vị trí trung gian chiến lược: từ đó có thể đi lên (tách microservice) hoặc đi xuống (từ legacy monolith nâng cấp).

13a. Từ Legacy Monolith — Strangler Fig

Áp dụng pattern Strangler Fig của Martin Fowler: thay vì viết lại, cắt từng bounded context ra khỏi đống code cũ, đặt vào project module mới cạnh đó, chuyển route cũ sang module mới dần. Các bước thực tế:

  1. Xác định bounded context dễ cắt trước (thường là domain biên — notification, audit log, invoice export).
  2. Tạo project {Module}.Contracts, {Module}.Domain, {Module}.Application, {Module}.Infrastructure cạnh legacy code.
  3. Copy (không cut) model và logic vào project mới, sửa cho đúng style modular.
  4. Tạo schema riêng cho module trong cùng DB. Dùng view hoặc replication để đồng bộ dữ liệu tạm.
  5. Chuyển một endpoint từ legacy controller sang endpoint mới của module. Hai route tồn tại song song. Dùng feature flag để điều hướng traffic.
  6. Khi đã ổn định, xóa code cũ trong legacy.

Lặp lại cho từng bounded context. Sau 3–6 tháng, legacy co lại dần, module mới chiếm ưu thế, cuối cùng legacy biến mất.

13b. Từ Modular Monolith lên Microservices

Nếu một module thực sự cần tách (tải không đồng nhất, team riêng, compliance riêng), modular monolith đã chuẩn bị sẵn 80% công việc:

  • Contracts đã tồn tại — chỉ cần đổi transport từ in-process sang HTTP/gRPC/message broker.
  • Schema DB đã tách — chỉ cần chuyển sang instance riêng và cập nhật connection string.
  • Integration event đã chạy qua outbox — chỉ cần đổi outbox từ in-process dispatcher sang publish ra Rabbit/Kafka.
  • Observability đã per-module — copy sang service mới là xong.

Wolverine làm việc này đặc biệt mượt: cùng một handler, chỉ đổi config transport từ UseInProcess() sang UseRabbitMQ(). Code nghiệp vụ không đổi.

graph LR
    A[Legacy Monolith
spaghetti, một DB chung] -- Strangler Fig --> B[Modular Monolith
một process, N module, N schema] B -- Extract khi cần --> C[Hybrid
N-1 module ở monolith, 1 module tách riêng] C -- Nếu thực sự cần --> D[Microservices
nhiều process, nhiều DB] B -. đa số dừng ở đây .-> B
Modular Monolith là điểm dừng hợp lý cho phần lớn hệ thống; tách tiếp chỉ khi có nhu cầu thật

14. Pitfalls và khi nào KHÔNG dùng

Sáu anti-pattern hay gặp

  1. Một DbContext khổng lồ cho toàn hệ: giết chết ranh giới ngay từ ngày một. Mỗi module một DbContext.
  2. Cross-module SQL join: tiện lúc này, trả giá ba tháng sau khi migration chia cắt. Luôn đi qua query contract.
  3. Gọi nội bộ module qua service class thay vì command/query: làm mất ranh giới, tái hợp coupling âm thầm.
  4. Contracts project "kitchen sink": nhét cả Entity vào Contracts cho tiện — lập tức biến Contracts thành Domain và chuỗi tham chiếu sụp đổ.
  5. Không có ArchTest: review con người sẽ quên, compiler không bắt được, chỉ ArchTest bảo vệ được ranh giới sau hai năm.
  6. Microservices-cosplay: thêm REST client giả lập giữa module, serialize JSON in-process chỉ để "giống microservice" — phí CPU, không giải quyết gì.

Modular monolith không phải lựa chọn tốt trong ba trường hợp:

  • Tải giữa các bounded context rất không đồng đều, ví dụ một module cần auto-scale 100 instance còn các module khác chỉ 2.
  • Bounded context có yêu cầu compliance khác nhau (một module xử lý PCI-DSS phải chạy trong subnet cô lập).
  • Team trên 50 kỹ sư phân bố ở nhiều múi giờ và cần deploy pipeline độc lập để không block nhau.

Ba trường hợp trên hiếm ở startup và dự án SMB. Ngay cả công ty cỡ vừa, một modular monolith thiết kế tốt đã thừa sức phục vụ hàng trăm nghìn người dùng.

15. Một case study nhỏ: SaaS chấm công cho SME

Mô tả hệ thống tham khảo (tái hiện từ nhiều dự án thực tế): SaaS chấm công - tính lương cho doanh nghiệp 50–500 nhân viên. Domain gồm bốn bounded context:

  • Identity: đăng ký, đăng nhập, phân quyền, SSO qua Microsoft Entra.
  • Timekeeping: chấm công, ca làm, overtime, xin nghỉ.
  • Payroll: công thức lương, thuế TNCN, bảo hiểm, xuất bảng lương.
  • Reporting: dashboard, export Excel, API cho kế toán.

Áp dụng modular monolith:

  • Một instance Azure SQL, bốn schema (identity, timekeeping, payroll, reporting).
  • Host ASP.NET Core .NET 10, Native AOT, container 120MB.
  • Wolverine in-process cho integration event (AttendanceApproved → Payroll, PayrollIssued → Reporting).
  • Reporting module có read model được cập nhật từ event, không bao giờ query schema khác.
  • Một pipeline CI/CD duy nhất, deploy qua Azure Container Apps, auto-scale 2–10 instance.
  • Quan sát qua OpenTelemetry export sang Application Insights; dashboard riêng cho mỗi module.

Kết quả thực tế sau một năm: team 8 kỹ sư phục vụ 300+ khách hàng, p95 API < 250ms, chi phí hạ tầng dưới 400 USD/tháng, deploy 3 lần/ngày. Khi khách hàng lớn nhất yêu cầu tách Payroll ra môi trường cô lập vì lý do compliance, việc extract thành service riêng mất đúng một sprint hai tuần — tất cả nhờ ranh giới đã rạch ròi từ trước.

16. Kết luận: chọn đủ dùng, không chọn thời thượng

Modular Monolith không phải công nghệ mới, không phải "cái sau microservices". Nó là sự trở lại của một tư tưởng kỹ sư cơ bản: chọn kiến trúc đủ dùng cho giai đoạn hiện tại, chừa không gian thay đổi cho tương lai. Với .NET 10, hệ sinh thái đã chín muồi — Wolverine, MediatR, EF Core đa schema, Aspire, Minimal API, Native AOT, keyed services, NetArchTest — đủ để một đội nhỏ xây một sản phẩm nghiêm túc mà không phải trả giá vận hành microservices.

Nguyên tắc thực hành có thể gói trong bốn câu:

  1. Bắt đầu với monolith. Thiết kế module từ ngày một theo bounded context.
  2. Mỗi module một schema, một contracts project, một DbContext, một ActivitySource.
  3. Giao tiếp trong module qua mediator đồng bộ, giữa module qua integration event có outbox.
  4. Chỉ tách thành microservice khi có dữ liệu đo được — không tách vì "nó hay".

Khi bạn thực sự cần microservice, modular monolith đã lát sẵn đường. Khi bạn không cần, nó giúp bạn tập trung vào đúng việc: giải quyết bài toán của khách hàng, không phải chống chọi với phức tạp tự tạo.

Nguồn tham khảo