Skip to main content

18.2 — 1. Khi nào không nên microservices?

Summary

Microservices là một đánh đổi, không phải một nâng cấp: bạn trả bằng độ phức tạp vận hành để mua lấy khả năng triển khai độc lập và scale độc lập. Điểm mấu chốt mà rất nhiều đội bỏ qua: cái giá phải trả ngay từ ngày đầu, còn lợi ích chỉ xuất hiện khi hệ thống và đội đã đủ lớn. Martin Fowler gọi đó là microservice premium — một khoản phí cố định mà hệ thống nhỏ trả nhưng không bao giờ thu lại được. Ba điều kiện tiên quyết, thiếu bất kỳ cái nào thì microservices sẽ làm mọi thứ tệ hơn: CI/CD tự động hoàn toàn, observability xuyên service, và đội đủ lớn để mỗi service có chủ sở hữu rõ ràng. Với phần lớn dự án CRM/ERP ở Việt Nam, câu trả lời đúng là modular monolith.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Nêu chính xác cái giá của microservices và nó đến khi nào.
  • Kiểm tra ba điều kiện tiên quyết trước khi tách service.
  • Nhận ra dấu hiệu hệ thống đã đủ đau để tách.
  • Xây modular monolith có ranh giới thật, không chỉ thư mục.
  • Từ chối microservices một cách có lý lẽ khi bị áp lực.

Nội dung bài học​

18.2.1 — Cái giá đến trước, lợi ích đến sau​

ViệcMonolithMicroservices
Gọi một hàm ở module khácGọi thẳng, có IntelliSenseHTTP/gRPC, serialize, timeout, retry
Debug một luồngMột stack traceTrace xuyên 4 service, cần hạ tầng
Transaction nhiều bảngSaveChanges một lầnSaga hoặc chấp nhận không nhất quán
Thêm một trườngSửa một chỗSửa hợp đồng, phối hợp triển khai
Chạy localF5Docker Compose nhiều container
Refactor xuyên ranh giớiIDE làm đượcGần như không thể tự động

Hàng thứ ba là cái giá đắt nhất và hay bị đánh giá thấp nhất. Trong monolith, "tạo customer và ghi audit log" là một transaction. Sau khi tách, nó trở thành một quy trình phân tán cần outbox, idempotency và xử lý lỗi từng bước (Module 17).

Hàng cuối cũng đáng sợ không kém: đổi tên một trường trong monolith là một phím tắt; xuyên service là một dự án nhỏ có phối hợp triển khai.

18.2.2 — Ba điều kiện tiên quyết​

1. CI/CD tự động hoàn toàn. Với 8 service, triển khai thủ công là không khả thi. Nếu hôm nay bạn vẫn copy file lên server, microservices sẽ nhân con số đó lên 8 lần.

2. Observability xuyên service. Không có distributed tracing, một lỗi "đơn hàng không được tạo" trở thành cuộc điều tra qua 4 hệ thống log rời rạc. Đây là điều kiện bắt buộc, không phải thứ làm sau (bài 18.6).

3. Đội đủ lớn. Conway's Law nói kiến trúc hệ thống sẽ phản chiếu cấu trúc giao tiếp của tổ chức. Một đội 4 người chia 8 service nghĩa là mỗi người "sở hữu" 2 service và không ai thật sự sở hữu cái nào — khi có sự cố, không ai biết rõ service đó đủ để sửa nhanh.

Con số tham khảo thực dụng: dưới 8–10 kỹ sư backend thì hầu như không có lý do gì để tách.

18.2.3 — Dấu hiệu đã đủ đau​

Tách khi có vấn đề cụ thể mà microservices giải quyết, không tách vì "kiến trúc hiện đại":

Scale khác nhau rõ rệt. Module gửi thông báo cần 20 instance lúc cao điểm, phần còn lại cần 2. Trong monolith bạn phải scale cả 20 instance cho toàn bộ ứng dụng — lãng phí bộ nhớ và kết nối database.

Nhịp phát hành khác nhau. Module billing có yêu cầu tuân thủ, mỗi lần đổi phải qua kiểm duyệt 2 tuần. Module CRM UI muốn phát hành hàng ngày. Chung một deployable nghĩa là CRM cũng bị kẹt 2 tuần.

Yêu cầu công nghệ khác nhau. Module xử lý ảnh cần thư viện native; module báo cáo cần chạy trên máy nhiều RAM.

Ranh giới đội rõ ràng. Hai đội riêng biệt, mỗi đội có backlog riêng, và họ liên tục chặn nhau ở khâu phát hành.

