Hiếm có đội ngũ nào chủ động muốn xây một hệ phân tán. Họ thường đi tới đó qua những quyết định riêng lẻ hợp lý: cần deploy độc lập, scale một luồng nóng, hay thoát khỏi schema chung khó thay đổi. Chẳng bao lâu, lời gọi hàm cũ phải đi qua mạng, mang trace ID, retry sau timeout và đôi khi thành công sau khi phía gọi đã bỏ cuộc.
Thái cực còn lại cũng quen thuộc. Một ứng dụng khởi đầu gọn gàng, rồi mỗi tính năng dần đọc mọi bảng và gọi mọi helper. Sửa thuế làm hỏng checkout; query báo cáo khóa đơn hàng; không ai biết team nào được đổi bảng customers. Process chung không phải vấn đề. Thiết kế không còn ranh giới mới là vấn đề.
Modular monolith vẫn là một đơn vị triển khai, nhưng được chia thành các module nghiệp vụ. Mỗi module sở hữu dữ liệu và hành vi, công bố API in-process nhỏ và khai báo dependency được phép. Nó không cô lập vận hành như microservices, nhưng mang lại kỷ luật kiến trúc mà chưa buộc đội ngũ trả toàn bộ chi phí network và platform.
Điều cần chọn là vị trí của ranh giới
“Monolith” mô tả topology triển khai, không nói lên chất lượng code. Một ứng dụng tốt có thể chạy trong một process; hàng chục service vẫn có thể tạo thành distributed monolith. Hãy hỏi cụ thể hơn: Ai sở hữu trạng thái? Thay đổi qua contract nào? Dependency đi theo chiều nào? Thành phần nào thật sự có thể hỏng độc lập?
Ba mô hình phổ biến khác nhau chủ yếu ở những trách nhiệm mà đội ngũ phải gánh:
| Khía cạnh | Layered monolith | Modular monolith | Microservices |
|---|---|---|---|
| Triển khai | Một artifact | Một artifact | Nhiều artifact phát hành độc lập |
| Ranh giới | Thường theo tầng kỹ thuật | Theo năng lực nghiệp vụ, có API được cưỡng chế | Theo process và network |
| Dữ liệu | Schema chung, truy cập rộng | Mỗi module sở hữu bảng hoặc schema riêng | Mỗi service sở hữu database |
| Giao tiếp | Gọi trực tiếp bên trong | Gọi qua contract in-process | Gọi từ xa, có partial failure |
| Transaction | Dễ bao trùm mọi thứ | Mặc định cục bộ, workflow rõ ràng khi qua module | Chỉ cục bộ, dùng saga hoặc outbox giữa service |
| Scale | Scale toàn ứng dụng | Scale toàn ứng dụng | Scale từng service |
| Vận hành | Chi phí thấp nhất | Thấp đến vừa | Cần platform và observability lớn nhất |
Modular monolith không phải trạng thái tạm bợ trong lúc chờ chuyển sang service. Với nhiều sản phẩm, đây là đích đến bền vững: một runtime, một pipeline phát hành, debug trực tiếp và ranh giới khái niệm mạnh. Đổi lại, memory leak, CPU spike hay deploy lỗi có thể ảnh hưởng toàn ứng dụng; từng module cũng không thể scale riêng.
Tip
Hãy chọn modular monolith phù hợp với năng lực vận hành hiện tại, rồi giữ quyền lựa chọn cho tương lai bằng ownership và contract. Đừng mua độ phức tạp của hệ phân tán chỉ để chứng minh tham vọng kiến trúc.
Ranh giới tốt đi theo năng lực nghiệp vụ như Catalog, Billing và Orders, không theo nhóm kỹ thuật như Controllers, Services và Repositories. Một năng lực phải đổi được phần bên trong mà không kéo thành phần không liên quan đi cùng.
Biến module thành chủ sở hữu, không chỉ là thư mục
Cây thư mục chỉ là tài liệu nếu không có cơ chế giới hạn truy cập. Một module đúng nghĩa sở hữu quyết định, code và dữ liệu. Orders sở hữu trạng thái đơn hàng cùng các bảng tương ứng; Billing sở hữu lần thanh toán và hoàn tiền; Catalog sở hữu sản phẩm và giá. Module khác có thể yêu cầu Orders hủy đơn, nhưng không được tự update hàng dữ liệu hay import repository nội bộ của Orders.
Tách ownership dữ liệu không đòi hỏi ba database server. PostgreSQL schema, tiền tố bảng hoặc thư mục migration riêng đã làm ownership hiện rõ trong cùng database; permission có thể cưỡng chế thêm. Quy tắc cốt lõi là chỉ code của module được đọc và ghi bảng của nó. Báo cáo liên module nên dùng read model, projection hoặc analytical query được quản trị, không biến schema riêng thành API chung.
Public surface nên mô tả thao tác nghiệp vụ và dữ liệu ổn định, không để lộ cơ chế persistence. Ví dụ dưới đây giữ Orders khá nhỏ. Consumer chỉ khởi tạo và gọi qua public.ts; repository và orchestration nằm hoàn toàn ở phần private.
// src/modules/orders/public.ts
import { buildOrders } from './internal/build-orders.js';
export type OrderId = string & { readonly orderId: unique symbol };
export interface PlaceOrder {
customerId: string;
lines: ReadonlyArray<{ productId: string; quantity: number }>;
}
export interface OrderView {
id: OrderId;
status: 'pending' | 'confirmed' | 'rejected';
totalCents: number;
}
export interface Orders {
place(command: PlaceOrder): Promise<OrderView>;
find(id: OrderId): Promise<OrderView | undefined>;
}
export interface OrdersDependencies {
prices: {
quote(lines: PlaceOrder['lines']): Promise<number>;
};
payments: {
authorize(customerId: string, amountCents: number): Promise<boolean>;
};
transaction<T>(work: () => Promise<T>): Promise<T>;
}
export function createOrders(dependencies: OrdersDependencies): Orders {
return buildOrders(dependencies);
}
// src/modules/orders/internal/build-orders.ts
import type {
OrderView,
Orders,
OrdersDependencies,
PlaceOrder,
} from '../public.js';
import { createOrderRepository } from './order-repository.js';
export function buildOrders(dependencies: OrdersDependencies): Orders {
const repository = createOrderRepository();
return {
async place(command: PlaceOrder): Promise<OrderView> {
const totalCents = await dependencies.prices.quote(command.lines);
const authorized = await dependencies.payments.authorize(
command.customerId,
totalCents,
);
return dependencies.transaction(async () =>
repository.insert({
command,
totalCents,
status: authorized ? 'confirmed' : 'rejected',
}),
);
},
find: (id) => repository.find(id),
};
}
Dependency interface thuộc về Orders vì nó diễn tả điều Orders cần, không phơi bày toàn bộ Catalog hay Billing. Ở composition root, adapter nối các port hẹp với public API tương ứng. Refactor nội bộ vì thế không lan rộng; test chỉ cần fake nhỏ. Contract in-process vẫn cần tên rõ, input bất biến, lỗi tường minh và kỷ luật tương thích, nhưng không phải gánh serialization, retry hay network telemetry.
Cưỡng chế chiều dependency ngay trong build
Kiến trúc dựa vào trí nhớ sẽ xuống cấp khi deadline tới. Hãy quyết định đồ thị dependency rồi khiến vi phạm thất bại cả ở local lẫn CI. Trong sơ đồ trên, Orders phụ thuộc vào Catalog và Billing; chiều ngược lại bị cấm. Dependency vòng làm ownership và thứ tự khởi tạo trở nên mơ hồ.
Script TypeScript sau dùng parser của compiler thay cho regex. Nó kiểm tra mọi import tương đối đi qua ranh giới module: đích đến phải là public.js, và cạnh dependency phải có trong allowlist.
// scripts/check-module-dependencies.ts
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import ts from 'typescript';
const modulesRoot = path.resolve('src/modules');
const allowed = {
catalog: [],
billing: [],
orders: ['catalog', 'billing'],
} as const;
type ModuleName = keyof typeof allowed;
async function sourceFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(
entries.map((entry) => {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) return sourceFiles(entryPath);
return entry.name.endsWith('.ts') ? [entryPath] : [];
}),
);
return nested.flat();
}
function moduleName(file: string): ModuleName | undefined {
const [name] = path.relative(modulesRoot, file).split(path.sep);
return name in allowed ? (name as ModuleName) : undefined;
}
function importedFile(source: string, specifier: string): string | undefined {
if (!specifier.startsWith('.')) return undefined;
return path.resolve(path.dirname(source), specifier.replace(/\.js$/, '.ts'));
}
const violations: string[] = [];
for (const file of await sourceFiles(modulesRoot)) {
const sourceModule = moduleName(file);
const text = await readFile(file, 'utf8');
const ast = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
ast.forEachChild((node) => {
if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) return;
const targetFile = importedFile(file, node.moduleSpecifier.text);
const targetModule = targetFile && moduleName(targetFile);
if (!sourceModule || !targetModule || sourceModule === targetModule) return;
const publicOnly = path.basename(targetFile) === 'public.ts';
const directionAllowed = (allowed[sourceModule] as readonly string[]).includes(targetModule);
if (!publicOnly || !directionAllowed) {
violations.push(`${path.relative('.', file)} -> ${node.moduleSpecifier.text}`);
}
});
}
if (violations.length > 0) {
console.error(`Module boundary violations:\n${violations.join('\n')}`);
process.exitCode = 1;
}
Hãy chạy bước kiểm tra này cùng typecheck và unit test. Codebase lớn có thể dùng dependency-cruiser, tag của Nx, ESLint boundaries hoặc visibility của ngôn ngữ; nguyên tắc vẫn không đổi: biến đồ thị mong muốn thành một luật có thể thực thi. Allowlist cũng cần được review. Build xanh vẫn có thể che giấu kiến trúc tệ nếu module nào cũng được phép phụ thuộc vào mọi module khác.
Giữ transaction cục bộ, làm workflow tường minh
Database chung khiến việc bọc Orders, Billing và Inventory trong một ACID transaction rất dễ. Đừng lấy đó làm mặc định. Transaction vượt ownership ghép chặt schema, lock và xử lý lỗi, đồng thời tạo ra contract không thể sống sót khi module được tách.
Transaction cục bộ bảo vệ invariant của một module: tạo đơn cùng các dòng hàng, ghi payment cùng ledger entry, hoặc đổi tồn kho cùng inventory movement. Với workflow liên module, lời gọi đồng bộ phù hợp khi cần kết quả ngay; event phù hợp cho phản ứng sau commit như gửi xác nhận.
Để event tin cậy, ghi state và outbox record trong cùng transaction. Background publisher chuyển event; consumer phải idempotent. Hôm nay publisher có thể gọi handler trong process, ngày mai đổi sang broker mà không định nghĩa lại business event hay giả vờ có exactly-once delivery.
Warning
Đừng mô phỏng microservices bằng cách dùng in-memory event bus cho mọi tương tác. Control flow bất đồng bộ bị che giấu khó debug hơn một lời gọi hàm. Chỉ dùng event cho nhu cầu tách rời thật sự và phản ứng sau commit, không dùng nó để né tránh việc gọi tên dependency.
Nếu thanh toán thành công nhưng giữ hàng thất bại, workflow có thể chuyển sang refund_pending, yêu cầu hoàn tiền rồi tiếp tục an toàn sau khi process crash.
Test module độc lập, deploy ứng dụng thống nhất
Unit test kiểm tra domain rule bằng value thuần. Module test gọi public API với repository thật trên schema dùng một lần. Contract test khóa giả định giữa port và adapter ở composition root; một tập application test nhỏ đi qua workflow liên module quan trọng.
Đừng assert lời gọi private hoặc import repository của module khác vào test. Hãy ưu tiên hành vi qua public API; private test chỉ nằm trong module sở hữu. Architecture test cần chặn import cấm, còn permission hoặc migration check có thể bảo vệ ownership bảng.
Deployment nên đơn giản có chủ ý: build một artifact, chạy migration có thứ tự, deploy và giám sát như một ứng dụng. Log, metric và trace vẫn cần nhãn module để quy lỗi latency cho Billing thay vì endpoint chung. Feature flag và migration tương thích ngược giảm rủi ro dù chỉ có một release train.
Một artifact vẫn có thể chạy nhiều replica sau load balancer; cùng codebase có thể chạy web và worker ở mode khác nhau. Nhưng một module không dễ có runtime, security boundary, nhịp phát hành hay đường cong scale riêng. Khi nhu cầu ấy có chi phí thật, tách service mới là quyết định kinh tế.
Chỉ tách khi ranh giới đáng để đi qua mạng
Một module tốt là ứng viên cho service, không phải service bị trì hoãn. Khi tách, giữ public contract, thay adapter in-process bằng remote client và chuyển các bảng thuộc ownership ra sau service mới. Outbox đi lên broker; tracing vượt network hop; bên gọi cần deadline, retry, circuit breaker, idempotency và fallback. Ranh giới nghiệp vụ còn nguyên, contract vận hành thì khắt khe hơn.
Quá trình này hiếm khi hoàn toàn cơ học. Contract in-process có thể chứa rich type hoặc giả định latency thấp không phù hợp với wire protocol. Workflow từng dựa vào một database cần compensation rõ ràng. Báo cáo cần projection thay vì join. Modular monolith giúp các dependency ấy lộ diện sớm, chứ không xóa bỏ công việc chuyển đổi.
Microservices có lý khi deploy độc lập giải quyết một nút thắt tổ chức có thật; khi một năng lực có profile scale hoặc availability khác biệt rõ; khi quy định bắt buộc cô lập; hoặc khi các team tự chủ có thể sở hữu trọn vẹn build, dữ liệu, vận hành và incident. Lập luận sẽ yếu hơn nhiều với team nhỏ, domain còn biến động, traffic thấp, hoặc sản phẩm có các tính năng luôn thay đổi cùng nhau.
Sự phù hợp với tổ chức quan trọng không kém code. Modular monolith hoạt động tốt khi các team thống nhất ownership và chia sẻ release pipeline. Nó thất bại nếu module chỉ là thư mục, sự khéo léo xuyên tầng được đánh giá cao hơn ranh giới, hoặc deadline thường xuyên trở thành lý do import private. Nó cũng thất bại khi một nhóm kiến trúc trung tâm sở hữu mọi contract còn product team không có quyền tiến hóa module của mình.
Cần nhận ra các failure mode điển hình: module common biến thành hố hút dependency; đọc bảng xuyên module; API để lộ ORM entity; lời gọi hai chiều; global event không có owner; và composition root chứa business logic. Mỗi dấu hiệu cho thấy một quyết định đã thoát khỏi chủ sở hữu. Hãy sửa ownership trước khi vẽ sơ đồ triển khai phức tạp hơn.
Kết luận
Modular monolith tách hai quyết định vốn thường bị buộc chung: code và dữ liệu được chia thế nào, và phần mềm được triển khai ra sao. Hãy bắt đầu bằng module theo năng lực nghiệp vụ. Trao cho mỗi module quyền sở hữu dữ liệu độc quyền, một public API hẹp và vị trí rõ trong đồ thị dependency không vòng. Cưỡng chế quy tắc bằng công cụ, không chỉ bằng tài liệu hay thiện chí.
Giữ invariant và transaction bên trong một owner. Mô hình hóa công việc liên module bằng contract đồng bộ tường minh hoặc workflow bền vững, tùy semantics. Test module qua public surface, rồi test ứng dụng đã lắp ráp ở nơi có rủi ro tích hợp thật. Tiếp tục deploy một artifact có observability tốt cho tới khi scale, cô lập hay quyền tự chủ của team đủ giá trị để trả chi phí của network và distributed failure.
Lựa chọn ở giữa mang tính thực dụng vì nó không giả định rằng ta biết chắc tương lai. Nó cho đội ngũ sự đơn giản để giao hàng hôm nay, cấu trúc đủ tốt để tiếp tục thay đổi, và ranh giới đủ vững để tách service khi bằng chứng thực tế cuối cùng cho thấy điều đó là cần thiết.