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:

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 saiTrông như thế nàoVỡ ở đâ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.

Một yêu cầu đổi chạm 4 module khi chia theo bảng, chạm 1 module khi chia theo quy tắc Phía trên là một thẻ yêu cầu của giáo vụ: vắng quá 20 phần trăm buổi thì cấm thi. Bên trái, cách chia theo bảng có bốn ô Students, Courses, Sessions, Grades; cả bốn ô lần lượt sáng đỏ, kèm ghi chú bốn chỗ phải sửa, quên một chỗ là sai. Bên phải, cách chia theo quy tắc có ô Learning sáng xanh vì nó sở hữu quy tắc điểm danh, và ô Assessment chỉ gọi qua cửa canSit, kèm ghi chú một chỗ sửa. Giáo vụ: “vắng quá 20% buổi thì cấm thi” Chia theo bảng dữ liệu mỗi bảng một domain Students Courses Sessions Grades 4 chỗ phải sửa — quên 1 chỗ là dữ liệu lệch và không ai biết chắc là 4 hay 5 quy tắc “20%” nằm rải trong cả 4 Chia theo quy tắc + người chủ giáo vụ sở hữu điểm danh → Learning Learning attendances AttendanceRule canSit() Assessment không đổi dòng nào canSit() 1 chỗ sửa — số 20% chỉ tồn tại ở 1 file Assessment hỏi qua cửa, không tự tính lại đổi thành 25% → sửa 1 hằng số
Phép thử rẻ nhất để biết cách chia đúng hay sai: lấy 5 yêu cầu thay đổi gần nhất, đếm số domain mỗi yêu cầu chạm vào. Trung bình 1–2 là chia đúng. Trung bình 4 trở lên nghĩa là ranh giới đang cắt ngang nghiệp vụ — thường vì chia theo bảng thay vì theo quy tắc.

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âmHọ hỏi câu gì
Tuyển sinhMột người quan tâm chưa chắc đã nhập họcnguồ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ạoMộ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ánMột người phải trả tiền theo hợp đồngsố 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ợ.

Một model Student gánh ba phòng ban, so với ba vai riêng chung một user_id Bên trái, ba mũi tên đỏ từ ba phòng Tuyển sinh, Đào tạo và Kế toán cùng đổ vào một hộp Student duy nhất liệt kê các cột lẫn lộn của cả ba phòng, kèm cảnh báo ba phòng cùng sửa một file và bảng có nhiều cột null. Bên phải, mỗi phòng có một mũi tên xanh riêng đi vào hộp riêng của mình là Admission Applicant, Learning Enrollment, Finance Payer, ba hộp này nối xuống một viên chung là bảng users cột id, cho thấy danh tính dùng chung còn vai thì tách. Một model gánh cả ba app/Models/Student.php Tuyển sinh Đào tạo Kế toán Student name, email lead_source care_status class_id, gpa absent_count contract_no debt_amount … 53 cột nữa 3 phòng cùng sửa 1 file 28/60 cột luôn NULL — tuỳ người đó đang là vai gì đổi quy tắc chăm sóc lead → sợ vỡ bảng điểm Ba vai, chung một danh tính app/Domain/<Domain>/Models/ Tuyển sinh Đào tạo Kế toán Admission\Applicant Learning\Enrollment Finance\Payer lead_source, care_status class_id, absent_count contract_no, debt users.id (shared kernel) danh tính dùng chung — vai thì mỗi domain tự giữ
Đây là lỗi chia domain phổ biến nhất: đẻ domain cho một danh từ (“Student”) thay vì cho một nhóm quy tắc. Không có domain 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:

Đừ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ỏiVì 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 ngheVì sao saiNê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.