Không phải lý do chính đáng: "để dễ scale sau này" (chưa có vấn đề scale), "để code sạch hơn" (modular monolith làm được), "công ty khác làm thế" (họ có 200 kỹ sư).

18.2.4 — Modular monolith​

Đây là lựa chọn mặc định đúng cho phần lớn hệ thống: một deployable, nhiều module có ranh giới thật.

Crm.Api/                      <- MOT deployable
Modules/
Sales/
Sales.Domain/
Sales.Application/
Sales.Infrastructure/
Sales.Contracts/ <- CHỈ project này được module khác tham chiếu
Billing/
Billing.Domain/
...
Billing.Contracts/

Quy tắc cốt lõi: module chỉ được tham chiếu project Contracts của module khác, không bao giờ tham chiếu Domain hay Infrastructure.

[Fact]
public void Sales_ShouldNotDependOn_BillingInternals()
{
var result = Types.InAssembly(typeof(SalesModule).Assembly)
.ShouldNot()
.HaveDependencyOnAny("Billing.Domain", "Billing.Infrastructure", "Billing.Application")
.GetResult();

Assert.True(result.IsSuccessful);
}

Test này chạy trong CI và chặn PR vi phạm ranh giới (bài 16.11). Không có nó, ranh giới chỉ là thư mục và sẽ bị phá trong vòng vài tháng.

Ranh giới dữ liệu cũng cần rõ:

// Mỗi module một schema riêng — chuẩn bị cho việc tách sau này
modelBuilder.Entity<Lead>().ToTable("Leads", schema: "sales");
modelBuilder.Entity<Invoice>().ToTable("Invoices", schema: "billing");

Cấm JOIN xuyên schema. Khi cần dữ liệu module khác, gọi qua Contracts — đúng như khi chúng là service riêng.

Lợi ích lớn nhất: nếu sau này thật sự cần tách, mỗi module đã là một ứng viên sẵn sàng. Tách một module đã có ranh giới rõ tốn vài tuần; tách một monolith rối tốn vài quý — và thường thất bại giữa chừng.

18.2.5 — Khi bị áp lực phải làm microservices​

Lập luận có dữ liệu thay vì tranh cãi quan điểm:

Câu hỏiVì sao nó hữu ích
Service nào cần scale khác phần còn lại, và số liệu đâu?Buộc chuyển từ cảm tính sang đo đạc
Deploy hiện tại mất bao lâu, bao nhiêu bước thủ công?Phơi bày điều kiện tiên quyết còn thiếu
Ai trực sự cố cho từng service?Phơi bày vấn đề nhân lực
Trace một request qua 4 service mất bao lâu?Phơi bày lỗ hổng observability
Nếu sai, quay lại monolith mất bao lâu?Buộc tính chi phí đảo ngược

Đề xuất thay thế thường được chấp nhận: làm modular monolith trước, tách khi có số liệu chứng minh cần tách. Martin Fowler gọi đây là monolith first và ghi nhận rằng gần như mọi hệ microservices thành công đều bắt đầu từ một monolith được tách dần.

18.2.6 — Rà lại code của bạn​

Danh sách rà soát trước khi tách microservices

  • •Có vấn đề cụ thể mà microservices giải quyết, kèm số liệu.
  • •CI/CD đã tự động hoàn toàn, không còn bước thủ công.
  • •Đã có distributed tracing xuyên service trước khi tách.
  • •Mỗi service dự kiến đều có chủ sở hữu rõ ràng và người trực sự cố.
  • •Đã cân nhắc modular monolith và nêu được vì sao nó không đủ.
  • •Module hiện tại chỉ tham chiếu Contracts của nhau, có kiến trúc test chặn.
  • •Dữ liệu đã tách schema, không JOIN xuyên ranh giới module.
  • •Đã tính chi phí quay lại nếu quyết định tách là sai.

Bài tập áp dụng​

Bài 1 — Kiểm tra điều kiện tiên quyết​

Với dự án hiện tại, trả lời đủ 5 câu hỏi ở mục 18.2.5 bằng số liệu thật, không bằng ước lượng.

Tiêu chí hoàn thành: mỗi câu trả lời có một con số đo được, và bạn nhận ra câu nào trong năm câu là câu quyết định.

Gợi ý và lời giải — Bài 1

Gợi ý. "Chúng tôi cần scale tốt hơn" không phải câu trả lời. "Endpoint báo cáo dùng 78% CPU trong khi phần còn lại dùng 12%" thì có.

Lời giải — đo từng câu:

