Domain là gì và chia thế nào
Bài Xây codebase dùng chung cho team nói cách dựng module: service provider, Contracts, DTO, architecture test. Bài này nói phần khó hơn và đứng trước nó: cắt ở đâu.
Lý do phải tách riêng: tooling không cứu được ranh giới đặt sai chỗ. Chia sai thì càng nhiều interface, DTO và architecture test, hệ thống càng khó sửa — vì bạn vừa dựng hàng rào ngay giữa một nghiệp vụ liền mạch. Ngược lại, chia đúng thì kể cả không có module gì cả, chỉ cần thư mục thôi, code vẫn dễ sống.
Ví dụ xuyên suốt: hệ quản trị (admin/staff) cho một LMS (Learning Management System — hệ thống quản lý đào tạo). Cách làm áp dụng được cho mọi nghiệp vụ khác.
Thuật ngữ dùng trong bài
| Từ | Nghĩa dùng ở đây |
|---|---|
| Domain (miền nghiệp vụ) | Một nhóm quy tắc nghiệp vụ thay đổi cùng nhau, do cùng một nhóm người trong tổ chức quyết định. |
| Bounded context (vùng có biên) | Vùng mà một bộ từ vựng giữ đúng một nghĩa. Ra khỏi vùng, cùng một từ có thể mang nghĩa khác. |
| Ubiquitous language (ngôn ngữ chung) | Bộ từ mà dân nghiệp vụ và dev dùng y hệt nhau. Tên class/bảng phải trùng từ này. |
| Aggregate (cụm dữ liệu có chủ) | Nhóm bảng phải được ghi cùng lúc để giữ đúng một luật. Ví dụ hoá đơn + các dòng hoá đơn. |
| Cohesion / coupling (độ dính trong / dính ngoài) | Code trong một domain nên dính chặt với nhau; giữa các domain thì dính càng ít càng tốt. |
| Shared kernel (lõi dùng chung) | Phần mọi domain đều đọc nhưng chỉ một nơi được ghi. Ví dụ User, Tenant. |
| Cross-cutting concern (mối lo cắt ngang) | Thứ có mặt ở mọi domain nhưng không thuộc domain nào: log, audit, phân quyền, cache. |
| Conway's law (luật Conway) | Cấu trúc phần mềm có xu hướng sao chép cấu trúc giao tiếp của tổ chức làm ra nó. |
| Anti-corruption layer (lớp chống nhiễm) | Lớp dịch dữ liệu ở biên, để từ vựng của hệ thống ngoài không chui vào trong. |
1. Nó là gì / chạy thế nào
1.1. Định nghĩa dùng được, không phải định nghĩa học thuộc
Sách DDD định nghĩa domain khá trừu tượng. Định nghĩa dùng được khi ngồi chia code:
Domain là một nhóm quy tắc nghiệp vụ (1) thay đổi cùng nhau, vì (2) cùng một người quyết định chúng đúng hay sai.
Hai vế đó làm ra hai câu hỏi kiểm tra nhanh, dùng được ngay trong buổi họp:
- Câu hỏi thay đổi: "Khi quy tắc X đổi, còn quy tắc nào buộc phải đổi theo?" Những cái đổi theo nhau thuộc cùng một domain.
- Câu hỏi thẩm quyền: "Ai là người duy nhất có quyền nói quy tắc này sai?" Cùng một người/phòng ban → cùng một domain.
Chú ý cả hai câu đều hỏi về quy tắc và con người — không hỏi về bảng, model, màn hình hay API. Đó là chỗ đa số team chia sai.
1.2. Ba cách chia sai hay gặp
| Cách chia sai | Trông như thế nào | Vỡ ở đâu |
|---|---|---|
| Chia theo bảng dữ liệu | Có bảng courses → đẻ domain Course. Có bảng students → domain Student. |
Một quy tắc nghiệp vụ gần như luôn đụng nhiều bảng. Chia theo bảng thì mỗi yêu cầu đổi phải sửa 3–5 domain. Đây là lỗi phổ biến nhất. |
| Chia theo tầng kỹ thuật | app/Services/, app/Repositories/, app/Jobs/ — mỗi thư mục 80 file. |
Đó không phải chia domain, đó là chia loại file. Muốn hiểu một nghiệp vụ phải mở 6 thư mục. Không ranh giới nào được sinh ra. |
| Chia theo màn hình / menu | Menu admin có mục "Báo cáo" → đẻ domain Report; có mục "Cài đặt" → domain Setting. |
Menu đổi theo ý sếp và theo mùa. Code không nên đổi theo nhịp đó. Màn hình là giao diện, không phải miền. |
Kiểm chứng cách chia bằng một phép thử rẻ: lấy 5 yêu cầu thay đổi gần nhất trong Jira, xem mỗi yêu cầu chạm vào bao nhiêu domain.
1.3. Chỉ dấu mạnh nhất: cùng một từ, nhiều nghĩa
Nếu chỉ được giữ một công cụ để tìm ranh giới, giữ cái này: đi hỏi từng phòng ban định nghĩa của cùng một từ. Chỗ nào định nghĩa lệch nhau, chỗ đó là biên.
Trong LMS, từ "học viên" có ít nhất ba nghĩa:
| Phòng ban | "Học viên" nghĩa là | Dữ liệu họ quan tâm | Họ hỏi câu gì |
|---|---|---|---|
| Tuyển sinh | Một người quan tâm chưa chắc đã nhập học | nguồn lead, lần liên hệ cuối, trạng thái chăm sóc | "Tỉ lệ chốt tháng này bao nhiêu?" |
| Đào tạo | Một người đang học trong một lớp cụ thể | mã học viên, lớp, buổi vắng, bài nộp | "Ai đủ điều kiện dự thi?" |
| Kế toán | Một người phải trả tiền theo hợp đồng | số hợp đồng, công nợ, lịch trả góp | "Ai quá hạn 30 ngày?" |
Ba nghĩa, ba bộ dữ liệu, ba phòng ban, ba nhịp thay đổi. Ép cả ba vào một model
Student thì được một class 60 cột mà ai sửa cũng sợ.
Student. Có
users ở shared kernel giữ danh tính (tên, email, đăng nhập), còn mỗi domain giữ
vai của người đó, khoá theo cùng user_id. Ba bảng nhỏ, mỗi bảng đủ cột, mỗi
bảng một phòng ban sửa.1.4. Domain, bounded context, aggregate — xếp theo cỡ
Ba từ này hay bị dùng lẫn. Xếp từ to xuống nhỏ:
flowchart TB A["Domain: Đào tạo
(cả mảng nghiệp vụ của phòng Đào tạo)"] A --> B1["Bounded context: Scheduling
từ vựng: lớp, buổi, phòng, phân công"] A --> B2["Bounded context: Learning
từ vựng: điểm danh, bài nộp, hạn nộp"] B2 --> C1["Aggregate: Enrollment
enrollments + attendances
ghi cùng lúc, cùng transaction"] B2 --> C2["Aggregate: Submission
submissions + files"]
Trong code Laravel, thực tế gọn lại còn hai mức:
- Bounded context → một thư mục trong
app/Domain/. Đây là thứ bài này gọi tắt là "domain", vì đó là đơn vị mà code thật sự chia theo. - Aggregate → một nhóm model + một service/action ghi chúng trong cùng một
DB::transaction(). Không cần thư mục riêng.
Đừng dựng thư mục cho aggregate. Một số team đẻ
Domain/Learning/Aggregates/Enrollment/có đủ Repository, Factory, Event Store. Trừ khi đang làm event sourcing thật, đó là chi phí thuần: Eloquent + một transaction đã đủ giữ bất biến của aggregate.
2. Tiêu chí: khi nào một nghiệp vụ thành domain riêng
2.1. Bảy câu hỏi, chấm điểm trên 10
Hai câu đầu tính hệ số 2 vì chúng là chỉ dấu bền nhất — năm câu còn lại có thể đổi khi codebase lớn lên, hai câu đầu thì không.
| # | Câu hỏi | Vì sao nó là chỉ dấu | Điểm |
|---|---|---|---|
| 1 | Từ vựng riêng. Có từ nào mà ra khỏi nghiệp vụ này thì đổi nghĩa, hoặc không ai bên ngoài hiểu? | Ngôn ngữ đổi nghĩa chính là định nghĩa của bounded context. Rẻ nhất để kiểm tra: đi hỏi hai phòng ban cùng một từ. | ×2 |
| 2 | Người chủ riêng. Có đúng một người/phòng ban là người duy nhất được nói "quy tắc này sai"? | Luật Conway. Ranh giới code chỉ sống lâu khi nó trùng ranh giới tổ chức. Ranh giới do kiến trúc sư vẽ ra mà không khớp tổ chức sẽ mục trong 6 tháng. | ×2 |
| 3 | Nhịp đổi riêng. Quy tắc của nó đổi vì lý do khác với phần còn lại? | Học phí đổi khi luật thuế đổi; lịch học đổi khi vào kỳ mới. Hai lý do khác nhau → nên tách (SRP ở mức module). | ×1 |
| 4 | Dữ liệu sở hữu riêng. Có ít nhất một bảng mà không ai ngoài nó được UPDATE? |
Không sở hữu dữ liệu nào thì nó chỉ là một cách nhìn (view) lên dữ liệu của người khác — đó là service hoặc read model, không phải domain. | ×1 |
| 5 | Vòng đời riêng. Có state machine (máy trạng thái) riêng: các trạng thái và những bước chuyển hợp lệ giữa chúng? | State machine là lõi cứng của nghiệp vụ. Chỗ nào có nó, chỗ đó có quy tắc đáng bảo vệ sau một cái cửa. | ×1 |
| 6 | Tắt đi vẫn chạy. Bỏ nó ra thì phần còn lại què nhưng vẫn chạy được? | Nếu tắt là cả hệ thống chết (ví dụ đăng nhập, phân quyền) thì nó là hạ tầng hoặc shared kernel, không phải domain nghiệp vụ. | ×1 |
| 7 | Đủ to và còn to tiếp. Từ ~4–5 bảng hoặc ~1.500 dòng logic trở lên, và roadmap còn thêm nữa? | Domain 200 dòng là chi phí thuần: thêm thư mục, thêm cửa, thêm test, không đổi lại được gì. | ×1 |
Cách đọc điểm:
flowchart TB
S["Chấm 7 câu → tổng /10"] --> G{"Câu 1 hoặc câu 2
có ít nhất 1 cái?"}
G -->|"không"| R1["KHÔNG tách.
Đây là một chức năng, không phải một miền.
Để nó là service trong domain gần nhất."]
G -->|"có"| P{"Tổng điểm?"}
P -->|"≥ 7"| D1["Domain riêng:
thư mục app/Domain/X/ + Contracts khi có người gọi"]
P -->|"4 - 6"| D2["Sub-domain:
thư mục con trong domain lớn hơn,
chưa cần Contracts riêng"]
P -->|"≤ 3"| D3["Không tách:
một class service là đủ"]
Điều kiện chặn quan trọng nhất: nếu cả câu 1 và câu 2 đều "không" thì dừng — dù năm câu sau đúng hết. Không có từ vựng riêng và không có người chủ riêng nghĩa là thứ bạn đang nhìn là một chức năng (feature), không phải một miền (domain). Tách nó ra chỉ tạo thêm một hàng rào mà không ai bảo vệ.
2.2. Phản-tiêu chí: dấu hiệu KHÔNG nên tách
Năm lý do nghe hợp lý nhưng đều sai:
| Lý do hay nghe | Vì sao sai | Nên làm gì |
|---|---|---|
| "Sau này có thể cần tách" | YAGNI. Dự đoán ranh giới trước khi biết nghiệp vụ là cách chắc chắn nhất để đặt sai chỗ. | Đợi đến khi có yêu cầu thật. Chuyển thư mục là việc IDE làm được. |
| "Thư mục này nhiều file quá" | Đó là vấn đề sắp xếp, không phải vấn đề ranh giới. | Thêm thư mục con. Không cần Contracts, không cần DTO. |
| "Import Excel là một mảng lớn" | Import/export là năng lực kỹ thuật (capability), không phải miền nghiệp vụ. Nó không có quy tắc riêng, nó chỉ dịch định dạng. | Đặt trong domain cần nó: Finance/Import/InvoiceImporter.php. |
| "Mỗi bảng nên thuộc về một domain" | Sai từ gốc. Nhiều bảng là bảng nối, bảng log, bảng cấu hình — chúng không thuộc về miền nào. | Chia quy tắc trước, bảng theo sau. |
| "Nó có màn hình riêng trên menu" | Menu phản ánh cách sếp muốn trình bày, không phản ánh cách nghiệp vụ vận hành. | Một domain có thể có 8 màn hình; một màn hình có thể đọc 4 domain. |
2.3. Hướng phụ thuộc: chảy về phía ổn định hơn
Chia đúng chưa đủ — còn phải quyết ai được gọi ai. Một luật duy nhất:
Phụ thuộc chỉ được chảy về phía đổi ít hơn. Domain đổi nhiều được biết domain đổi ít. Ngược lại thì cấm.
Trong LMS: Catalog (chương trình, khoá học, tín chỉ) là thứ ổn định nhất — đổi
mỗi năm học một lần. Finance đổi nhiều nhất — khuyến mãi, chính sách trả góp,
luật thuế. Vậy Finance → Catalog được; Catalog →
Finance thì cấm. Nếu Catalog cần phản ứng khi hoá đơn thanh toán xong, nó
nghe event, không gọi thẳng.
3. Ví dụ đầy đủ: chia domain cho hệ admin LMS
3.1. Bước 1 — liệt kê nghiệp vụ thô, chưa gom
Không bắt đầu từ bảng, không bắt đầu từ menu. Bắt đầu từ câu trả lời cho câu hỏi: nhân viên nào ngồi làm việc gì cả ngày.
tuyển sinh / chăm lead ký hợp đồng xếp lớp đầu vào
khoá học & chương trình điều kiện tiên quyết lớp học phần
lịch học & phòng phân công giảng viên điểm danh
bài tập & bài nộp ngân hàng câu hỏi đề thi & ca thi
chấm điểm & bảng điểm phúc khảo cấp chứng chỉ
học phí & hoá đơn công nợ & nhắc nợ hoàn tiền
thông báo email/SMS người dùng & phân quyền báo cáo & dashboard
nhật ký thao tác (audit) import/export Excel
3.2. Bước 2 — chấm điểm từng cái
Bảng dưới là kết quả chấm theo bảy câu ở mục 2.1. Cột tiêu đề viết tắt: Từ = từ vựng riêng (×2), Chủ = người chủ riêng (×2), Nhịp = nhịp đổi riêng, Ghi = sở hữu bảng riêng, Đời = vòng đời riêng, Tắt = tắt đi vẫn chạy, To = đủ lớn.
| Nghiệp vụ | Từ×2 | Chủ×2 | Nhịp | Ghi | Đời | Tắt | To | Điểm | Quyết định |
|---|---|---|---|---|---|---|---|---|---|
| Tuyển sinh, hợp đồng, xếp lớp đầu vào | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 10 | Admission |
| Khoá học, chương trình, tiên quyết | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 10 | Catalog |
| Lớp học phần, lịch, phòng, phân công GV | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 10 | Scheduling |
| Điểm danh, bài tập, bài nộp | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 10 | Learning |
| Ngân hàng câu hỏi, đề thi, chấm, bảng điểm | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 10 | Assessment |
| Học phí, hoá đơn, công nợ, hoàn tiền | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 10 | Finance |
| Cấp chứng chỉ | ✓ | ✗ vẫn Khảo thí | ✗ | ✓ | ✓ | ✓ | ✗ 2 bảng | 6 | sub-domain của Assessment |
| Phúc khảo điểm | ✗ dùng từ của Assessment | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ | 3 | sub-domain của Assessment |
| Thông báo email/SMS | ✗ từ kỹ thuật, không phải nghiệp vụ | ✗ IT | ✓ | ✓ | ✓ | ✓ | ✓ | 4 | capability, không phải domain |
| Người dùng & phân quyền | ✗ | ✗ IT | ✗ | ✓ | ✗ | ✗ tắt là chết | ✓ | 2 | shared kernel |
| Báo cáo & dashboard | ✗ mượn từ mọi nơi | ✗ ai cũng đòi | ✓ | ✗ không ghi gì | ✗ | ✓ | ✓ | 3 | read model cắt ngang |
| Nhật ký thao tác (audit) | ✗ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ | 2 | cross-cutting (observer) |
| Import/export Excel | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | 1 | adapter trong domain cần nó |
Chú ý chỗ "Thông báo" bị loại. Nó có từ vựng riêng thật (kênh, template, bounce, retry) nhưng đó là từ vựng kỹ thuật, không phải từ vựng nghiệp vụ. Câu hỏi quyết định: "quy tắc ai được nhận thông báo gì do ai quyết?" — do chính domain gửi quyết (Finance quyết ai bị nhắc nợ, Learning quyết ai bị cảnh báo vắng). Notification chỉ chuyển tin, nên nó là capability. Chi tiết ở bài Gửi thông báo đa kênh.
3.3. Bước 3 — xử lý các ca khó
Sáu domain ở trên là phần dễ. Giá trị thật nằm ở những nghiệp vụ nằm giữa hai domain. Với mỗi ca, câu hỏi quyết định luôn là câu số 2: ai đổi quy tắc này?
| Ca khó | Hai lựa chọn | Quyết định & lý do |
|---|---|---|
| Điểm danh thuộc Learning hay Scheduling? | Scheduling sở hữu buổi học; Learning sở hữu ai có mặt. | Learning. Người đổi quy tắc "vắng 20% thì cấm thi" là giáo vụ đào tạo, không phải người xếp lịch. Learning đọc session_id từ Scheduling qua cửa, và tự giữ bảng attendances. |
| Chứng chỉ thuộc Assessment hay Catalog? | Catalog định nghĩa chương trình; Assessment giữ điểm. | Assessment. Điều kiện cấp chứng chỉ là quy tắc về điểm ("đủ 8 môn, GPA ≥ 2.5"), do khảo thí quyết. Catalog chỉ cần công bố "chương trình này gồm môn nào". |
| Học phí theo tín chỉ thuộc Finance hay Catalog? | Số tín chỉ nằm ở Catalog; đơn giá nằm ở Finance. | Tách đôi. Catalog trả lời "khoá này mấy tín chỉ" (đổi mỗi năm học). Finance trả lời "một tín chỉ bao nhiêu tiền, giảm bao nhiêu" (đổi mỗi đợt khuyến mãi). Hai câu hỏi, hai người chủ, hai nhịp → hai domain. |
| Xếp lớp cho tân học viên thuộc Admission hay Scheduling? | Admission có người mới; Scheduling có chỗ trống. | Admission khởi xướng, Scheduling quyết. Admission gọi Scheduling::assign($userId, $classId). Quy tắc "lớp còn chỗ không, có trùng lịch không" là của Scheduling — Admission không được tự kiểm tra rồi INSERT. |
| Hoàn tiền khi học viên nghỉ thuộc Finance hay Learning? | Learning biết ai nghỉ; Finance biết trả lại bao nhiêu. | Finance. Learning phát event EnrollmentCancelled. Finance nghe và tự tính theo chính sách của nó ("nghỉ trước buổi 3 hoàn 70%"). Learning không được biết con số 70%. |
| "Student" là domain gì? | Đẻ domain Student, hay không có? |
Không có. Xem lại Hình 2. users giữ danh tính ở shared kernel; mỗi domain giữ vai riêng (Applicant, Enrollment, Payer) khoá theo user_id. Đây là lỗi chia domain phổ biến nhất: đẻ domain cho danh từ. |
3.4. Bước 4 — cấu trúc thư mục
app/
Domain/
Admission/ Catalog/ Scheduling/
Learning/ Assessment/ Finance/
Models/ ← Eloquent của riêng domain này
Services/ ← quy tắc nghiệp vụ
Actions/ ← 1 việc = 1 class, khi service phình to
Events/ ← thứ domain khác được phép nghe
Policies/
Data/ ← DTO công bố ra ngoài (tạo khi có người gọi)
Contracts/ ← "cửa trước" (tạo khi có người gọi)
Support/ ← shared kernel: User, Role, Tenant, Money, DateRange
Reporting/ ← chỉ đọc, được phép đọc mọi Domain
Notification/ ← capability: kênh gửi, template
Http/Controllers/Admin/
Admission/ Catalog/ Scheduling/ Learning/ Assessment/ Finance/
Vì sao controller KHÔNG nằm trong
app/Domain/. Controller thuộc về giao diện, không thuộc về miền. Ngày mai thêm API cho app mobile, thêm một lệnh artisan, thêm một webhook — cả ba cùng gọi một service. Nếu controller nằm trong domain thì domain phải biết mình đang được gọi từ HTTP. Để controller ở chỗ Laravel đặt sẵn, chỉ nhóm thư mục con theo domain cho dễ tìm.
Điểm quan trọng về chi phí: Contracts/ và Data/ chưa cần tạo
ngay. Ngày đầu chỉ có 6 thư mục domain + một architecture test. Cửa trước chỉ dựng khi có
domain thứ hai thật sự gọi tới — chi tiết cách dựng ở
bài codebase dùng chung, mục 1.5.
3.5. Bước 5 — ma trận ai được gọi ai
| Từ ↓ gọi → | Admission | Catalog | Scheduling | Learning | Assessment | Finance | Support |
|---|---|---|---|---|---|---|---|
| Admission | — | ✓ cửa | ✓ cửa | ✗ | ✗ | ✗ event | ✓ |
| Catalog | ✗ | — | ✗ | ✗ | ✗ | ✗ | ✓ |
| Scheduling | ✗ | ✓ cửa | — | ✗ | ✗ | ✗ | ✓ |
| Learning | ✗ | ✓ cửa | ✓ cửa | — | ✗ event | ✗ event | ✓ |
| Assessment | ✗ | ✓ cửa | ✗ | ✓ cửa | — | ✗ | ✓ |
| Finance | ✓ cửa | ✓ cửa | ✗ | ✓ cửa | ✗ | — | ✓ |
| Reporting | ✓ đọc | ✓ đọc | ✓ đọc | ✓ đọc | ✓ đọc | ✓ đọc | ✓ |
Đọc bảng: ✓ cửa = gọi được nhưng chỉ qua Contracts\. ✗ event =
không gọi ngược được, muốn phản ứng thì nghe event. ✓ đọc = Reporting được join thẳng vào
bảng, nhưng cấm ghi.
Ma trận này không phải tài liệu — nó là một file test. Với
pestphp/pest-plugin-arch (đã có sẵn trong Laravel 11+):
<?php
// tests/Arch/DomainBoundaryTest.php
$domains = ['Admission', 'Catalog', 'Scheduling', 'Learning', 'Assessment', 'Finance'];
foreach ($domains as $self) {
$others = array_values(array_diff($domains, [$self]));
// Ruột domain khác: cấm. Cửa trước (Contracts\, Data\) thì được.
arch("domain {$self} khong cham ruot domain khac")
->expect("App\\Domain\\{$self}")
->not->toUse(array_map(fn ($o) => "App\\Domain\\{$o}", $others))
->ignoring(array_merge(
array_map(fn ($o) => "App\\Domain\\{$o}\\Contracts", $others),
array_map(fn ($o) => "App\\Domain\\{$o}\\Data", $others),
array_map(fn ($o) => "App\\Domain\\{$o}\\Events", $others),
));
}
// Hướng phụ thuộc: Catalog là nền, không được biết ai cả.
arch('catalog khong phu thuoc domain nao')
->expect('App\Domain\Catalog')
->not->toUse(array_map(fn ($o) => "App\\Domain\\{$o}", array_diff($domains, ['Catalog'])));
// Reporting chi doc: khong duoc goi Services (noi co lenh ghi).
arch('reporting khong ghi gi')
->expect('App\Reporting')
->not->toUse(array_map(fn ($o) => "App\\Domain\\{$o}\\Services", $domains));
// Tang HTTP khong duoc cham thang Models.
arch('controller khong cham model')
->expect('App\Http\Controllers')
->not->toUse(array_map(fn ($o) => "App\\Domain\\{$o}\\Models", $domains));
Chưa verify trên máy thật. Đoạn trên viết theo tài liệu Pest arch testing (
toUse,ignoring). Repo này chưa có dự án Laravel để chạy thử, nên coi nó là bản mẫu cần chạy lại khi áp dụng. Bản PHPUnit thuần 27 dòng đã chạy thật và có output nằm ở bài codebase dùng chung mục 1.6 — nếu chỉ cần đúng một luật thì dùng bản đó, không phải thêm package.
4. Lợi ích
| Được gì | Đo bằng gì | Khi nào thật sự được |
|---|---|---|
| Phạm vi ảnh hưởng nhìn được bằng mắt | Số domain một PR chạm vào | Ngay lập tức. Đây là lợi ích lớn nhất và rẻ nhất: reviewer nhìn cây file là biết cần test lại chỗ nào. |
| Onboard nhanh hơn | Thời gian để dev mới sửa được bug đầu tiên | Từ ~3–5 domain trở lên. Dưới đó thì thư mục phẳng cũng đọc được. |
| Quy tắc chỉ tồn tại một chỗ | Số lần grep ra cùng một hằng số nghiệp vụ | Ngay lập tức, nhưng chỉ khi chia theo quy tắc. Chia theo bảng thì quy tắc vẫn rải. |
| Test chạy theo phần | pest --filter=Learning | Khi suite test > ~2 phút. |
| Đàm phán với nghiệp vụ dễ hơn | Số lần họp phải giải thích lại | Ngay lập tức. Tên thư mục trùng tên phòng ban thì hai bên nói cùng một ngôn ngữ. |
| Tách service sau này rẻ hơn | Số chỗ phải sửa khi tách | Chỉ khi có thật. Đừng dùng lý do này để biện minh cho việc chia sớm. |
5. Tác hại / hạn chế
- Chia sai đắt hơn không chia. Ranh giới sai = hàng rào giữa hai thứ đáng lẽ đi
chung. Mỗi feature phải đi vòng qua cửa, qua DTO, qua event — cho một việc lẽ ra là một câu
UPDATE. Và càng lâu càng khó sửa vì đã có code dựa vào cái cửa sai đó. - Ranh giới trong code không phải ranh giới trong DB. Sáu domain vẫn join chung một schema. Ngày muốn tách service thật, chỗ đau là dữ liệu, không phải namespace. Đừng nói với sếp là "chia domain xong thì tách microservice dễ" — nó chỉ dễ hơn một phần nhỏ.
- Báo cáo là kẻ thù tự nhiên. Màn hình "học viên nợ học phí và sắp rớt môn" đụng
Finance + Learning + Assessment. Tuân luật thì 3 lần gọi cửa rồi gộp trong PHP — không phân
trang được, không sắp xếp chéo cột được. Đó là lý do phải có
Reportingđược miễn luật. - Có vài nghiệp vụ thật sự nằm giữa. Không phải cái gì cũng có câu trả lời sạch.
Khi bí, chọn đại một bên, ghi lại lý do trong một file
docs/domain-map.md, và đặt lịch xem lại sau 3 tháng. Tranh luận quá 30 phút là dấu hiệu hai lựa chọn tương đương nhau. - Tên domain có thể sai theo thời gian. Tổ chức tái cơ cấu, phòng Khảo thí sáp nhập vào Đào tạo → ranh giới theo Conway's law cũng nên đổi theo. Đây là chi phí thật, khoảng vài ngày mỗi lần.
- Không giải quyết được gì về hiệu năng. Chia domain không làm app nhanh hơn một mili-giây nào. Đo thật ở bài codebase dùng chung: 31 module không làm app chậm hơn — và cũng không nhanh hơn.
6. Trade-off
6.1. Chia mịn hay chia thô
| Chia thô (3–4 domain) | Chia vừa (6–8) | Chia mịn (12+) | |
|---|---|---|---|
| Tìm code | vẫn phải grep trong domain to | tốt nhất | không biết mở thư mục nào |
| Số lần đi qua cửa | ít | vừa | nhiều — mỗi feature qua 3–4 cửa |
| Rủi ro chia sai | thấp (gộp dễ sửa hơn tách) | vừa | cao |
| Hợp với | team ≤ 5, nghiệp vụ còn đổi nhiều | team 5–15 | nhiều team, mỗi team sở hữu 1–2 domain |
Khi phân vân, chia thô hơn. Tách một domain to thành hai là việc cơ học (di chuyển file, IDE làm được). Gộp hai domain đã có cửa, DTO và event giữa chúng thì phải gỡ cả đống code trung gian. Sai theo hướng thô rẻ hơn sai theo hướng mịn.
6.2. Chia theo nghiệp vụ hay chia theo team
Hai cái này thường trùng nhau (Conway's law), nhưng khi lệch:
- Team ổn định, nghiệp vụ còn đổi (startup, sản phẩm mới) → chia theo team. Ranh giới trùng với người thì conflict ít nhất, và nghiệp vụ đổi thì đổi bên trong một domain.
- Nghiệp vụ ổn định, team hay đổi (outsourcing, dự án chạy lâu) → chia theo nghiệp vụ. Người đến người đi, ranh giới nghiệp vụ vẫn đúng.
6.3. Các cặp chọn lựa hay bị hỏi
| Tình huống | Chọn | Tiêu chí |
|---|---|---|
| Nghiệp vụ nằm giữa 2 domain | Đặt ở nơi có người chủ của quy tắc | Ai trả lời được "quy tắc này đúng hay sai" — không phải ai có bảng dữ liệu. |
| Cần dữ liệu domain khác | Gọi cửa nếu cần bây giờ; nghe event nếu chỉ cần phản ứng | Gọi cửa = đồng bộ, bên kia chết thì mình chết. Event = lỏng, nhưng chậm và khó debug. |
| Hai domain cần ghi trong một transaction | Xem lại — có thể chúng là một domain | Nếu hai thứ luôn phải đúng cùng lúc thì ranh giới giữa chúng là giả. |
| Một bảng bị 3 domain cùng ghi | Tách bảng, hoặc gán chủ + cho 2 domain kia gọi cửa | Mỗi bảng đúng một domain được UPDATE. Không có ngoại lệ trừ shared kernel. |
| Domain mới hay sub-domain? | Sub-domain, cho tới khi có người chủ riêng | Hạ xuống dễ hơn nâng lên. |
| Đặt tên tiếng Anh hay tiếng Việt? | Tiếng Anh cho namespace, nhưng phải là bản dịch đúng của từ nghiệp vụ | Phòng Khảo thí → Assessment, không phải Exam (hẹp hơn) hay Grade (chỉ một phần). |
7. Khó khăn khi vận hành thực tế
| Vấn đề | Xử lý |
|---|---|
| Dev mới không biết đặt file mới vào đâu | Một file docs/domain-map.md 1 trang: mỗi domain một dòng "sở hữu bảng nào, quyết quy tắc gì, ai là người chủ". Không có nó thì sau 6 tháng mỗi người chia một kiểu. |
| Ranh giới mục dần mà không ai thấy | Đếm số ngoại lệ đã khai báo trong architecture test theo thời gian. Tăng đều = ranh giới đặt sai chỗ, không phải team kém kỷ luật. Đó là tín hiệu để chia lại, không phải để mắng. |
| Deadline gấp, dev import thẳng model domain khác | Cho merge bằng cách thêm ngoại lệ có ghi chú + ngày hết hạn, không tắt test. Tắt một lần là mất luôn. |
| Debug lúc 3h sáng | Exception và log có namespace domain → nhìn stacktrace là biết gọi ai. Thêm Log::withContext(['domain' => 'Finance']) ở service provider của từng domain. |
| Migration của ai | Giữ tất cả trong database/migrations/ chung, đặt tiền tố domain: 2026_09_23_finance_create_invoices.php. Tách thư mục migration theo domain nghe hay nhưng làm migrate:fresh và thứ tự foreign key thành vấn đề — không đáng. |
| Sếp hỏi "chia xong nhanh hơn bao nhiêu?" | Trả lời thật: không nhanh hơn. Cái đo được là số domain một PR chạm vào và thời gian review. Đo trước khi chia để có mốc so sánh. |
8. Case thực tế và cách xử lý
Case 1 — Chia theo bảng, một yêu cầu phải sửa 4 chỗ
Tình huống: Team chia Student, Course,
Class, Grade theo bảng. Giáo vụ yêu cầu "vắng quá 20% buổi thì cấm thi".
Hoá ra phải sửa: Student (thêm cột absent_rate),
Class (đếm buổi), Grade (chặn nhập điểm thi),
Course (cấu hình ngưỡng 20% theo môn). Sửa xong quên chỗ thứ tư, bảng điểm sai 2 tuần.
Xử lý từng bước:
- Tìm người chủ thật của quy tắc: giáo vụ đào tạo → domain
Learning. - Gom quy tắc vào một chỗ:
Learning\Services\AttendanceRule::canSit($enrollmentId): bool. Ngưỡng 20% là một hằng số, tồn tại đúng một lần. - Các chỗ khác hỏi thay vì tự tính: Assessment gọi
LearningReader::canSit()trước khi cho nhập điểm. - Thêm architecture test cấm domain khác
usemodelAttendance. - Xoá cột
students.absent_rate— dữ liệu dẫn xuất lưu trùng là nguồn gốc của lệch số.
Case 2 — Hai domain cần ghi trong cùng một transaction
Tình huống: "Ghi nhận thanh toán xong thì mở khoá học ngay." Finance ghi
payments, Learning ghi enrollments.status = active. Dev viết một
DB::transaction() ôm cả hai, import thẳng model của nhau, architecture test đỏ.
Xử lý — ba mức, chọn theo yêu cầu nghiệp vụ:
- Mức rẻ nhất (nên thử trước): Finance gọi cửa
LearningWriter::activate($enrollmentId)bên trong transaction của nó. Vẫn một transaction, vẫn đúng ranh giới, chỉ là đi qua interface. Đây là lối thoát cho 90% trường hợp. - Nếu chấp nhận trễ vài giây: Finance commit rồi phát
PaymentReceived; Learning nghe và mở khoá. Nhớ dùngEvent::dispatch()trongDB::afterCommit(), và listener phải idempotent (chạy 2 lần vẫn đúng) — chi tiết ở bài at-least-once. - Nếu hai thứ luôn phải đúng cùng lúc, không chấp nhận trễ một giây nào: ranh
giới đang sai. "Thanh toán" và "mở khoá" có thể là một nghiệp vụ — cân nhắc gộp phần đó
thành một domain
Enrollmentsở hữu cả hai.
Case 3 — Màn hình báo cáo đụng 3 domain, đi đúng luật thì chậm
Tình huống: Dashboard "học viên nợ học phí và có nguy cơ rớt". Đi đúng luật:
gọi FinanceReader::debtors() ra 800 ID, gọi
AssessmentReader::atRisk($ids), gộp trong PHP. Không phân trang được, không sắp xếp
theo cột của domain kia được, 2,4 giây.
Xử lý:
- Chấp nhận
app/Reporting/được miễn luật đọc: join thẳnginvoices+gradestrong một query. Kèm architecture test cấm nóusebất kỳServices\nào — đọc thì được, ghi thì không. - Nếu báo cáo nhiều và nặng: dựng read model riêng — một bảng tổng hợp cập nhật theo event, hoặc materialized view (bảng kết quả query được lưu sẵn, làm mới định kỳ) trong PostgreSQL.
- Đừng mở cửa domain rộng ra chỉ để báo cáo dùng được. Cửa mở cho báo cáo thì tuần sau có người khác dùng cửa đó để ghi.
Case 4 — Sáu tháng sau, phòng Khảo thí sáp nhập vào Đào tạo
Tình huống: Ranh giới Assessment / Learning chia theo
hai phòng ban. Giờ một trưởng phòng quản cả hai, và mọi yêu cầu đều đụng cả hai domain.
Xử lý:
- Đo trước khi quyết: đếm PR 3 tháng gần nhất chạm cả hai domain. Dưới 30% thì để yên — tổ chức đổi không có nghĩa code phải đổi ngay.
- Trên 60% thì gộp:
Assessmentthành thư mục con củaLearning, xoá Contracts giữa hai bên, giữ lại Contracts ra bên ngoài. - Gộp là refactor cơ học: đổi namespace, chạy test, một buổi chiều. Đây chính là lý do mục 6.1 khuyên chia thô.
Case 5 — Đang ở codebase phẳng, muốn chia mà không dám dừng feature
Xử lý — chia dần, không big bang:
- Tuần 1: tạo
app/Domain/rỗng + architecture test chưa bật luật nào. Chỉ có khung. - Tuần 2: chuyển một domain rõ ràng nhất (thường là Finance — kế toán luôn
là ranh giới sắc nhất) vào
app/Domain/Finance/. Không sửa logic, chỉ move + đổi namespace. - Bật một luật: "không ai ngoài Finance được
useFinance\Models\". Sửa hết chỗ đỏ — đây là lúc phát hiện coupling thật sự nằm ở đâu. - Lặp lại mỗi 2–3 tuần với domain tiếp theo. Code chưa chuyển cứ để nguyên ở
app/Models/,app/Services/— hai kiểu sống chung được. - Domain cuối cùng còn lại trong
app/Services/thường là phần mơ hồ nhất. Lúc đó đã đủ hiểu để quyết.
9. Câu hỏi phụ interviewer hay hỏi tiếp
"Domain khác module ở chỗ nào?" — Domain là khái niệm nghiệp vụ (nhóm quy tắc + người chủ). Module là cách hiện thực nó trong code (thư mục + service provider + cửa). Một domain có thể chưa được module hoá — vẫn là domain.
"Chia domain trước hay chia bảng trước?" — Domain trước. Bảng là hệ quả. Chia bảng trước thì gần như chắc chắn ra cấu trúc theo danh từ, và mỗi quy tắc nghiệp vụ nằm rải ở 3–4 chỗ.
"Làm sao biết đã chia sai?" — Ba tín hiệu đo được: (1) trung bình một PR
chạm > 3 domain; (2) số ngoại lệ trong architecture test tăng đều mỗi tháng; (3) có bảng bị 2+
domain cùng UPDATE. Tín hiệu (3) nặng nhất.
"Một domain nên to bao nhiêu?" — Không có con số đúng, nhưng có mốc kiểm tra: một người mới phải đọc hiểu được cả domain trong một ngày. Quá to thì tách; quá nhỏ (dưới ~5 file) thì gộp.
"Domain có được có bảng riêng, database riêng không?" — Bảng riêng: có, và nên. Database riêng: chưa, trong monolith. Tách DB là bước của microservice, và khi đó mất luôn transaction chung — phải đổi sang saga (chuỗi bước có bước bù trừ khi hỏng). Đừng trả giá đó khi chưa cần.
"Event giữa các domain có làm hệ thống khó debug không?" — Có, thật. Đó là lý do chỉ dùng event cho phản ứng (thứ chậm vài giây cũng được), còn thứ cần ngay thì gọi cửa đồng bộ. Mỗi event thêm vào là thêm một đường đi vô hình trong stacktrace.
"Nếu nghiệp vụ chưa rõ thì chia thế nào?" — Chưa chia. Viết phẳng, gom theo thư mục, đợi 3–6 tháng cho ranh giới tự lộ ra qua các yêu cầu thay đổi. Chia domain dựa trên phỏng đoán là cách đắt nhất để sai.
"DDD có bắt buộc phải dùng Repository, Value Object, Aggregate Root không?" — Không. Phần giá trị nhất của DDD là strategic design (chia bounded context, thống nhất ngôn ngữ) — đó là phần bài này nói. Phần tactical (Repository, VO, Aggregate Root) là tuỳ chọn, và trên Eloquent thì Repository thường là lớp thừa.
Trả lời trong 60 giây (bản dùng khi phỏng vấn)
"Domain với tôi là một nhóm quy tắc nghiệp vụ đổi cùng nhau vì cùng một người quyết. Nên tôi không chia theo bảng hay theo màn hình — chia theo bảng thì một yêu cầu đổi phải sửa 4 chỗ.
Để quyết một nghiệp vụ có thành domain riêng không, tôi hỏi bảy câu, nhưng hai câu đầu nặng nhất: nó có từ vựng riêng không — tức là có từ nào ra khỏi vùng đó thì đổi nghĩa; và nó có đúng một người chủ không. Nếu cả hai đều không thì dù các tiêu chí khác đúng hết tôi vẫn không tách, vì đó là một chức năng chứ không phải một miền. Năm câu còn lại là nhịp đổi riêng, có bảng chỉ nó được ghi, có vòng đời riêng, tắt đi hệ thống vẫn chạy, và đủ to.
Ví dụ LMS tôi chia sáu domain: Admission, Catalog, Scheduling, Learning, Assessment, Finance. Ba thứ bị loại là phân quyền — đó là shared kernel vì tắt đi thì chết; báo cáo — đó là read model được miễn luật để join thẳng; và thông báo — đó là capability, vì quy tắc ai nhận gì do domain gửi quyết chứ không phải nó.
Điểm tôi cẩn thận nhất là không đẻ domain cho danh từ. Không có domain Student. Có
usersở shared kernel giữ danh tính, còn mỗi domain giữ vai riêng — Applicant, Enrollment, Payer — khoá theo cùnguser_id. Và một luật về hướng phụ thuộc: chỉ chảy về phía đổi ít hơn, Finance biết Catalog chứ Catalog không biết Finance. Luật đó nằm trong architecture test, không nằm trong tài liệu."