Mở đầu — vì sao bài này quan trọng
Ở Bài 12, chúng ta đã làm quen với Domain-Driven Design (DDD) và ý tưởng lớn nhất của nó: phần mềm phức tạp không thất bại vì thiếu công nghệ, mà thất bại vì lập trình viên và người kinh doanh nói hai thứ ngôn ngữ khác nhau. Bài 13 này đi vào công cụ quan trọng bậc nhất mà DDD trao cho một kiến trúc sư: Bounded Context (ngữ cảnh có ranh giới).
Nếu bạn từng ngồi trong một buổi họp mà người làm Marketing nói "khách hàng", người làm Kế toán nói "khách hàng", người làm Vận hành giao hàng cũng nói "khách hàng" — và cả ba đang nói về ba thứ hoàn toàn khác nhau — thì bạn đã trực tiếp chạm vào vấn đề mà Bounded Context sinh ra để giải quyết. Đây không phải là chuyện học thuật. Đây là lý do vì sao rất nhiều hệ thống lớn sụp đổ dưới sức nặng của chính nó: người ta cố nhồi mọi ý nghĩa của một từ vào cùng một cái bảng dữ liệu, cùng một class Customer, và class đó phình to đến mức không ai dám sửa.
Với vai trò một người ra quyết định kiến trúc, Bounded Context là ranh giới mà bạn dùng để chia một hệ thống lớn thành các phần có thể quản lý được. Nó quyết định team nào sở hữu cái gì, service nào tách khỏi service nào, và ở đâu bạn được phép thay đổi mà không làm vỡ chỗ khác. Hiểu sai Bounded Context, bạn sẽ chia nhầm — và mọi quyết định về microservice, database, hay API sau này (những bài sau của khóa) đều xây trên nền móng sai đó.
Khái niệm cốt lõi
Định nghĩa: ranh giới của ý nghĩa
Bounded Context là một ranh giới rõ ràng bên trong đó một mô hình (model) có một ý nghĩa nhất quán và chính xác. Bên ngoài ranh giới đó, cùng một cái tên có thể mang một mô hình hoàn toàn khác.
Hãy đọc lại câu trên thật chậm, vì nó chứa toàn bộ tinh thần của bài. Chìa khóa không phải là "chia nhỏ hệ thống" — mà là thừa nhận rằng cùng một từ có nhiều nghĩa tùy theo bối cảnh, và thay vì cố ép chúng thành một, ta vẽ ranh giới để mỗi nghĩa được sống trong không gian riêng của nó.
Trong ngôn ngữ tự nhiên, con người xử lý điều này rất giỏi. Khi bác sĩ nói "bệnh nhân" và luật sư nói "bệnh nhân" (như một thân chủ), không ai nhầm lẫn vì ngữ cảnh đã rõ. Nhưng phần mềm thì ngây thơ: nếu bạn tạo một bảng patients dùng chung, hệ thống bệnh án và hệ thống thanh toán sẽ giành nhau định nghĩa cột nào là quan trọng, và bạn kết thúc với một cái bảng 80 cột mà nửa số cột luôn NULL.
Ubiquitous Language sống bên trong ranh giới
Ở Bài 12, ta nói về Ubiquitous Language — ngôn ngữ chung giữa dev và business. Điểm mấu chốt mà nhiều người bỏ sót: ngôn ngữ chung đó chỉ chung bên trong một Bounded Context. Không tồn tại một ngôn ngữ chung cho toàn bộ công ty.
Nói cách khác: Bounded Context chính là phạm vi hiệu lực của một Ubiquitous Language. Từ "Đơn hàng" trong ngữ cảnh Bán hàng có nghĩa "giỏ hàng đã chốt, chờ thanh toán". Từ "Đơn hàng" trong ngữ cảnh Kho vận có nghĩa "danh sách các món cần lấy ra khỏi kệ và đóng gói". Chúng liên quan nhau nhưng không giống nhau, và ép chúng thành một class duy nhất là sai lầm kiến trúc kinh điển.
Ví dụ kinh điển: "Customer" trong e-commerce
Đây là ví dụ mà mọi người học DDD đều phải nắm. Hãy tưởng tượng một sàn thương mại điện tử như Tiki hay Shopee. Từ "Customer" (khách hàng) xuất hiện trong nhiều ngữ cảnh, và trong mỗi ngữ cảnh nó mang một mô hình khác nhau:
| Bounded Context | "Customer" nghĩa là gì | Thuộc tính quan trọng |
|---|---|---|
| Sales / Ordering (Bán hàng) | Người đặt hàng | Giỏ hàng, lịch sử mua, hạng thành viên |
| Billing / Payment (Thanh toán) | Người trả tiền | Phương thức thanh toán, hạn mức tín dụng, hóa đơn VAT |
| Shipping (Giao vận) | Người nhận hàng | Địa chỉ giao, số điện thoại, ghi chú tài xế |
| Support (CSKH) | Người cần hỗ trợ | Lịch sử ticket, mức độ ưu tiên, kênh liên hệ |
| Marketing | Đối tượng nhắm tới | Phân khúc, hành vi click, opt-in email |
Customer đúng cho tất cả. Trong Shipping, người ta thậm chí không quan tâm khách hàng có hạng Vàng hay Bạc — họ chỉ cần địa chỉ và số điện thoại người nhận (mà đôi khi còn khác người đặt, ví dụ mua tặng). Trong Billing, hạng thành viên vô nghĩa nhưng hạn mức công nợ lại sống còn.Nếu bạn cố nhét tất cả vào một Customer khổng lồ, class đó sẽ có hàng chục thuộc tính, phụ thuộc vào hàng chục module, và trở thành thứ mà giới lập trình gọi là "God Object" — không ai dám đụng vào vì sửa một chỗ vỡ mười chỗ.
Ranh giới không phải là bảng dữ liệu
Một hiểu lầm phổ biến: nghĩ Bounded Context là một cái bảng hay một cái schema. Không. Bounded Context là ranh giới của mô hình và ngôn ngữ, thường (nhưng không bắt buộc) ánh xạ sang: một module code riêng, một team sở hữu, và về sau có thể là một microservice hoặc một schema database riêng.
Ranh giới đó tồn tại ở tầng khái niệm trước, tầng kỹ thuật sau. Bạn vẽ ranh giới dựa trên ngôn ngữ nghiệp vụ, rồi mới quyết định hiện thực nó bằng module hay service.
Context Map — bức tranh các ranh giới nối với nhau
Khi đã có nhiều Bounded Context, câu hỏi tiếp theo là: chúng nói chuyện với nhau thế nào? DDD gọi bức tranh đó là Context Map. Ta sẽ không đi sâu vào các kiểu quan hệ (đó là chủ đề nâng cao), nhưng cần nhớ vài kiểu thường gặp:
- Shared Kernel: hai context dùng chung một phần model nhỏ (rủi ro cao, cần đồng thuận chặt).
- Customer/Supplier: context này phụ thuộc context kia, bên "trên" phải đáp ứng nhu cầu bên "dưới".
- Anti-Corruption Layer (ACL): context của bạn tự dựng một lớp dịch để không bị mô hình của bên ngoài "làm bẩn" mô hình của mình — cực kỳ hữu ích khi tích hợp hệ thống cũ (legacy) hoặc bên thứ ba.
Tình huống thực tế
Tình huống 1 — Sàn TMĐT Việt Nam và cái bảng users 60 cột
Một startup TMĐT ở TP.HCM (gọi là "ChợViệt", giả định) khởi đầu với một monolith và một bảng users duy nhất. Ban đầu ổn: khách đăng ký, mua hàng, nhận hàng. Sau hai năm, bảng users phình lên 62 cột: từ loyalty_tier, credit_limit, default_shipping_address, marketing_opt_in, cho tới last_support_ticket_id và preferred_courier.
Vấn đề bùng nổ khi họ mở tính năng "mua hộ/giao hộ": người đặt hàng (con) khác người nhận hàng (bố mẹ ở quê). Cái bảng users không mô tả nổi tình huống này, vì nó ngầm giả định "một người dùng = một người đặt = một người nhận". Team Shipping cần một model "người nhận" độc lập, nhưng model đó lại bị khóa chặt trong class User mà team Sales và Marketing cũng đang dùng. Mỗi lần Shipping muốn đổi, họ phải xin phép hai team khác và chạy lại toàn bộ test của Sales.
Cách họ tháo gỡ: Kiến trúc sư ngồi lại vẽ Context Map và tách thành các Bounded Context: Sales, Billing, Shipping, Marketing. Trong Shipping, họ tạo model Recipient (người nhận) hoàn toàn tách khỏi Customer bên Sales. Hai model liên kết qua một customer_id chung, nhưng mỗi context sở hữu và định nghĩa dữ liệu của riêng nó. Bảng 62 cột được xẻ thành bốn nhóm nhỏ, mỗi nhóm một team làm chủ.
Bài học: Khi một class hay một bảng có quá nhiều thuộc tính "chỉ vài module quan tâm", đó là tín hiệu bạn đang nhồi nhiều Bounded Context vào một chỗ. Đừng chia theo dữ liệu — chia theo ai dùng nó để làm gì.
Tình huống 2 — Grab và từ "Trip" mang nhiều nghĩa
Ở một nền tảng gọi xe kiểu Grab, hãy xét từ "chuyến đi" (Trip). Nghe thì đơn giản, nhưng trong hệ thống nó sống trong ít nhất ba ngữ cảnh khác nhau:
- Trong Matching/Dispatch (ghép tài xế): "Trip" là một yêu cầu đang chờ ghép, gắn với vị trí GPS thời gian thực, bán kính tìm tài xế, thuật toán ước lượng ETA.
- Trong Pricing (tính giá): "Trip" là một tập các yếu tố để tính cước — quãng đường, thời gian, hệ số giờ cao điểm, khuyến mãi áp dụng.
- Trong Accounting/Payout (đối soát, chi trả tài xế): "Trip" là một giao dịch đã hoàn tất, gắn với số tiền, phần chia cho tài xế, thuế, và trạng thái thanh toán.
Trip chung, thì mỗi lần team Pricing đổi công thức tính hệ số giờ cao điểm, họ có nguy cơ làm vỡ luồng đối soát tài chính — hai chuyện chẳng liên quan gì nhau về mặt nghiệp vụ. Bằng cách tách thành các Bounded Context, mỗi team có model Trip riêng, deploy độc lập, và giao tiếp với nhau qua sự kiện (ví dụ TripCompleted) thay vì chia sẻ trực tiếp class.Bài học: Ranh giới đúng cho phép các team thay đổi độc lập. Nếu đổi một chỗ luôn kéo theo phải sửa và test chỗ khác không liên quan nghiệp vụ, ranh giới của bạn đang sai.
Tình huống 3 — Ngân hàng số và Anti-Corruption Layer với core banking cũ
Một ngân hàng số ở Đông Nam Á xây app mới hiện đại, nhưng lõi vẫn là hệ thống core banking đời cũ với model "Account" (tài khoản) cực kỳ phức tạp: mã tài khoản 20 ký tự, hàng loạt trường mã hóa theo chuẩn nội bộ từ những năm 2000, và ngôn ngữ nghiệp vụ hoàn toàn khác với app hiện đại.
Nếu team app mới để model cũ "tràn" vào code của mình, họ sẽ phải mang theo toàn bộ sự rắc rối của hệ thống 20 năm tuổi. Giải pháp: họ tạo Bounded Context riêng cho phần "Digital Banking" của mình, với model Account sạch, đơn giản, đúng ngôn ngữ hiện đại. Giữa hai context, họ dựng một Anti-Corruption Layer — một lớp dịch nhận dữ liệu từ core cũ và chuyển thành model mới, và ngược lại. Nhờ vậy, khi core banking cũ đổi (hiếm khi, nhưng có), chỉ lớp ACL bị ảnh hưởng, còn toàn bộ nghiệp vụ app mới vẫn nguyên vẹn.
Bài học: Bounded Context không chỉ chia hệ thống của bạn, nó còn bảo vệ mô hình của bạn khỏi mô hình của người khác. Đây là lá chắn sống còn khi tích hợp hệ thống legacy hoặc bên thứ ba.
Hướng dẫn từng bước
Dưới đây là quy trình thực dụng để tìm và vẽ Bounded Context cho một hệ thống.
Bước 1 — Thu thập ngôn ngữ nghiệp vụ. Ngồi với người kinh doanh và liệt kê các danh từ, động từ nghiệp vụ quan trọng: khách hàng, đơn hàng, thanh toán, giao hàng, hoàn tiền... Đây là nguyên liệu thô.
Bước 2 — Săn tìm từ "đa nghĩa". Với mỗi danh từ quan trọng, hỏi: "Từ này có nghĩa khác nhau ở các phòng ban khác nhau không?" Nếu Sales hiểu "Đơn hàng" khác với Kho, bạn vừa tìm thấy đường biên giới giữa hai Bounded Context. Những từ đa nghĩa chính là chỉ dấu mạnh nhất cho ranh giới.
Bước 3 — Nhóm theo năng lực nghiệp vụ (business capability). Hỏi: "Phần này của hệ thống phục vụ mục tiêu nghiệp vụ nào?" Bán hàng, thu tiền, giao vận, chăm sóc khách... Mỗi năng lực nghiệp vụ thường ứng với một Bounded Context. Đây là cách chia đúng — chia theo nghiệp vụ, không chia theo tầng kỹ thuật (đừng tạo context "Database", context "UI").
Bước 4 — Đối chiếu với cấu trúc team. Theo Định luật Conway, hệ thống có xu hướng phản ánh cấu trúc giao tiếp của tổ chức. Một Bounded Context lý tưởng nên vừa với một team có thể sở hữu trọn vẹn nó (thường 5–9 người). Nếu một context cần ba team cùng sửa, nó quá to; nếu một team ôm sáu context, có thể bạn chia quá vụn.
Bước 5 — Vẽ Context Map. Vẽ các context như những hình tròn, nối chúng bằng mũi tên chỉ quan hệ và hướng phụ thuộc. Đánh dấu chỗ nào cần Anti-Corruption Layer (thường là ranh giới với hệ thống cũ hoặc bên thứ ba).
Bước 6 — Định nghĩa hợp đồng (contract) tại ranh giới. Với mỗi cặp context giao tiếp, chốt rõ: dữ liệu gì đi qua, dưới dạng gì (API, event), và ai chịu trách nhiệm dịch. Bên trong context, bạn tự do; qua ranh giới, phải có hợp đồng rõ ràng.
Bước 7 — Chỉ khi đó mới bàn tới hiện thực kỹ thuật. Module trong monolith? Schema riêng? Microservice? Đó là quyết định đến sau — và các bài 16–18 sẽ giúp bạn chọn. Bounded Context tồn tại độc lập với việc bạn triển khai nó thế nào.
Lỗi thường gặp & mẹo
Lỗi 1 — Nhầm Bounded Context với microservice. Đây là hiểu lầm phổ biến nhất. Bounded Context là khái niệm về thiết kế và ngôn ngữ; microservice là quyết định triển khai. Một Bounded Context có thể được hiện thực bằng một module trong monolith. Nhiều team lao vào tách microservice trước khi hiểu ranh giới nghiệp vụ, và kết quả là các "microservice" chia nhầm, nói chuyện với nhau chằng chịt — cái mà người ta gọi là "distributed monolith", tệ hơn cả monolith ban đầu.
Lỗi 2 — Chia theo tầng kỹ thuật thay vì nghiệp vụ. Tạo context "Frontend", "Backend", "Database" là sai. Ranh giới phải theo năng lực nghiệp vụ (Sales, Billing), không theo lớp kỹ thuật.
Lỗi 3 — Cố tạo một model chung cho toàn hệ thống. Ước mơ về một "Canonical Data Model" duy nhất cho cả công ty nghe hấp dẫn nhưng luôn thất bại. Nó buộc mọi context phải đồng ý về mọi thứ, biến mọi thay đổi nhỏ thành cuộc họp liên phòng ban. Chấp nhận rằng "Customer" có nhiều nghĩa là dấu hiệu bạn đã trưởng thành về kiến trúc.
Lỗi 4 — Chia quá vụn. Ngược lại, tạo hàng chục context tí hon cho từng thực thể (context "Address", context "PhoneNumber") tạo ra chi phí điều phối khổng lồ. Ranh giới nên bọc một năng lực nghiệp vụ có ý nghĩa, đủ để một team sở hữu.
Mẹo 1 — Dùng phép thử "hai người, hai nghĩa". Nếu hai phòng ban dùng cùng một từ nhưng vẽ nó khác nhau lên bảng, đó gần như chắc chắn là ranh giới giữa hai Bounded Context.
Mẹo 2 — Ranh giới nên vừa với một team. Nếu bạn phải triệu tập nhiều team để đổi một context, nó quá to. Nếu một người phải nhảy giữa nhiều context để làm một việc, có thể quá vụn.
Mẹo 3 — Ưu tiên Anti-Corruption Layer khi chạm hệ thống cũ. Đừng để model của người khác (legacy, vendor) tràn vào và làm bẩn model sạch của bạn. Chi phí dựng ACL luôn rẻ hơn chi phí gỡ rối về sau.
Bài tập thực hành
Hãy chọn một hệ thống bạn đang làm hoặc quen thuộc (hoặc dùng một sàn TMĐT giả định) và thực hiện:
- Liệt kê 10 danh từ nghiệp vụ quan trọng nhất của hệ thống (ví dụ: khách hàng, đơn hàng, sản phẩm, thanh toán, giao hàng...).
- Săn từ đa nghĩa: với mỗi danh từ, viết ra ít nhất một trường hợp mà hai phòng ban khác nhau hiểu nó khác nhau. Đánh dấu những từ có từ hai nghĩa trở lên.
- Đề xuất 4–6 Bounded Context theo năng lực nghiệp vụ. Với mỗi context, viết một câu định nghĩa "trong context này, [từ đa nghĩa] có nghĩa là ___".
- Vẽ Context Map: vẽ các context và nối chúng, chỉ rõ context nào phụ thuộc context nào, và đánh dấu ít nhất một chỗ bạn sẽ đặt Anti-Corruption Layer (thường là ranh giới với thanh toán bên thứ ba hoặc hệ thống cũ).
- Kiểm tra bằng cấu trúc team: đối chiếu số context với số team hiện có. Có context nào cần nhiều hơn một team không? Có team nào ôm quá nhiều context không? Ghi lại điều bạn sẽ điều chỉnh.
Tóm tắt
- Bounded Context là ranh giới bên trong đó một mô hình có ý nghĩa nhất quán; bên ngoài ranh giới, cùng một cái tên có thể mang mô hình khác. Nó là phạm vi hiệu lực của Ubiquitous Language.
- Ví dụ "Customer" trong e-commerce cho thấy: không có một class đúng cho tất cả — Sales, Billing, Shipping, Support, Marketing đều có model "khách hàng" riêng. Cố nhồi tất cả vào một chỗ tạo ra "God Object" không ai dám sửa.
- Từ đa nghĩa là chỉ dấu mạnh nhất cho ranh giới. Chia theo năng lực nghiệp vụ, không chia theo tầng kỹ thuật.
- Ranh giới đúng cho phép các team thay đổi độc lập. Nếu sửa một chỗ luôn làm vỡ chỗ khác không liên quan nghiệp vụ, ranh giới đang sai.
- Context Map mô tả cách các context liên kết; Anti-Corruption Layer bảo vệ model của bạn khỏi model của hệ thống cũ hoặc bên thứ ba.
- Bounded Context là quyết định thiết kế, không phải quyết định triển khai: đừng nhầm nó với microservice. Vẽ ranh giới trước, chọn cách hiện thực (module, schema, service) sau.