Câu 1 — Service nào cần scale khác phần còn lại?

# CPU theo endpoint trong 30 ngày
topk(10, sum by (http_route) (rate(process_cpu_seconds_total[30d])))
# Hoặc từ log của reverse proxy
awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10
Endpoint                         Request/ngày   CPU trung bình   Bộ nhớ
/api/leads 842.104 12% 180 MB
/api/bao-cao/tong-hop 1.204 78% 2,1 GB
/api/khach-hang 412.008 8% 140 MB
/api/dong-bo-erp 412 4% 320 MB
Kết luận: /api/bao-cao dùng 78% CPU cho 0,1% lưu lượng
-> ĐÂY là ứng viên tách, và có số liệu chứng minh

Nếu mọi endpoint dùng CPU và bộ nhớ tương đương nhau, không có nhu cầu scale khác biệt — và một trong ba lý do chính đáng cho microservices không tồn tại.

Câu 2 — Deploy mất bao lâu, bao nhiêu bước thủ công?

gh run list --workflow=deploy.yml --limit 20 \
--json durationMs,conclusion --jq '.[] | select(.conclusion=="success") | .durationMs' \
| awk '{s+=$1; n++} END {printf "Trung bình: %.1f phút qua %d lần\n", s/n/60000, n}'
Trung bình: 18.4 phút qua 20 lần
Bước thủ công hiện có:
1. [ ] Chạy migration bằng tay trên production — 5 phút
2. [ ] Xoá cache Redis bằng tay sau deploy — 2 phút
3. [ ] Kiểm tra bằng mắt 3 endpoint chính — 5 phút
4. [ ] Báo trong kênh chat — 1 phút

-> 4 bước thủ công, khoảng 13 phút
Kết luận: CI/CD CHƯA tự động hoàn toàn.
Với 1 dịch vụ: 13 phút thủ công mỗi lần deploy — khó chịu
Với 8 dịch vụ: 104 phút thủ công — không khả thi

Đây là điều kiện tiên quyết rõ ràng nhất, và nó thường bị bỏ qua vì nó không thú vị bằng việc thiết kế kiến trúc.

Câu 3 — Ai trực sự cố cho từng service?

| Service dự kiến | Chủ sở hữu | Người trực chính | Người trực phụ |
|---|---|---|---|
| Identity | Đội A | An | Bình |
| Lead | Đội A | An | Bình |
| Billing | Đội B | Cường | ? |
| Notification | ? | ? | ? |
| Reporting | ? | ? | ? |
| ERP Sync | Đội B | Cường | ? |
Kết luận: 6 service dự kiến, 3 người có thể trực, 3 service không có chủ
-> An sẽ trực 2 service, Cường trực 2, và 2 service không ai lo
-> đội 6 người không vận hành được 6 service

Quy tắc thường được nhắc tới: một service cần ít nhất 3–4 người để có lịch trực bền vững. Đội 6 người vận hành được khoảng 2 service.

Câu 4 — Trace một request qua 4 service mất bao lâu?

grep -rn "AddOpenTelemetry\|AddSource\|TraceId" --include="*.cs" src/ | wc -l
0
Kết luận: KHÔNG có distributed tracing
-> trace một request qua 4 service = đọc 4 file log,
ghép bằng dấu thời gian
-> ước lượng: 30–60 phút cho MỘT request, và thường không ghép được

Nếu con số này không phải "vài giây", bạn chưa sẵn sàng. Sự cố đầu tiên trong hệ nhiều dịch vụ sẽ mất nhiều giờ để chẩn đoán (bài 18.5).

Câu 5 — Nếu sai, quay lại monolith mất bao lâu?

Ước lượng quay lại từ 4 service về monolith:
- Gộp code: 2 tuần
- Gộp 4 database về 1: 3 tuần (di chuyển dữ liệu, sửa khoá ngoại)
- Sửa mọi lời gọi HTTP thành gọi trực tiếp: 2 tuần
- Test lại toàn bộ: 2 tuần
-> Tổng: khoảng 9 tuần
Kết luận: quyết định này khó đảo ngược.
-> phải chắc chắn hơn nhiều so với một quyết định đảo ngược được trong một ngày

Câu nào là câu quyết định — câu 1.

Câu 2, 3, 4:  điều kiện TIÊN QUYẾT — thiếu thì phải bổ sung trước
Câu 5: đo mức rủi ro
Câu 1: LÝ DO — nếu không có, mọi câu khác vô nghĩa

