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
Table of contents
- 1. Con lắc đang dao động về phía Modular Monolith
- 2. Cái bẫy của microservices áp dụng từ sớm
- 3. Biên niên sử: từ N-tier đến Modular Monolith
- 4. Modular Monolith là gì — định nghĩa chính xác
- 5. Nguyên lý thiết kế: Bounded Context và Public API
- 6. Vertical Slice Architecture bên trong mỗi module
- 7. Tổ chức project .NET 10 trong thực tế
- 8. Giao tiếp giữa module với Wolverine và MediatR
- 9. Persistence theo module — một database, nhiều schema
- 10. .NET 10 cung cấp gì cho Modular Monolith
- 11. Chiến lược test theo module
- 12. Observability và deployment
- 13. Con đường migration hai chiều
- 14. Pitfalls và khi nào KHÔNG dùng
- 15. Một case study nhỏ: SaaS chấm công cho SME
- 16. Kết luận: chọn đủ dùng, không chọn thời thượng
- Nguồn tham khảo
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.
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 compiler và quy ước project thay vì bằng network.
3. Biên niên sử: từ N-tier đến Modular Monolith
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
InternalsVisibleTocó 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
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 contract | Mục đích | Ví dụ |
|---|---|---|
| Commands | Yêu cầu thay đổi trạng thái, đồng bộ, expect response | PlaceOrder, IssueInvoice |
| Queries | Lấy dữ liệu read-only, đồng bộ, không side-effect | GetOrderById, GetCustomerBalance |
| Integration Events | Phá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 (
IUserRepositorychỉ 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
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}.Domainkhông reference bất kỳ thứ gì ngoài .NET BCL vàBuildingBlocks.Abstractions. Không EF, không ASP.NET. - Project
{Module}.Contractschỉ 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.csvớiAddOrderingModule(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
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 class | Handler, 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 groups | Mỗi module map một RouteGroupBuilder, filter riêng, tag riêng trong OpenAPI |
| Native AOT cải thiện | Cho 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 orchestration | Dev-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ế:
- Xác định bounded context dễ cắt trước (thường là domain biên — notification, audit log, invoice export).
- Tạo project
{Module}.Contracts,{Module}.Domain,{Module}.Application,{Module}.Infrastructurecạnh legacy code. - Copy (không cut) model và logic vào project mới, sửa cho đúng style modular.
- Tạo schema riêng cho module trong cùng DB. Dùng view hoặc replication để đồng bộ dữ liệu tạm.
- 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.
- 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
14. Pitfalls và khi nào KHÔNG dùng
Sáu anti-pattern hay gặp
- 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.
- 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.
- 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.
- 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 đổ.
- 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.
- 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:
- Bắt đầu với monolith. Thiết kế module từ ngày một theo bounded context.
- Mỗi module một schema, một contracts project, một DbContext, một ActivitySource.
- Giao tiếp trong module qua mediator đồng bộ, giữa module qua integration event có outbox.
- 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
- Microsoft Learn — Architecting Modern Web Applications with ASP.NET Core and Azure
- Kamil Grzybek — Modular Monolith with DDD (sample repo)
- Wolverine — In-process and out-of-process messaging cho .NET
- Jimmy Bogard — Vertical Slice Architecture
- Prime Video Tech Blog — Scaling audio/video monitoring and reducing cost by 90%
- Martin Fowler — Strangler Fig Application
- NetArchTest — Fluent API cho architecture tests
- .NET Aspire — Cloud-native orchestration
Thiết kế hệ thống Payment Gateway 2026 - Idempotency, Saga Pattern và phòng thủ Double-Charge cho Stripe-scale
Claude Code Skills 2026 - Progressive Disclosure và Cách Chuẩn Hoá Workflow cho Team Engineering
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.