Bản đồ sáu domain của LMS với hướng phụ thuộc chảy xuống phía ổn định Xếp bốn tầng từ trên xuống. Trên cùng là Reporting chỉ đọc. Tầng hai gồm Finance, Assessment, Admission là nhóm đổi nhiều. Tầng ba gồm Learning và Scheduling. Tầng bốn là Catalog, phần ổn định nhất. Dưới cùng là dải Identity shared kernel mà mọi domain đều đọc. Các mũi tên xanh chỉ chảy từ trên xuống dưới. Một mũi tên đỏ từ Catalog ngược lên Finance bị gạch chéo kèm chữ ngược chiều thì cấm, dùng event thay thế. Bên phải có trục ghi đổi nhiều ở trên và ổn định ở dưới. Reporting — đọc mọi domain, cấm ghi read model cắt ngang, không phải domain Finance Assessment Admission Learning Scheduling Catalog hoá đơn, công nợ đề thi, bảng điểm lead, hợp đồng điểm danh, bài nộp lớp, buổi, phòng khoá, tín chỉ, tiên quyết Learning → Scheduling Catalog → Finance: cấm cần phản ứng → nghe event, không gọi ngược lên Identity (users, roles, permissions) — shared kernel: ai cũng đọc, chỉ nó ghi đổi nhiều khuyến mãi, luật thuế, quy chế thi ổn định đổi mỗi năm học 1 lần mũi tên chỉ đi xuống
Đọc hình từ dưới lên: Catalog là nền, không biết ai gọi nó. Finance ở trên cùng, biết nhiều nhất và bị phụ thuộc ít nhất — nên đổi chính sách giá không làm gãy gì. Reporting đứng ngoài, chỉ đọc. Identity nằm dưới cùng vì nó là thứ ổn định nhất. Có đúng một luật cần nhớ: mũi tên không bao giờ đi lên.

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ừ×2Chủ×2NhịpGhiĐờiTắtToĐiểmQuyết định
Tuyển sinh, hợp đồng, xếp lớp đầu vào✓✓✓✓✓✓✓10Admission
Khoá học, chương trình, tiên quyết✓✓✓✓✓✓✓10Catalog
Lớp học phần, lịch, phòng, phân công GV✓✓✓✓✓✓✓10Scheduling
Điểm danh, bài tập, bài nộp✓✓✓✓✓✓✓10Learning
Ngân hàng câu hỏi, đề thi, chấm, bảng điểm✓✓✓✓✓✓✓10Assessment
Học phí, hoá đơn, công nợ, hoàn tiền✓✓✓✓✓✓✓10Finance
Cấp chứng chỉ✓✗ vẫn Khảo thí✗✓✓✓✗ 2 bảng6sub-domain của Assessment
Phúc khảo điểm✗ dùng từ của Assessment✗✗✓✓✓✗3sub-domain của Assessment
Thông báo email/SMS✗ từ kỹ thuật, không phải nghiệp vụ✗ IT✓✓✓✓✓4capability, không phải domain
Người dùng & phân quyền✗✗ IT✗✓✗✗ tắt là chết✓2shared kernel
Báo cáo & dashboard✗ mượn từ mọi nơi✗ ai cũng đòi✓✗ không ghi gì✗✓✓3read model cắt ngang
Nhật ký thao tác (audit)✗✗✗✓✗✓✗2cross-cutting (observer)
Import/export Excel✗✗✗✗✗✓✗1adapter 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ọnQuyế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 →AdmissionCatalogSchedulingLearningAssessmentFinanceSupport
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ắtSố domain một PR chạm vàoNgay 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ơnThời gian để dev mới sửa được bug đầu tiênTừ ~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ầnpest --filter=LearningKhi suite test > ~2 phút.
Đàm phán với nghiệp vụ dễ hơnSố lần họp phải giải thích lạiNgay 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ơnSố chỗ phải sửa khi táchChỉ 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ế

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 codevẫn phải grep trong domain totốt nhấtkhông biết mở thư mục nào
Số lần đi qua cửaítvừanhiều — mỗi feature qua 3–4 cửa
Rủi ro chia saithấp (gộp dễ sửa hơn tách)vừacao
Hợp vớiteam ≤ 5, nghiệp vụ còn đổi nhiềuteam 5–15nhiề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:

6.3. Các cặp chọn lựa hay bị hỏi

Tình huốngChọnTiêu chí
Nghiệp vụ nằm giữa 2 domainĐặt ở nơi có người chủ của quy tắcAi 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ácGọi cửa nếu cần bây giờ; nghe event nếu chỉ cần phản ứngGọ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 transactionXem lại — có thể chúng là một domainNế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 ghiTách bảng, hoặc gán chủ + cho 2 domain kia gọi cửaMỗ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êngHạ 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:

  1. Tìm người chủ thật của quy tắc: giáo vụ đào tạo → domain Learning.
  2. 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.
  3. 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.
  4. Thêm architecture test cấm domain khác use model Attendance.
  5. 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ụ:

  1. 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.
  2. 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ùng Event::dispatch() trong DB::afterCommit(), và listener phải idempotent (chạy 2 lần vẫn đúng) — chi tiết ở bài at-least-once.
  3. 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 Enrollment sở 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ý:

  1. Chấp nhận app/Reporting/ được miễn luật đọc: join thẳng invoices + grades trong một query. Kèm architecture test cấm nó use bất kỳ Services\ nào — đọc thì được, ghi thì không.
  2. 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.
  3. Đừ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ý:

  1. Đ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.
  2. Trên 60% thì gộp: Assessment thành thư mục con của Learning, xoá Contracts giữa hai bên, giữ lại Contracts ra bên ngoài.
  3. 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:

  1. Tuần 1: tạo app/Domain/ rỗng + architecture test chưa bật luật nào. Chỉ có khung.
  2. 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.
  3. Bật một luật: "không ai ngoài Finance được use Finance\Models\". Sửa hết chỗ đỏ — đây là lúc phát hiện coupling thật sự nằm ở đâu.
  4. 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.
  5. 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ùng user_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."