Không có câu trả lời cụ thể cho câu 1, bạn đang giải một bài toán không tồn tại. Và đây là trường hợp phổ biến nhất: đội muốn microservices vì nó là kiến trúc được nói tới nhiều, không vì một vấn đề cụ thể.

Ba lý do chính đáng — cần ít nhất một:

1. Yêu cầu scale KHÁC BIỆT RÕ RỆT, có số liệu
(một phần dùng 78% CPU cho 0,1% lưu lượng)

2. Nhiều đội cần triển khai ĐỘC LẬP, và ranh giới đội ĐÃ RÕ
(không phải "sẽ rõ sau khi tách")

3. Yêu cầu tuân thủ buộc CÁCH LY DỮ LIỆU
(dữ liệu y tế, thanh toán phải nằm ở hạ tầng riêng)

Ba lý do KHÔNG chính đáng:

1. "Monolith là lỗi thời"
2. "Chúng tôi muốn dùng nhiều ngôn ngữ"
3. "Code đang rối"

Lý do 3 đáng nói kỹ: tách dịch vụ không sửa được kiến trúc tệ. Nó thêm ranh giới mạng vào giữa code đã rối, và bạn có một hệ phân tán rối — khó sửa hơn nhiều.

Viết kết quả thành một trang:

## Đánh giá tách microservices — 2026-09-25

| Câu hỏi | Trả lời | Kết luận |
|---|---|---|
| Ai cần scale khác? | Báo cáo: 78% CPU / 0,1% lưu lượng | **CÓ lý do, cho 1 service** |
| Deploy bao lâu? | 18,4 phút + 13 phút thủ công | **Chưa đủ điều kiện** |
| Ai trực? | 3 người cho 6 service dự kiến | **Chưa đủ nhân lực** |
| Trace 4 service? | Không có tracing | **Chưa đủ điều kiện** |
| Quay lại mất bao lâu? | ~9 tuần | Rủi ro cao |

## Đề xuất
1. Tách DUY NHẤT service báo cáo (có lý do rõ, ít phụ thuộc)
2. Trước đó: tự động hoá 4 bước deploy thủ công (2 tuần)
3. Trước đó: dựng OpenTelemetry (1 tuần)
4. Phần còn lại: giữ monolith, làm modular monolith
5. Đánh giá lại sau 6 tháng

Đề xuất "tách một service" thường được chấp nhận dễ hơn nhiều so với "chưa tách gì cả" — và nó cho bạn kinh nghiệm thật trước khi quyết định về phần còn lại.


Bài 2 — Kiến trúc test cho ranh giới module​

Thêm NetArchTest.Rules và viết test chặn một module tham chiếu nội bộ của module khác. Chạy trên dự án thật, đếm số vi phạm hiện có.

Tiêu chí hoàn thành: bạn đếm được vi phạm, và hiểu vì sao con số này dự báo chi phí tách service.

Gợi ý và lời giải — Bài 2

Gợi ý. Nếu module A gọi thẳng vào lớp nội bộ của module B, chuyện gì xảy ra khi B trở thành một service riêng?

Lời giải — cấu trúc modular monolith:

src/Crm.Api/
Modules/
Leads/
Contracts/ <- PUBLIC: interface và DTO cho module khác dùng
ILeadQueries.cs
LeadSummaryDto.cs
Domain/ <- NỘI BỘ
Infrastructure/ <- NỘI BỘ
Features/ <- NỘI BỘ
Billing/
Contracts/
Domain/
...
[Fact]
public void Module_chi_duoc_tham_chieu_Contracts_cua_module_khac()
{
var modules = new[] { "Leads", "Billing", "Notification", "Reporting", "Identity" };
var viPham = new List<string>();

foreach (var module in modules)
{
var namespaceKhac = modules
.Where(m => m != module)
.SelectMany(m => new[]
{
$"Crm.Api.Modules.{m}.Domain",
$"Crm.Api.Modules.{m}.Infrastructure",
$"Crm.Api.Modules.{m}.Features",
})
.ToArray();

var kq = Types.InAssembly(typeof(Program).Assembly)
.That().ResideInNamespace($"Crm.Api.Modules.{module}")
.ShouldNot().HaveDependencyOnAny(namespaceKhac)
.GetResult();

if (!kq.IsSuccessful)
viPham.AddRange((kq.FailingTypeNames ?? []).Select(t => $"{module}: {t}"));
}

viPham.Should().BeEmpty(
"module chỉ được dùng Contracts của module khác. Vi phạm: {0}",
string.Join("\n ", viPham));
}
Xpect: module chỉ được dùng Contracts của module khác. Vi phạm:
Billing: Crm.Api.Modules.Billing.Features.TaoHoaDonHandler
Billing: Crm.Api.Modules.Billing.Domain.Invoice
Notification: Crm.Api.Modules.Notification.Features.GuiEmailHandler
Reporting: Crm.Api.Modules.Reporting.Features.BaoCaoDoanhSoHandler
... (34 kiểu)

