Mở đầu — vì sao bài này quan trọng
Hãy hình dung một buổi họp mà bạn từng dự. Bên business nói về "khách hàng", "đơn hàng", "giỏ hàng". Còn bên kỹ thuật lại nói về "user_id", "transaction table", "cart_items JSON". Cả hai bên đều gật gù như thể hiểu nhau, nhưng đến khi sản phẩm ra mắt, hóa ra cái mà business gọi là "đơn hàng đã hoàn tất" lại khác hoàn toàn với cái mà code đánh dấu là order.status = completed. Kết quả là một chuỗi bug âm ỉ, những cuộc tranh cãi vòng vo, và một hệ thống mà không ai dám sửa vì không ai thực sự hiểu nó phản ánh nghiệp vụ gì.
Đây chính xác là vấn đề mà Domain-Driven Design (DDD) ra đời để giải quyết. Trong hành trình của một người ra quyết định kiến trúc, bạn sẽ sớm nhận ra rằng phần khó nhất của phần mềm doanh nghiệp không phải là chọn database hay message broker — những thứ đó có bài học riêng phía sau. Phần khó nhất là làm cho phần mềm phản ánh đúng nghiệp vụ, và giữ cho nó tiếp tục phản ánh đúng khi nghiệp vụ thay đổi. Một hệ thống được thiết kế kỹ thuật xuất sắc nhưng hiểu sai domain thì vẫn là một hệ thống sai.
Bài này là cánh cửa đầu tiên bước vào DDD. Chúng ta sẽ không đi sâu vào cách chia hệ thống (đó là Bounded Context ở bài sau) hay các kiểu kiến trúc như Hexagonal, Onion — những chủ đề đó có bài riêng. Ở đây, mục tiêu của tôi là giúp bạn hiểu DDD thực chất là gì, nó giải quyết vấn đề gì, và ba ý tưởng cốt lõi làm nền tảng cho mọi thứ về sau. Nắm chắc phần nền này, những bài kế tiếp sẽ trở nên dễ thở hơn rất nhiều.
Khái niệm cốt lõi
DDD ra đời từ đâu
Domain-Driven Design được Eric Evans giới thiệu chính thức trong cuốn sách cùng tên xuất bản năm 2003 (thường được cộng đồng gọi là "Blue Book"). Điều quan trọng cần hiểu ngay: DDD không phải là một framework, không phải một thư viện bạn cài về, cũng không phải một danh sách các pattern kỹ thuật cần thuộc lòng. DDD là một cách tiếp cận (approach) để thiết kế phần mềm sao cho mô hình phần mềm khớp với domain nghiệp vụ mà nó phục vụ.
"Domain" ở đây nghĩa là lĩnh vực nghiệp vụ — vấn đề thực tế mà phần mềm giải quyết. Với một sàn thương mại điện tử như Tiki hay Shopee, domain là mua bán trực tuyến: sản phẩm, đơn hàng, thanh toán, vận chuyển, hoàn trả. Với một ngân hàng số như Timo hay Cake, domain là tài khoản, giao dịch, hạn mức, tuân thủ quy định. DDD nói rằng: thay vì để cấu trúc kỹ thuật (database schema, framework) dẫn dắt thiết kế, hãy để chính domain dẫn dắt — thiết kế bị điều khiển (driven) bởi domain.
Nghe qua thì hiển nhiên, nhưng trong thực tế phần lớn dự án làm ngược lại. Chúng ta bắt đầu bằng việc vẽ bảng CSDL, rồi dựng CRUD, rồi mới nhét nghiệp vụ vào các chỗ trống. Kết quả là code phản ánh cấu trúc bảng chứ không phản ánh cách business tư duy.
Ba ý tưởng cốt lõi
DDD có rất nhiều khái niệm, nhưng ở tầng nhập môn, bạn chỉ cần nắm vững ba ý tưởng nền tảng. Mọi thứ khác đều mọc lên từ ba trụ cột này.
#### 1. Ubiquitous Language — Ngôn ngữ chung
Đây là ý tưởng trung tâm nhất, và cũng dễ bị xem nhẹ nhất. "Ubiquitous" nghĩa là "hiện diện ở khắp mọi nơi". Ubiquitous Language là một bộ từ vựng chung, thống nhất giữa lập trình viên và chuyên gia nghiệp vụ (domain expert), được dùng nhất quán trong mọi cuộc trò chuyện, tài liệu, sơ đồ, và quan trọng nhất là trong chính code.
Nguyên tắc là: nếu business gọi một khái niệm là "Vận đơn" (waybill), thì trong code cũng phải có class tên Waybill, chứ không phải ShippingRecord hay DeliveryData do lập trình viên tự đặt. Nếu trạng thái đơn hàng theo nghiệp vụ là "Chờ lấy hàng", "Đang giao", "Giao thành công", "Hoàn" — thì enum trong code phải phản ánh đúng những trạng thái đó với đúng tên gọi, không phải một tập trạng thái do dev tự chế ra cho tiện.
Vì sao điều này quan trọng đến vậy? Bởi vì mỗi lần một khái niệm nghiệp vụ được "dịch" sang một tên khác trong code, ta tạo ra một điểm mất mát thông tin. Sau vài lớp dịch như vậy, code không còn kể cùng một câu chuyện với business nữa. Ubiquitous Language loại bỏ lớp dịch đó: từ ngữ trong đầu domain expert, từ ngữ trong tài liệu, và từ ngữ trong code là một và giống nhau.
Một điểm tinh tế: Ubiquitous Language không phải là ngôn ngữ toàn công ty dùng chung một nghĩa. Nó gắn với một ngữ cảnh cụ thể. Từ "Khách hàng" trong bộ phận Bán hàng có thể mang nghĩa khác với "Khách hàng" trong bộ phận Chăm sóc sau bán — nhưng đó là câu chuyện của Bounded Context ở bài sau. Ở bài này, bạn chỉ cần nắm: trong một phạm vi nhất định, ngôn ngữ phải thống nhất tuyệt đối.
#### 2. Model-Driven Design — Thiết kế dựa trên mô hình
Ý tưởng thứ hai: mô hình domain (domain model) và code phải là một. Domain model là biểu diễn tinh gọn của tri thức nghiệp vụ — các khái niệm, quy tắc, mối quan hệ. Trong DDD, mô hình này không phải một sơ đồ UML vẽ xong rồi bỏ tủ. Nó sống trong code: các class, method, và mối quan hệ giữa chúng chính là hiện thân của mô hình.
Điều này có nghĩa là khi domain expert nói "một đơn hàng đã thanh toán thì không được phép hủy, chỉ được yêu cầu hoàn tiền", thì quy tắc đó phải hiện diện rõ ràng trong code — chẳng hạn method order.cancel() phải ném ra exception nếu đơn đã thanh toán. Logic nghiệp vụ không được nằm rải rác trong controller hay service ngẫu nhiên, mà tập trung trong chính các đối tượng domain, nơi nó thuộc về.
Khi mô hình và code là một, ta có một vòng phản hồi quý giá: mỗi khi hiểu sâu hơn về nghiệp vụ, ta cập nhật mô hình; mỗi khi mô hình rõ hơn, code rõ hơn theo. Đây gọi là knowledge crunching — quá trình gọt giũa tri thức nghiệp vụ liên tục cùng domain expert.
#### 3. Phân tách Core Domain khỏi phần phụ
Ý tưởng thứ ba mang tính chiến lược: không phải phần nào của hệ thống cũng đáng đầu tư như nhau. DDD phân biệt:
- Core Domain (miền lõi): phần tạo ra lợi thế cạnh tranh, là lý do doanh nghiệp tồn tại. Với Grab, thuật toán ghép tài xế và định giá động là core. Với một sàn TMĐT, có thể là engine đề xuất sản phẩm hoặc logic khuyến mãi.
- Supporting Subdomain (miền hỗ trợ): cần thiết nhưng không tạo lợi thế riêng — ví dụ quản lý danh mục sản phẩm.
- Generic Subdomain (miền chung): những thứ ai cũng cần và có thể mua/dùng sẵn — gửi email, xác thực đăng nhập, thanh toán qua cổng thứ ba.
Tình huống thực tế
Ví dụ 1 — Sàn TMĐT Việt và cái bẫy "status = 3"
Một công ty TMĐT tầm trung ở TP.HCM (tạm gọi là ShopViet, doanh thu khoảng 200 tỷ/năm) có bảng orders với cột status kiểu số nguyên: 1, 2, 3, 4, 5. Trong code, khắp nơi rải rác những dòng if order.status == 3. Chỉ vài lập trình viên kỳ cựu còn nhớ 3 nghĩa là "đã giao cho đơn vị vận chuyển". Khi một dev mới join, cậu ấy viết logic tính hoa hồng cho cộng tác viên dựa trên status >= 3, nghĩ rằng 3 là "hoàn tất". Hậu quả: hệ thống trả hoa hồng cho cả những đơn sau đó bị khách hoàn trả, gây thất thoát khoảng 400 triệu trong một quý trước khi bị phát hiện.
Diễn giải: Đây là ví dụ kinh điển của việc thiếu Ubiquitous Language. Business nói "đã hoàn tất", "đã hoàn trả", "đang giao" — nhưng code chỉ có những con số vô nghĩa. Không có sự khớp giữa ngôn ngữ nghiệp vụ và ngôn ngữ code, nên một hiểu lầm nhỏ trở thành lỗi tiền bạc lớn.
Bài học: Nếu ShopViet dùng một enum rõ ràng như OrderStatus.HandedToCarrier, OrderStatus.Delivered, OrderStatus.Returned, và một method order.isEligibleForCommission() chứa đúng quy tắc nghiệp vụ (chỉ tính hoa hồng khi đã giao thành công và qua thời hạn hoàn trả), thì dev mới đã không thể hiểu sai. Ngôn ngữ chung không phải là thứ "làm cho đẹp" — nó là hàng rào ngăn hiểu lầm.
Ví dụ 2 — Fintech Đông Nam Á đặt core domain sai chỗ
Một startup fintech ở khu vực (tương tự các ví như MoMo, ShopeePay giai đoạn đầu) khi khởi nghiệp đã dồn phần lớn năng lượng kỹ thuật giỏi nhất vào việc tự xây một hệ thống gửi thông báo (email/SMS/push) "thật xịn" với template engine riêng, hàng đợi riêng, dashboard riêng. Trong khi đó, phần lõi thực sự — engine phát hiện gian lận giao dịch và tính hạn mức theo thời gian thực — lại được giao cho hai dev junior làm cho xong. Sáu tháng sau, hệ thống thông báo hoạt động tuyệt vời, nhưng engine chống gian lận thì đầy lỗ hổng, để lọt nhiều giao dịch bất thường và suýt khiến công ty mất giấy phép hoạt động.
Diễn giải: Đây là lỗi không phân biệt được Core Domain và Generic Subdomain. Gửi thông báo là generic — thị trường có vô số dịch vụ như Twilio, các nhà cung cấp SMS nội địa, có thể mua sẵn. Còn engine chống gian lận mới chính là core — nó là thứ quyết định công ty sống hay chết.
Bài học: Trước khi lao vào xây, hãy hỏi: "Phần này có phải là lý do khách hàng chọn chúng ta không?" Nếu không, đừng đầu tư như thể nó là lõi. Đặt đúng người giỏi vào đúng chỗ lõi là một trong những quyết định kiến trúc có sức nặng nhất, và DDD cho bạn khung để nhìn ra điều đó.
Ví dụ 3 — Đội logistics và mô hình sống trong code
Một công ty giao vận nội thành ở Hà Nội (tạm gọi GiaoNhanh) ban đầu có toàn bộ logic nghiệp vụ nằm trong các "service" khổng lồ: OrderService dài 3000 dòng, xử lý mọi thứ từ tính cước, gán tài xế, đến kiểm tra vùng giao. Quy tắc "không được gán tài xế đang có quá 5 đơn chưa hoàn tất" nằm lẫn đâu đó giữa hàng trăm dòng if-else. Mỗi lần nghiệp vụ thay đổi con số 5 thành 7, dev phải dò tìm khắp nơi, và thường sót.
Đội quyết định áp dụng tư duy Model-Driven: đưa quy tắc vào chính đối tượng domain. Họ tạo class Driver với method canAcceptOrder() chứa đúng quy tắc, và DeliveryZone biết tự kiểm tra một địa chỉ có thuộc vùng phục vụ hay không. Ngôn ngữ trong code giờ khớp với cách điều phối viên nói chuyện hằng ngày.
Diễn giải: Trước đây mô hình domain chỉ tồn tại trong đầu vài người và trong những tài liệu lỗi thời; code thì là một mớ thủ tục. Sau khi mô hình sống trong code, quy tắc nghiệp vụ có một "ngôi nhà" rõ ràng.
Bài học: Khi ai đó hỏi "quy tắc gán tài xế nằm ở đâu?", câu trả lời nên là một địa chỉ cụ thể (Driver.canAcceptOrder()), không phải "để tôi grep tìm thử". Đó là dấu hiệu mô hình và code đã là một.
Hướng dẫn từng bước
Nếu bạn muốn bắt đầu áp dụng tư duy DDD ở mức nhập môn cho một dự án, đây là lộ trình thực tế:
- Ngồi cùng domain expert và lắng nghe từ ngữ họ dùng. Ghi lại chính xác các danh từ (đơn hàng, vận đơn, hạn mức) và động từ (thanh toán, hoàn trả, gán tài xế) mà họ nói. Đừng vội "dịch" sang thuật ngữ kỹ thuật. Đây là nguyên liệu thô của Ubiquitous Language.
- Xây một bảng thuật ngữ (glossary) chung. Với mỗi từ, ghi rõ định nghĩa mà cả business và dev đồng thuận. Khi có hai nghĩa khác nhau cho cùng một từ, đó là tín hiệu quan trọng (dẫn tới việc tách ngữ cảnh — chủ đề của bài Bounded Context).
- Phân loại các subdomain. Liệt kê các mảng nghiệp vụ lớn, rồi gắn nhãn: đâu là Core, đâu là Supporting, đâu là Generic. Đây là cơ sở để phân bổ nguồn lực.
- Đưa ngôn ngữ chung vào code. Đặt tên class, method, biến, enum theo đúng glossary. Nếu business nói "Vận đơn" thì class là
Waybill, không phảiShipData.
- Đặt quy tắc nghiệp vụ vào đúng đối tượng domain. Thay vì để logic trong service chung chung, hỏi "quy tắc này thuộc về khái niệm nào?" và đặt method vào đó.
- Lặp lại và tinh chỉnh (knowledge crunching). Mỗi khi hiểu sâu hơn, cập nhật cả glossary lẫn code. Mô hình domain là thứ tiến hóa, không phải đóng băng một lần.
Lỗi thường gặp & mẹo
Lỗi 1 — Nhầm DDD với "chỉ là cách đặt tên class." DDD không phải chỉ đặt tên đẹp; nó là sự thống nhất giữa tư duy nghiệp vụ và code. Đặt tên đúng là bước đầu, nhưng nếu logic vẫn nằm sai chỗ thì bạn chưa làm DDD.
Lỗi 2 — Áp DDD kỹ lưỡng lên toàn bộ hệ thống, kể cả CRUD đơn giản. DDD tốn công. Với một màn hình quản lý danh mục đơn thuần (thêm/sửa/xóa), áp DDD nặng nề là lãng phí. Hãy dồn nó vào Core Domain.
Lỗi 3 — Coi domain model là sơ đồ vẽ một lần rồi thôi. Mô hình phải sống trong code và tiến hóa liên tục. Một sơ đồ UML in ra dán tường sẽ lỗi thời sau hai tuần.
Lỗi 4 — Bỏ qua domain expert. DDD không thể làm một mình trong phòng dev. Nếu bạn không thường xuyên nói chuyện với người hiểu nghiệp vụ, bạn đang đoán, không phải mô hình hóa.
Mẹo: Một bài kiểm tra nhanh xem bạn có Ubiquitous Language tốt hay không: hãy đọc to một đoạn code cho domain expert nghe (bằng tên method và class). Nếu họ hiểu và gật đầu, bạn đang đi đúng. Nếu họ ngơ ngác, ngôn ngữ của code và của nghiệp vụ đã tách rời.
Mẹo: Khi phân vân một phần có phải Core Domain không, hỏi: "Nếu đối thủ có phần này tốt hơn ta, ta có mất khách không?" Nếu có, đó là core.
Bài tập thực hành
- Lập glossary mini. Chọn một hệ thống bạn quen (ví dụ đặt đồ ăn như GrabFood/ShopeeFood). Liệt kê 10 khái niệm nghiệp vụ cốt lõi và viết định nghĩa ngắn cho mỗi khái niệm bằng đúng ngôn ngữ mà một người vận hành nhà hàng sẽ dùng.
- Phát hiện lỗ hổng ngôn ngữ. Mở một đoạn code trong dự án hiện tại của bạn. Tìm 3 chỗ mà tên biến/method KHÔNG khớp với cách business nói. Viết lại tên cho khớp và ghi chú vì sao tên cũ dễ gây hiểu lầm.
- Phân loại subdomain. Với hệ thống bạn chọn ở bài 1, phân loại các mảng nghiệp vụ thành Core / Supporting / Generic. Với mỗi phần Generic, đề xuất một giải pháp có sẵn thay vì tự xây.
- Di dời một quy tắc nghiệp vụ. Tìm một quy tắc nghiệp vụ đang nằm trong controller hoặc service chung. Đề xuất (bằng mô tả hoặc pseudocode) cách đưa nó vào đúng đối tượng domain, đặt tên method theo ngôn ngữ nghiệp vụ.
Tóm tắt
Domain-Driven Design, do Eric Evans giới thiệu năm 2003, là một cách tiếp cận để mô hình hóa phần mềm sao cho khớp với domain nghiệp vụ — để chính nghiệp vụ, chứ không phải cấu trúc kỹ thuật, dẫn dắt thiết kế. Ba ý tưởng cốt lõi bạn cần khắc ghi từ bài này:
- Ubiquitous Language — một ngôn ngữ chung, thống nhất giữa business và dev, hiện diện nhất quán từ cuộc họp đến tài liệu đến chính code. Đây là hàng rào chống hiểu lầm mạnh nhất.
- Model-Driven Design — mô hình domain và code là một; quy tắc nghiệp vụ sống trong chính các đối tượng domain, không rải rác khắp nơi.
- Phân tách Core Domain — không đầu tư dàn trải; dồn năng lượng vào phần lõi tạo lợi thế cạnh tranh, mua/tích hợp sẵn cho phần generic.