34 vi phạm.

Vì sao con số này dự báo chi phí tách service:

Mỗi vi phạm là một chỗ mà module A gọi thẳng vào nội bộ của B.

Khi B thành service riêng:
lời gọi trong tiến trình -> lời gọi mạng
đồng bộ, không thể lỗi -> bất đồng bộ, có thể timeout
dữ liệu mới nhất -> có thể cũ
transaction chung -> KHÔNG có transaction chung

Mỗi vi phạm cần một trong bốn cách xử lý, với chi phí rất khác nhau:

CáchChi phíKhi nào dùng
Thêm vào Contracts của BThấp — chỉ là interfaceA cần đọc dữ liệu của B
Nhân bản dữ liệu qua eventTrung bìnhA cần dữ liệu của B thường xuyên
Gọi HTTP/gRPCTrung bìnhA cần dữ liệu tươi, chấp nhận coupling
Thiết kế lại ranh giớiCaoA và B chia sẻ một transaction

Phân loại 34 vi phạm:

[Fact]
public void Phan_loai_vi_pham_theo_muc_do()
{
// Vi phạm ĐỌC — dễ sửa
var doc = TimViPham(chiDoc: true);

// Vi phạm GHI — khó, vì có thể đang chia sẻ transaction
var ghi = TimViPhamGhi();

_output.WriteLine($"Vi phạm ĐỌC: {doc.Count} -> thêm vào Contracts");
_output.WriteLine($"Vi phạm GHI: {ghi.Count} -> cần thiết kế lại");
}
Vi phạm ĐỌC: 28  -> thêm vào Contracts
Vi phạm GHI: 6 -> cần thiết kế lại

Sáu vi phạm ghi là phần đắt nhất, và chúng thường có hình dạng này:

// Billing gọi thẳng vào Domain của Leads
public async Task<Result> TaoHoaDonAsync(LeadId leadId, CancellationToken ct)
{
var lead = await _db.Leads.FirstAsync(l => l.Id == leadId, ct);
lead.DanhDauDaXuatHoaDon(); // GHI vào entity của module khác

var hoaDon = Invoice.Tao(lead.CustomerId, lead.Value);
_db.Invoices.Add(hoaDon);

await _db.SaveChangesAsync(ct); // MỘT transaction cho CẢ HAI module
}
Khi tách:
hai database riêng
-> không có transaction chung
-> "đánh dấu đã xuất hoá đơn" và "tạo hoá đơn" có thể lệch nhau
-> cần saga có bù trừ ([bài 17.11](../module-17-distributed-systems/17.11-advanced-notes.mdx))

Và đây là tín hiệu quan trọng nhất: nếu hai module chia sẻ transaction, chúng có lẽ THUỘC VỀ NHAU.

6 vi phạm ghi giữa Leads và Billing
-> hai module này luôn thay đổi cùng nhau
-> tách chúng = tạo ra một saga phức tạp cho thứ vốn là một transaction
-> cân nhắc GIỮ CHUNG trong một service

Sửa vi phạm đọc — thêm vào Contracts:

// Crm.Api/Modules/Leads/Contracts/ILeadQueries.cs
public interface ILeadQueries
{
Task<LeadSummaryDto?> LayTomTatAsync(Guid leadId, CancellationToken ct);
Task<IReadOnlyList<LeadSummaryDto>> LayTheoKhachHangAsync(Guid customerId, CancellationToken ct);
}

public sealed record LeadSummaryDto(Guid Id, string Ten, decimal GiaTri, string TrangThai);
// Billing dùng interface, KHÔNG dùng entity
public class TaoHoaDonHandler(ILeadQueries leadQueries, CrmDbContext db)
{
public async Task<Result> Handle(TaoHoaDonCommand c, CancellationToken ct)
{
var lead = await leadQueries.LayTomTatAsync(c.LeadId, ct);
if (lead is null) return Result.KhongTimThay();

db.Invoices.Add(Invoice.Tao(lead.Id, lead.GiaTri));
await db.SaveChangesAsync(ct);
return Result.ThanhCong();
}
}

Lợi ích ngay cả khi KHÔNG tách service:

1. Ranh giới rõ -> đọc code dễ hơn
2. Đổi nội bộ module Leads không ảnh hưởng Billing
3. Test Billing chỉ cần fake ILeadQueries
4. NẾU tách service sau này -> đổi phần cài đặt ILeadQueries thành HTTP client

Điểm 4 là điểm quan trọng nhất: modular monolith với ranh giới rõ là bước chuẩn bị tốt nhất cho microservices — và nếu bạn không bao giờ tách, bạn vẫn có một codebase tốt hơn.

Áp dụng ratchet:

private const int NguongViPham = 34;

[Fact]
public void So_vi_pham_ranh_gioi_chi_duoc_giam()
{
var soViPham = DemViPham();
soViPham.Should().BeLessThanOrEqualTo(NguongViPham,
"số vi phạm tăng từ {0} lên {1}", NguongViPham, soViPham);
}

Và theo dõi con số theo thời gian:

| Tuần | Vi phạm đọc | Vi phạm ghi | Ghi chú |
|---|---:|---:|---|
| 0 | 28 | 6 | Đo lần đầu |
| 4 | 12 | 6 | Thêm ILeadQueries, IBillingQueries |
| 8 | 0 | 6 | Xong vi phạm đọc |
| 12 | 0 | 2 | Gộp Leads và Billing thành một module |

Dòng cuối là kết luận mà số liệu dẫn tới: hai module chia sẻ transaction thì gộp lại, đừng tách ra.


Bài 3 — Tách schema​

Chọn hai module trong monolith của bạn, chuyển bảng sang hai schema riêng và tìm mọi truy vấn JOIN xuyên schema.

Tiêu chí hoàn thành: bạn tìm được mọi JOIN xuyên schema, và biết ba cách xử lý cùng chi phí của từng cách.

Gợi ý và lời giải — Bài 3

Gợi ý. Mỗi JOIN xuyên schema là một JOIN sẽ không còn tồn tại khi tách database.

Lời giải — tách schema:

CREATE SCHEMA leads;
CREATE SCHEMA billing;

ALTER SCHEMA leads TRANSFER dbo.Leads;
ALTER SCHEMA leads TRANSFER dbo.LeadActivities;
ALTER SCHEMA leads TRANSFER dbo.LeadNotes;

ALTER SCHEMA billing TRANSFER dbo.Invoices;
ALTER SCHEMA billing TRANSFER dbo.InvoiceLines;
ALTER SCHEMA billing TRANSFER dbo.Subscriptions;
builder.Entity<Lead>().ToTable("Leads", "leads");
builder.Entity<Invoice>().ToTable("Invoices", "billing");

Tìm JOIN xuyên schema:

-- Khoá ngoại xuyên schema — dễ tìm nhất
SELECT
fk.name AS TenKhoaNgoai,
SCHEMA_NAME(tp.schema_id) + '.' + tp.name AS BangCha,
SCHEMA_NAME(tr.schema_id) + '.' + tr.name AS BangCon
FROM sys.foreign_keys fk
JOIN sys.tables tp ON tp.object_id = fk.referenced_object_id
JOIN sys.tables tr ON tr.object_id = fk.parent_object_id
WHERE SCHEMA_NAME(tp.schema_id) <> SCHEMA_NAME(tr.schema_id)
AND SCHEMA_NAME(tp.schema_id) IN ('leads', 'billing')
ORDER BY BangCha;
TenKhoaNgoai              BangCha        BangCon
FK_Invoices_Leads leads.Leads billing.Invoices
FK_Subscriptions_Leads leads.Leads billing.Subscriptions
-- JOIN trong view, stored procedure, function
SELECT o.name, o.type_desc
FROM sys.sql_modules m
JOIN sys.objects o ON o.object_id = m.object_id
WHERE m.definition LIKE '%leads.%' AND m.definition LIKE '%billing.%';
name                        type_desc
vw_BaoCaoDoanhSo VIEW
sp_TinhHoaHongNhanVien SQL_STORED_PROCEDURE
# JOIN trong LINQ — tìm qua code
grep -rn "\.Include(\|join .* in \|\.Leads\." --include="*.cs" src/Crm.Api/Modules/Billing/
// Tìm được
var hoaDon = await _db.Invoices
.Include(i => i.Lead) // JOIN xuyên schema
.Where(i => i.Lead.TrangThai == "Won") // và lọc theo cột của module khác
.ToListAsync(ct);

Cách chắc chắn nhất — bật log SQL và chạy bộ test:

options.UseSqlServer(conn).LogTo(sql =>
{
if (sql.Contains("leads.") && sql.Contains("billing."))
File.AppendAllText("join-xuyen-schema.log", sql + "\n---\n");
}, LogLevel.Information);
dotnet test
wc -l join-xuyen-schema.log
Tìm thấy 18 truy vấn JOIN xuyên schema

Ba cách xử lý, với chi phí thật:

Cách 1 — nhân bản dữ liệu qua event (phổ biến nhất):

// Billing giữ bản sao TỐI THIỂU của dữ liệu Lead mà nó cần
public class LeadSnapshot
{
public Guid LeadId { get; private set; }
public string Ten { get; private set; } = null!;
public decimal GiaTri { get; private set; }
public string TrangThai { get; private set; } = null!;
public DateTime CapNhatLuc { get; private set; }
}
public class CapNhatLeadSnapshotConsumer : IConsumer<LeadDaCapNhatV1>
{
public async Task Consume(ConsumeContext<LeadDaCapNhatV1> ctx)
{
var e = ctx.Message;
var snapshot = await _db.LeadSnapshots
.FirstOrDefaultAsync(s => s.LeadId == e.LeadId, ctx.CancellationToken);

if (snapshot is null)
{
_db.LeadSnapshots.Add(LeadSnapshot.Tao(e.LeadId, e.Ten, e.GiaTri, e.TrangThai));
}
else
{
// Bỏ qua event CŨ hơn — thứ tự message không được bảo đảm
if (snapshot.CapNhatLuc >= e.ThoiDiemUtc) return;
snapshot.CapNhat(e.Ten, e.GiaTri, e.TrangThai, e.ThoiDiemUtc);
}

await _db.SaveChangesAsync(ctx.CancellationToken);
}
}
// JOIN giờ nằm TRONG schema billing
var hoaDon = await _db.Invoices
.Join(_db.LeadSnapshots, i => i.LeadId, s => s.LeadId, (i, s) => new { i, s })
.Where(x => x.s.TrangThai == "Won")
.ToListAsync(ct);
Chi phí:
+ Một bảng snapshot và một consumer cho mỗi module cần dữ liệu
+ Dữ liệu có thể cũ vài trăm mili giây
+ Cần xử lý thứ tự message (kiểm tra CapNhatLuc ở trên)
- JOIN vẫn nhanh (trong cùng database)
- Không phụ thuộc thời gian vào module kia

Đây là cách đúng nhất về kiến trúc, vì nó loại bỏ hẳn phụ thuộc thời gian.

Cách 2 — gọi qua interface (giữ trong monolith) hoặc HTTP (sau khi tách):

var hoaDon = await _db.Invoices.Where(...).ToListAsync(ct);
var leadIds = hoaDon.Select(h => h.LeadId).Distinct().ToList();

var leads = await _leadQueries.LayTheoIdsAsync(leadIds, ct); // một lời gọi cho nhiều id

var ketQua = hoaDon
.Join(leads, h => h.LeadId, l => l.Id, (h, l) => new { h, l })
.Where(x => x.l.TrangThai == "Won")
.ToList();
Chi phí:
+ Phụ thuộc thời gian: module kia chết -> chức năng này chết
+ Lọc trong BỘ NHỚ thay vì SQL -> phải nạp nhiều dữ liệu hơn
+ Độ trễ mạng sau khi tách service
- Dữ liệu luôn mới nhất
- Không cần bảng snapshot

Chú ý LayTheoIdsAsync nhận danh sách id — gọi từng id một là N+1 qua mạng, và đó là lỗi phổ biến nhất sau khi tách service.

Cách 3 — thiết kế lại để không cần dữ liệu của module kia:

// Invoice LƯU thông tin cần thiết tại thời điểm tạo
public class Invoice
{
public Guid LeadId { get; private set; }
public string TenLeadLucTao { get; private set; } = null!; // snapshot tại thời điểm
public decimal GiaTriLucTao { get; private set; }
}
Chi phí:
- Không phụ thuộc gì cả
- Truy vấn nhanh nhất
+ Dữ liệu là ảnh chụp, KHÔNG cập nhật khi lead đổi

Và đây thường là cách ĐÚNG NHẤT về mặt nghiệp vụ, không chỉ về kỹ thuật:

Hoá đơn ghi "Công ty ABC, 5 triệu" tại thời điểm xuất.
Nếu lead đổi tên thành "Công ty ABC Việt Nam" sau đó,
hoá đơn CŨ vẫn phải ghi tên CŨ — đó là bản chất của chứng từ.

Nhiều JOIN xuyên module thật ra là dấu hiệu của mô hình dữ liệu chưa đúng: thông tin cần được đóng băng tại một thời điểm đang được tra cứu động.

Bảng chọn:

Tình huốngCách
Cần dữ liệu tại thời điểm xảy ra sự kiệnCách 3 — lưu vào chính bản ghi
Cần dữ liệu hiện tại, truy vấn thường xuyênCách 1 — snapshot qua event
Cần dữ liệu tươi tuyệt đối, ít truy vấnCách 2 — gọi trực tiếp
Truy vấn báo cáo tổng hợp nhiều moduleRead model riêng, dựng từ event

Xoá khoá ngoại xuyên schema:

ALTER TABLE billing.Invoices DROP CONSTRAINT FK_Invoices_Leads;
builder.Entity<Invoice>()
.Property(i => i.LeadId)
.HasColumnName("LeadId");
// KHÔNG khai HasOne(i => i.Lead)

Mất khoá ngoại nghĩa là mất một lớp bảo vệ toàn vẹn — bù lại bằng kiểm tra định kỳ:

-- Hoá đơn trỏ tới lead không tồn tại
SELECT COUNT(*) FROM billing.Invoices i
WHERE NOT EXISTS (SELECT 1 FROM leads.Leads l WHERE l.Id = i.LeadId);
Cảnh báo khi con số này khác 0.

Chặn JOIN xuyên schema quay lại:

options.UseSqlServer(conn).LogTo(sql =>
{
if (sql.Contains("[leads].") && sql.Contains("[billing]."))
throw new InvalidOperationException(
$"JOIN xuyên schema bị cấm. SQL: {sql[..Math.Min(500, sql.Length)]}");
}, LogLevel.Information);

Bật ở môi trường test và dev, tắt ở production. Nó bắt được đúng lúc ai đó thêm một Include mới — và thông điệp lỗi kèm câu SQL nói rõ vấn đề nằm ở đâu.

Tự kiểm tra​

Frequently asked questions

Vì sao nói microservices premium là khoản phí cố định?

Vì cái giá về độ phức tạp vận hành phải trả ngay từ ngày đầu, trong khi lợi ích triển khai độc lập và scale độc lập chỉ xuất hiện khi hệ thống và đội đã đủ lớn. Hệ thống nhỏ trả khoản phí đó nhưng không bao giờ thu lại được.

Ba điều kiện tiên quyết trước khi tách service là gì?

CI/CD tự động hoàn toàn vì triển khai thủ công không khả thi với nhiều service, observability xuyên service vì không có tracing thì mọi sự cố thành cuộc điều tra qua nhiều hệ thống log rời rạc, và đội đủ lớn để mỗi service có chủ sở hữu thật sự.

Cái giá nào của microservices hay bị đánh giá thấp nhất?

Mất transaction xuyên module. Trong monolith, ghi nhiều bảng là một SaveChanges. Sau khi tách, việc đó thành quy trình phân tán cần outbox, idempotency và xử lý lỗi từng bước. Refactor xuyên ranh giới cũng vậy, từ một phím tắt IDE thành một dự án nhỏ.

Dấu hiệu nào cho thấy đã đủ đau để tách?

Nhu cầu scale khác nhau rõ rệt giữa các phần, nhịp phát hành khác nhau khiến phần này kẹt vì phần kia, yêu cầu công nghệ khác nhau, hoặc hai đội riêng biệt liên tục chặn nhau ở khâu phát hành. Tất cả đều là vấn đề cụ thể có số liệu.

Quy tắc cốt lõi của modular monolith là gì?

Module chỉ được tham chiếu project Contracts của module khác, không bao giờ tham chiếu Domain hay Infrastructure, và ranh giới đó phải được chặn bằng kiến trúc test trong CI. Dữ liệu cũng tách schema riêng và cấm JOIN xuyên schema.

Vì sao modular monolith giúp việc tách sau này rẻ hơn?

Vì mỗi module đã là một ứng viên có ranh giới rõ về cả code lẫn dữ liệu. Tách một module như vậy tốn vài tuần, còn tách một monolith rối tốn vài quý và thường thất bại giữa chừng.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Cái giá đến ngay, lợi ích đến muộn. Hệ thống nhỏ chỉ trả phí mà không thu lại.
  2. Thiếu CI/CD hoặc tracing thì microservices làm mọi thứ tệ hơn, không phải tốt hơn.
  3. Modular monolith là mặc định đúng — và là bước chuẩn bị rẻ nhất cho việc tách sau này.

Tham khảo​

Điều hướng​