GĐ08 — Clean Architecture: tầng, ranh giới, và khi nào đừng dùng
Vào đây sau GĐ07 (NestJS). Lý do: Clean Architecture chỉ có ý nghĩa khi bạn đã cảm thấy đau vì code rối. Nếu học trước, nó chỉ là một mớ thư mục bạn copy mà không hiểu vì sao.
Giai đoạn này là giai đoạn dễ bị làm quá nhất trong lộ trình. Mục tiêu không phải là "áp dụng Clean Architecture", mà là hiểu nguyên lý đằng sau nó (quy tắc phụ thuộc) rồi áp dụng đúng liều — vì phần lớn dự án chỉ cần một phần nhỏ của nó.
Kiểm chứng ngày 2026-10-05. Mã mẫu mới (mục 3.1, 6.1, 7.1 và bài tập 8) đã chạy với Node 24.21.0, Vitest 5.0.3, ESLint 10.12.0 (flat config) + typescript-eslint 8.71.0, NestJS 12.1.2, class-validator 0.15.1, và
tsc --strict(TypeScript 6.0.3 và 7.0.2 đều sạch). Phần Prisma ghi rõ "code tham chiếu, chưa chạy". Nguồn phiên bản: https://registry.npmjs.org/ và https://nodejs.org/en/about/previous-releases.
1. Vấn đề thật sự mà nó giải quyết#
Câu chuyện quen thuộc. Bạn viết OrderController gọi thẳng prisma.order.create(),
tính giảm giá ngay trong controller, gọi Stripe ngay trong đó luôn, rồi gửi email.
Sáu tháng sau:
- Muốn test logic giảm giá → phải dựng DB + mock Stripe + mock SMTP.
- Muốn thêm một đường vào khác (CLI, worker, gRPC) → phải copy logic.
- Muốn đổi Stripe sang Paddle → phải sửa ở 14 chỗ.
- Muốn biết "quy tắc giảm giá của hệ thống là gì" → phải đọc 8 file controller.
Chẩn đoán. Không phải "thiếu tầng". Vấn đề là logic nghiệp vụ bị trộn lẫn với chi tiết kỹ thuật, nên nó không thể được đọc, test, hay thay đổi độc lập.
Định nghĩa. Clean Architecture (Robert C. Martin, 2012) — cùng họ với Hexagonal Architecture / Ports & Adapters (Alistair Cockburn, 2005) và Onion Architecture (Jeffrey Palermo, 2008) — là một cách tổ chức code sao cho logic nghiệp vụ không biết gì về thế giới bên ngoài.
Ba tên gọi, một ý tưởng. Đừng để bị rối vì thuật ngữ.
2. Quy tắc phụ thuộc — thứ duy nhất phải nhớ#
Dependency Rule: mã nguồn chỉ được phụ thuộc vào phía trong. Không bao giờ ngược lại.
Bốn tầng, nói bằng tiếng Việt đời thường:
| Tầng | Chứa gì | Được import gì | Ví dụ |
|---|---|---|---|
| Domain | Thực thể + quy tắc nghiệp vụ luôn đúng bất kể ứng dụng nào | Không gì cả (chỉ ngôn ngữ + lib thuần) | Order biết "không huỷ được đơn đã giao" |
| Application | Use case — một hành động người dùng thực hiện | Domain + interface của cổng ra | PlaceOrderUseCase |
| Adapters | Dịch giữa trong và ngoài | Application + Domain | OrderController, PrismaOrderRepository |
| Infrastructure | Framework, DB, HTTP client, hàng đợi | Tất cả | Nest module, Prisma client, Stripe SDK |
Phép thử một câu. Mở file trong tầng Domain của bạn. Nếu thấy import { PrismaClient },
import { Request } từ Express, hay @Injectable() của Nest — bạn đã vi phạm
quy tắc. Domain không được biết những thứ đó tồn tại.
Tại sao quan trọng. Vì nó cho phép trả lời "quy tắc nghiệp vụ nằm ở đâu" bằng một thư mục, và test quy tắc đó không cần DB, không cần mạng, chạy trong mili-giây.
3. Dependency Inversion — cơ chế làm cho nó chạy được#
Đây là chỗ hầu hết người học bị tắc. Use case cần đọc DB, nhưng không được phụ thuộc vào Prisma. Giải quyết thế nào?
Bằng cách đảo chiều phụ thuộc: tầng trong định nghĩa interface, tầng ngoài hiện thực nó.
Nối dây trong NestJS — chỗ duy nhất tầng ngoài gặp tầng trong:
Đây chính là câu trả lời cho "DI để làm gì" mà bạn đã gặp ở GĐ07. DI không phải để "code đẹp" — nó là công cụ cơ khí để thực thi quy tắc phụ thuộc.
Sơ đồ: đảo chiều phụ thuộc
Đọc hình: ở cột phải, PlaceOrderUseCase không có dòng import nào tới Prisma. Thay Prisma bằng
InMemoryOrderRepository chỉ là đổi dòng useClass.
3.1 Transaction và outbox qua cổng UnitOfWork#
Mục 3 để PlaceOrderUseCase gọi orders.save() rời rạc. Khi một thao tác phải ghi nhiều thứ cùng nhau (đơn và ý định gửi sự kiện), use case cần nói "cùng một transaction" mà không biết Prisma tồn tại. Cách: thêm một cổng do tầng application sở hữu.
Adapter Prisma nằm ở infrastructure/ (code tham chiếu, chưa chạy; PrismaOrderRepository và PrismaOutbox nhận client của transaction thay vì PrismaService):
Hai quyết định đáng nói:
- Charge nằm ngoài transaction. Một lệnh gọi mạng chậm giữ kết nối DB (và khoá) suốt thời gian chờ, cạn pool khi tải cao. Cái giá là khe hở: charge xong rồi
uow.runlỗi thì tiền đã trừ mà chưa có đơn. Chốt bằng idempotency key truyền sang provider để lần thử lại không trừ tiền lần hai (GĐ10 mục 3). - Outbox thay cho
queue.add()trong use case. Một relay riêng đọc bảngoutboxrồi đẩy vào hàng đợi (GĐ10 mục 4); use case không biết BullMQ tồn tại.
Đã chạy (Vitest 5.0.3, fake InMemoryUnitOfWork ghi vào bản nháp và chỉ gộp khi work thành công; 4 test của use case qua):
| Tình huống | Kết quả quan sát |
|---|---|
| thanh toán bị từ chối | không có đơn, outbox rỗng |
| thành công | một đơn và một dòng order.placed có đúng orderId, totalMinor: 1000 |
uow.run lỗi sau khi charge | rollback: không đơn, không dòng outbox mồ côi; thử lại cùng key: provider được gọi 2 lần nhưng chỉ trừ 1 lần |
| đơn rỗng | EMPTY_ORDER ném ra trước khi chạm cổng thanh toán (0 lần gọi provider) |
Fake này kiểm hành vi use case, không chứng minh $transaction của Prisma rollback đúng: việc đó thuộc test tích hợp với DB thật (GĐ13).
4. Cái giá phải trả — nói thẳng#
Mọi tài liệu về Clean Architecture đều bán cho bạn lợi ích. Đây là hoá đơn:
| Chi phí | Cụ thể |
|---|---|
| Nhiều file hơn | Một CRUD entity: entity + repo interface + repo impl + mapper + 4 use case + DTO + controller ≈ 9 file thay vì 2 |
| Mapping thủ công | Domain ↔ persistence ↔ DTO. Ba hình dạng của cùng dữ liệu, phải viết và bảo trì chuyển đổi |
| Mất tiện ích của ORM | Không dùng được lazy loading, include lồng nhau, query builder linh hoạt nếu domain phải thuần |
| Đường học dốc cho người mới | Người mới vào team mất vài tuần mới biết đặt code ở đâu |
| Dễ bị áp dụng máy móc | Tạo interface IUserService chỉ có đúng một implementation mãi mãi — abstraction rỗng |
Khi nào chi phí này đáng:
- Nghiệp vụ phức tạp thật (bảo hiểm, ngân hàng, logistics, tính giá nhiều tầng), không phải CRUD có validate.
- Vòng đời dự án dài, nhiều người cùng làm.
- Có nhiều đường vào cùng một logic: HTTP + worker + CLI + cron.
- Ràng buộc nghiệp vụ cần test dày và test phải chạy nhanh.
Khi nào KHÔNG đáng:
- CRUD + validate + phân quyền. Đây là 80% API.
- Prototype, MVP, dự án dưới 3 tháng.
- Team một người và bạn đang học.
Lời khuyên thẳng thắn cho DA3 của bạn: đừng Clean Architecture toàn bộ. Áp dụng cho một module có nghiệp vụ thật (billing hoặc quota), giữ phần còn lại ở kiểu Nest thông thường. Bạn học được nguyên lý, giữ được tốc độ, và so sánh được hai kiểu trong cùng một codebase — đó là câu chuyện rất mạnh khi phỏng vấn.
5. Thang độ kiến trúc — chọn đúng bậc#
Đây là bảng hữu ích hơn bất kỳ sơ đồ vòng tròn nào:
| Bậc | Cấu trúc | Hợp với |
|---|---|---|
| 0 | Tất cả trong controller | Script, demo, không bao giờ cho production |
| 1 | Controller → Service → Repository | Mặc định tốt. 80% API dừng ở đây |
| 2 | Bậc 1 + module theo tính năng (không theo loại file) | Codebase bắt đầu lớn |
| 3 | Bậc 2 + interface cho cổng ra (repo, gateway) | Cần đổi hạ tầng, cần test không DB |
| 4 | Clean/Hexagonal đầy đủ: domain thuần + use case + mapper | Nghiệp vụ phức tạp thật |
| 5 | Bậc 4 + DDD chiến thuật (aggregate, domain event) + CQRS | Hệ lớn, nhiều team, event-driven |
Sai lầm phổ biến nhất: nhảy thẳng lên bậc 4-5 vì đọc blog. Sai lầm phổ biến thứ hai: mắc kẹt ở bậc 0-1 khi nghiệp vụ đã rõ ràng phức tạp.
Cách tổ chức thư mục ở bậc 2 — theo tính năng, không theo loại file:
Chứ không phải:
Cách thứ hai trông ngăn nắp nhưng làm mọi thay đổi trở thành thay đổi rải rác.
6. Domain model: thực thể "béo", không phải túi dữ liệu#
Anemic domain model (phản mẫu). Class chỉ có field + getter/setter, mọi logic nằm ở service:
Vấn đề: quy tắc "không huỷ đơn đã giao" có thể bị bỏ qua — bất kỳ ai cũng gán
order.status = 'CANCELLED' được. Sáu tháng sau bạn có ba nơi đổi status với ba
bộ quy tắc khác nhau.
Rich domain model. Trạng thái là private, thay đổi chỉ qua phương thức có ý
nghĩa nghiệp vụ (order.cancel(), order.markDelivered()). Đối tượng tự bảo vệ
tính hợp lệ của mình — gọi là invariant (bất biến nghiệp vụ).
Value Object. Kiểu dữ liệu nhỏ, bất biến, so sánh theo giá trị, tự validate:
Lợi ích cụ thể: Money.add khiến cộng USD với VND trở thành lỗi không thể
viết ra được, thay vì một bug phát hiện ở production. Đây là điều number không
bao giờ làm được.
Aggregate. Cụm object thay đổi cùng nhau, có một gốc (aggregate root) là
lối vào duy nhất. Order là root, OrderLine chỉ được sửa qua Order. Quy tắc
thực dụng: một transaction = một aggregate. Cần sửa hai aggregate cùng lúc →
dùng domain event + outbox (→ GĐ10 mục 4), không
nhét cả hai vào một transaction khổng lồ.
6.1 Order đầy đủ: id, dòng hàng, khôi phục từ DB#
Mục 3 chỉ phác Order và còn thiếu OrderId, OrderLine, markDelivered (mà test ở mục 8 gọi). Bản đầy đủ, chạy được:
Vì sao có restore riêng: place sinh id mới và áp quy tắc "tạo đơn"; đọc lại từ DB thì phải giữ id cũ và không được coi một đơn đã giao là đơn mới. OrderMapper ở tầng ngoài chỉ biết OrderSnapshot, không đụng vào field private của Order (code tham chiếu, chưa chạy):
Đã chạy (Vitest 5.0.3, tsc --strict sạch, 5 test của Order qua): place rồi markDelivered rồi cancel ném DomainError và trạng thái vẫn DELIVERED; đơn rỗng ném EMPTY_ORDER; đơn đã huỷ không giao được (CANNOT_DELIVER); restore(order.toSnapshot()) giữ nguyên id, trạng thái và tổng tiền (1100 với hai dòng hàng); restore với id không phải UUID ném INVALID_ORDER_ID thay vì dựng một đối tượng sai. Test markDelivered rồi cancel ở mục 8 chạy đúng với lớp này.
Lưu ý khi chạy thử: constructor có tham số private/readonly (parameter property) không chạy được bằng cơ chế bỏ kiểu của Node. Đã thử với một class tối thiểu trên Node 24.21.0: node file.ts báo ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX (parameter property is not supported in strip-only mode). Chạy qua tsc hoặc Vitest, hoặc viết field tường minh.
7. Lỗi ở biên: Result hay exception#
Domain ném lỗi kiểu gì và controller dịch ra HTTP thế nào? Đây là chỗ hai tầng gặp nhau và dễ rò rỉ nhất.
Nguyên tắc: tầng trong không biết HTTP tồn tại. Domain ném DomainError có
code, không bao giờ ném BadRequestException của Nest.
Hai trường phái:
Khuyến nghị cho lộ trình này: dùng A với exception filter duy nhất ở biên
dịch code → HTTP status. Đơn giản, hợp Nest, và cùng cơ chế bạn đã có ở
GĐ04 và GĐ07.
Dùng B khi lỗi là phần bình thường của luồng (validate form nhiều trường,
parse input), nơi exception làm code khó đọc.
7.1 Controller mỏng: từ HTTP tới use case#
Controller là adapter vào: dịch HTTP thành đầu vào use case rồi dịch kết quả ngược lại, không chứa quy tắc nghiệp vụ. Kiểu validate theo GĐ07 mục 6 (class-validator + ValidationPipe bật whitelist/forbidNonWhitelisted); lỗi dịch bằng DomainExceptionFilter ở mục 7. Import tương đối ESM phải có đuôi .js.
Đã chạy (Nest 12.1.2, use case nối với InMemoryUnitOfWork và cổng thanh toán giả, tsc --strict sạch, gọi bằng fetch):
| Request | Kết quả |
|---|---|
hợp lệ, có Idempotency-Key | 201 { orderId, totalMinor: 1000 } |
| không có header đăng nhập | 401 (guard mẫu ném UnauthorizedException) |
thiếu Idempotency-Key | 400 |
qty: -1 | 400 lines.0.qty must not be less than 1 |
lines: [] | 400 lines must contain at least 1 elements |
body có thêm userId: "hacker" | 400 property userId should not exist |
| cổng thanh toán từ chối | 402 { "error": "PAYMENT_FAILED" } qua filter |
Sau chuỗi trên chỉ có một đơn và một dòng outbox: các request bị chặn không chạm tới use case. Hai chỗ cần để ý:
- Trong Nest 12 kiểu của
@Headers()chỉ nhận tên header, không nhận pipe (tscbáo TS2554 khi truyềnnew ParseUUIDPipe()), nên kiểm header ngay trong handler hoặc viết decorator tham số riêng. - Hai tầng kiểm tra là cố ý: DTO chặn input sai hình ở biên (400), còn bất biến thật (
EMPTY_ORDER422,INVALID_QTY) vẫn nằm trong domain, vì use case còn có đường vào khác ngoài HTTP (worker, CLI).
8. Test — phần thưởng thật sự#
Đây là lợi ích cụ thể nhất, đo được nhất của kiến trúc này.
Fake, không phải mock. InMemoryOrderRepository là một hiện thực thật và
đơn giản của interface. Nó không vỡ khi bạn đổi thứ tự lời gọi, không cần
expect(mock).toHaveBeenCalledWith(...), và test đọc lên như đặc tả nghiệp vụ.
Nhưng. Test này không chứng minh SQL của bạn đúng. Vẫn phải có test tích hợp với DB thật — GĐ13 giải thích vì sao "không mock DB" là nguyên tắc. Kiến trúc này cho bạn thêm một tầng test nhanh, không thay thế tầng test tích hợp.
Kết quả mong đợi, và vì sao test thứ hai ở trên chưa đủ chặt
Code tham chiếu, chưa chạy; kết quả là suy ra từ code. Test thứ hai gọi repo.findById(anyId) với một id tuỳ ý nên
luôn ra null, dù use case có lưu đơn hay không: nó không bắt được lỗi nó định bắt. Viết chặt hơn: kiểm chính thứ đã lưu.
Mong đợi: store.size bằng 0 với thứ tự trong mục 3 (charge rồi mới save). Đảo hai dòng (save trước, charge sau) thì
test fail với expected 1 to be 0: đó là cách chứng minh test có răng. Test domain (Order.place, cancel) chạy
thuần bộ nhớ nên cỡ vài mili-giây mỗi test.
Một cạnh sắc của chính ví dụ mục 3: nếu charge thành công rồi save lỗi, tiền đã bị trừ mà không có đơn. Đó là bài
toán hai hệ thống; lời giải là PENDING → DONE kèm idempotency key phía provider (GĐ10 mục 3).
9. Phản mẫu — dấu hiệu bạn đang làm quá#
| Dấu hiệu | Vì sao xấu | Nên làm |
|---|---|---|
IUserService chỉ có một impl, mãi mãi | Abstraction rỗng, thêm một lần nhảy khi đọc code | Bỏ interface. Thêm khi có impl thứ hai thật sự |
| Repository trả về DTO thay vì entity | Rò rỉ hình dạng persistence vào tầng trong | Repo trả entity domain; mapper nằm ở tầng ngoài |
| Use case gọi use case gọi use case | Chuỗi gọi khó lần, transaction mờ | Use case gọi domain service, không gọi use case khác |
Entity domain có decorator @Column/@Entity | Domain phụ thuộc ORM → vi phạm quy tắc | Tách entity domain khỏi model persistence |
| Mapper 300 dòng cho một entity | Domain và schema lệch nhau quá xa | Xem lại: có thể bạn cần ít tầng hơn, không phải nhiều hơn |
| DTO = entity = model DB (cùng một class) | Đổi cột DB làm vỡ API công khai | Tách ít nhất DTO khỏi model DB |
| Mỗi CRUD field có một use case riêng | Nghi lễ không mang lại gì | CRUD dùng thẳng service. Use case dành cho hành động có nghiệp vụ |
Câu hỏi tự kiểm mỗi khi định thêm một lớp trừu tượng:
"Cái này chống lại một thay đổi tôi thực sự dự kiến, hay một thay đổi tôi tưởng tượng?"
Nếu là tưởng tượng — đừng thêm. Quy tắc YAGNI thắng Clean Architecture.
10. Bài tập — refactor một module của DA3#
Yêu cầu.
-
Chọn một module có nghiệp vụ thật trong DA3 (gợi ý: billing/quota, hoặc order nếu có).
Đáp án
Chọn
billing/quota("trừ lượt dùng, không vượt hạn mức"): có quy tắc thật (0 <= used <= limit) mà nhỏ. Tiêu chí chọn: có invariant cần bảo vệ, có ít nhất hai đường vào (HTTP, worker), test cần nhanh. Module CRUD thuần thì không đáng. -
Tách thành
domain/(entity + value object, không import gì từ Nest/Prisma),application/(use case + port interface),infrastructure/(repo Prisma + gateway ngoài).Lời giải và cách kiểm tra
Code tham chiếu, chưa chạy. Chiều import:
infrastructure --> application --> domain. Xem Khung và mã dùng chung ở dưới cho cây thư mục và mãQuota,ConsumeQuota,QuotaRepository.Adapter Prisma:
findđọc hàng rồiQuota.restore(...);saveghi lại. Cạnh sắc cần chốt: hai request song song cùng đọcused = 9, cùng ghi10và vượt hạn mức (lost update). Chọn một: cộtversion(optimistic lock,UPDATE ... WHERE version = $n, 0 dòng thì ném lỗi để retry) hoặc câu nguyên tửUPDATE quota SET used = used + $1 WHERE user_id = $2 AND used + $1 <= "limit"(limitlà từ khoá dành riêng của PostgreSQL nên phải đặt trong dấu nháy kép hoặc đổi tên cột). Test tích hợp với DB thật phải phủ chỗ này.Lỗi hay gặp: để
@Injectable()trongapplication/; repo trả row Prisma thay vì entity. -
Thêm lint rule chặn vi phạm quy tắc phụ thuộc — đây là phần quan trọng nhất, vì kỷ luật thủ công luôn thua:
typescriptReadyĐáp án
Mong đợi (chưa chạy trên dự án này; riêng mẫu
**/generated/prisma/**đã thử bằng ESLint 10 trong thư mục tạm và báono-restricted-imports): thêm vàodomain/quota.tsdòngimport { PrismaClient } from '../../../generated/prisma/client.js'(Prisma 7 không còn import từ@prisma/client, nên mẫu@prisma/*một mình không bắt được; điều chỉnh số../theo vị trí file) thìnpx eslint srcbáono-restricted-importsvà exit code khác 0. Tự thử một file vi phạm cố ý cho từng mẫu, đặc biệt đường dẫn tương đối như../application/...: đừng tin mẫu khớp đúng khi chưa thấy nó đỏ. Đưa vào CI:npx eslint src --max-warnings 0.Lưu ý:
typescript-eslint8 chưa chạy với TypeScript 7; ghim TypeScript tương thích cho bước lint. -
Viết test domain chạy không cần DB; đo và ghi lại thời gian chạy.
Đáp án
typescriptReadyChạy
npx vitest run src/modules/quota/domain, ghi số liệu thật của máy bạn ở dòngDuration(mong đợi: cỡ vài mili-giây mỗi test vì thuần bộ nhớ). Chưa chạy. -
Viết
InMemoryRepositoryvà test use case bằng nó.Đáp án
typescriptReadyKiểm trạng thái thật (
usedUnits), khôngtoHaveBeenCalledWith. Code tham chiếu, chưa chạy. -
Giữ nguyên các module khác ở kiểu Nest thường.
Đáp án
Tự kiểm:
git diff --statchỉ chạmsrc/modules/quota/**, cấu hình lint và README; các module khác không đổi import hay cấu trúc. Lint rule chỉ áp dụng chosrc/modules/quota/**nếu bạn muốn so sánh công bằng (đổifilestừ*thànhquota). -
Trong README, viết một đoạn so sánh hai kiểu trong chính codebase này: số file, thời gian test, mức dễ đọc — và kết luận bạn sẽ chọn kiểu nào cho module tiếp theo, kèm lý do.
Đáp án
Bảng README gợi ý (điền số thật của máy bạn):
Module quota (Clean) Module khác (Nest thường) Số file nguồn ghi số thật ghi số thật Thời gian chạy test ghi số thật ghi số thật Dễ đọc / dễ đổi nhận xét của bạn nhận xét của bạn Chọn cho module tiếp theo kết luận và lý do Lỗi hay gặp: ghi "Clean tốt hơn" mà không có số đo nào; kết luận không gắn với mức phức tạp nghiệp vụ (mục 4).
-
Cố ý vi phạm quy tắc phụ thuộc, từng cách một, và xác nhận lint báo đỏ. Một rule chưa từng thấy đỏ thì chưa có bằng chứng nó hoạt động.
Đáp án
Dùng đúng cấu hình ở bài 3. Tạo năm file vi phạm (xoá sau khi thử) rồi chạy
npx eslint src:File vi phạm Dòng import Kết quả domain/bad-nest.tsimport { Injectable } from '@nestjs/common'báo no-restricted-importsdomain/bad-relative.tsimport type { OrderRepository } from '../application/ports/order-repository.js'báo (mẫu **/application/**khớp cả đường dẫn tương đối vàimport type)domain/bad-deep.tsimport { OrdersModule } from '../infrastructure/orders.module.js'báo application/bad-infra.tsimport { OrdersModule } from '../infrastructure/orders.module.js'báo application/bad-prisma.tsimport { PrismaClient } from '../../../generated/prisma/client.js'báo (mẫu **/generated/prisma/**)Đã chạy (ESLint 10.12.0, typescript-eslint 8.71.0, trên cây
orders/ở mục 3.1 đến 7.1): trước khi thêm file vi phạmeslint srcthoát mã 0; sau khi thêm, 5 lỗino-restricted-imports, mã thoát 1; xoá các file vi phạm thì lại thoát 0. Chưa thửrequire()/import()động: rule này không phải bằng chứng mọi đường import đều bị chặn, nên giữ thêm review khi có import lạ.Đưa ba file vi phạm đầu vào một nhánh thử trong CI để thấy pipeline đỏ, rồi bỏ. Lỗi hay gặp: viết mẫu bằng đường dẫn cứng (
../../infrastructure) nên import từ thư mục khác lọt qua.
Mục 7 là sản phẩm giao quan trọng nhất. Nó chứng minh bạn có phán đoán kiến trúc, không phải chỉ biết làm theo mẫu.
Khung và mã dùng chung
Code tham chiếu, chưa chạy. Cây thư mục và chiều import:
Done khi#
-
Phát biểu được Dependency Rule trong một câu
Đáp án
Mã nguồn chỉ được phụ thuộc vào phía trong (domain), không bao giờ ngược lại: domain không biết use case, framework hay DB. Phép thử: file trong
domain/không cóimporttừ Nest, Prisma, Express. Xem mục 2. -
Biết Clean / Hexagonal / Onion là ba tên của cùng một ý tưởng
Đáp án
Clean (Martin), Hexagonal / Ports & Adapters (Cockburn), Onion (Palermo) đều đặt nghiệp vụ ở lõi, hạ tầng ở rìa, phụ thuộc hướng vào trong. Khác nhau về cách vẽ và từ vựng, không khác nguyên lý. Xem mục 1.
-
Giải thích được Dependency Inversion: tầng trong sở hữu interface, tầng ngoài hiện thực nó
Đáp án
Use case cần đọc DB nhưng không được import Prisma, nên tầng trong định nghĩa interface (
OrderRepository) và tầng ngoài hiện thực (PrismaOrderRepository). Nhờ vậy mũi tên mã nguồn chỉ vào trong dù luồng chạy đi ra ngoài. Sai thường gặp: đặt interface cạnh implementation ở tầng ngoài. Xem mục 3. -
Nối được liên hệ: DI của NestJS chính là cơ chế thực thi kiến trúc này
Đáp án
Container Nest là chỗ duy nhất nối
ORDER_REPOSITORYvớiPrismaOrderRepository(provide/useClass/useFactory). DI không phải để "code đẹp"; nó là cơ chế cơ khí thực thi quy tắc phụ thuộc, và để thay fake khi test. Xem mục 3, GĐ07. -
Định vị được dự án của mình trên thang 0–5 và bảo vệ được vị trí đó
Đáp án
Trả lời kiểu: "DA3 ở bậc 2-3: Controller → Service → Repository, module theo tính năng, interface cho cổng ra ở module billing; chưa cần aggregate/CQRS vì nghiệp vụ chưa phức tạp". Bảo vệ bằng ba câu hỏi: nghiệp vụ có phức tạp thật không, có nhiều đường vào không, test có cần nhanh không. Xem mục 5.
-
Tổ chức thư mục theo tính năng, không theo loại file
Đáp án
modules/orders/{domain,application,infrastructure}thay vìcontrollers/,services/,repositories/: một thay đổi về "orders" chạm một thư mục. Tự kiểm: thêm một field cho Order chỉ phải mở một thư mục. Xem mục 5. -
Phân biệt anemic vs rich domain model; viết được Value Object tự validate
Đáp án
Anemic: class chỉ có field, quy tắc ở service nên bị bỏ qua được. Rich: trạng thái
private, đổi quaorder.cancel()để tự bảo vệ invariant. VO: bất biến, so theo giá trị, validate ởcreate(Email.create,Money.of). Xem mục 6. -
Giải thích aggregate và quy tắc một transaction = một aggregate
Đáp án
Cụm object đổi cùng nhau qua một root (
OrdervớiOrderLine). Một transaction chỉ sửa một aggregate; cần sửa hai thì dùng domain event + outbox (eventual consistency). Sai thường gặp: một transaction khổng lồ khoá nhiều aggregate. Xem mục 6, GĐ10. -
Domain ném
DomainErrorcócode; chỉ một chỗ ở biên dịch sang HTTP statusĐáp án
Domain ném
DomainError(code), không némBadRequestException; một@Catch(DomainError)filter có bảngcode → statusduy nhất. Tự kiểm:grep -r "HttpException\|BadRequestException" src/modules/*/domainrỗng. Xem mục 7. -
Test domain chạy không cần DB; dùng fake thay vì mock từng phương thức
Đáp án
Order.place/canceltest thuần bộ nhớ, không DB. Use case test bằngInMemoryRepository(hiện thực thật, đơn giản) và kiểm trạng thái chứ khôngtoHaveBeenCalledWith. Không thay test tích hợp với DB thật. Xem mục 8. -
Nhận ra được ít nhất 4 phản mẫu ở mục 9 trong code của chính mình
Đáp án
Ví dụ:
IUserServicemột implementation; repo trả DTO; entity có@Column; mapper 300 dòng; DTO = entity = model DB. Mỗi mục phải gắn với một file thật trong code của bạn. Xem mục 9. -
Có lint rule chặn vi phạm quy tắc phụ thuộc trong CI
Đáp án
Có
eslint.config.mjsvớino-restricted-importstheo thư mụcdomain/vàapplication/, chạy trong CI. Tự kiểm: cố ý thêm một import vi phạm, CI phải đỏ. Xem mục 10 (và lời giải). -
Nêu được ít nhất 3 chi phí thật của kiến trúc này — không chỉ lợi ích
Đáp án
Ít nhất ba: nhiều file hơn (~9 so với 2 cho một CRUD), mapping thủ công ba hình dạng dữ liệu, mất tiện ích ORM, đường học dốc, dễ tạo abstraction rỗng. Bonus: nêu khi nào không đáng (CRUD, MVP, một người). Xem mục 4.
-
Viết được một use case ghi nhiều thứ cùng nhau qua cổng
UnitOfWork, với lệnh gọi hệ thống ngoài nằm ngoài transactionĐáp án
Use case gọi
uow.run(({ orders, outbox }) => ...); adapter Prisma bọc$transaction. Charge chạy trước, ngoài transaction, để không giữ kết nối DB lúc chờ mạng; khe hở "đã trừ tiền nhưng chưa có đơn" chốt bằng idempotency key truyền sang provider. Tự kiểm: test với fakeInMemoryUnitOfWorkchouow.runlỗi thì không còn đơn và không còn dòng outbox mồ côi. Sai thường gặp: gọi API thanh toán bên trong$transaction. Xem mục 3.1, GĐ10 mục 3, 4. -
Dựng được
Ordercórestore/toSnapshotđể mapper không cần đụng fieldprivateĐáp án
placetạo mới và kiểm bất biến;restore(snapshot)giữ id cũ và dựng lại từ dữ liệu phẳng;toSnapshot()trả hình phẳng cho mapper. Tự kiểm:Order.restore(order.toSnapshot())ra cùng id, trạng thái và tổng tiền;restorevới id hỏng némDomainError. Xem mục 6.1. -
Viết được controller mỏng: DTO validate ở biên,
userIdlấy từ token, lỗi domain dịch bằng filterĐáp án
DTO dùng
class-validatorvớiwhitelistvàforbidNonWhitelisted; controller chỉ gọiplaceOrder.execute({ userId: req.user.id, ... }). Tự kiểm bằng curl: body cóuserIdthừa ra400; thanh toán bị từ chối ra402qua filter; không có logic nghiệp vụ trong controller. Sai thường gặp: nhậnuserIdtừ body. Xem mục 7.1. -
Đã thấy lint rule phụ thuộc báo đỏ với từng kiểu vi phạm, kể cả import tương đối
Đáp án
Thêm file vi phạm cố ý cho
@nestjs/*,../application/...,../infrastructure/..., đường dẫn Prisma sinh ra;npx eslint srcphải thoát mã khác 0. Bằng chứng nằm ở bài tập 8 của mục 10 (5 lỗi, mã thoát 1). Sai thường gặp: tin cấu hình đúng mà chưa từng thấy nó đỏ.
Câu hỏi mở / chưa giải quyết#
-
CQRS có nên vào đây không? Tách đường đọc và đường ghi giải quyết vấn đề thật (đọc cần hình dạng khác ghi), nhưng nó là bậc 5. Đề xuất thực dụng: cho phép đường đọc đi tắt từ controller xuống query thẳng DB (bỏ qua domain), giữ đường ghi đi qua use case. Bất đối xứng có chủ đích này giữ được phần lớn lợi ích với phần nhỏ chi phí.
Hướng trả lời hiện tại
Với một dự án đầu tiên, cho đường đọc đi tắt (query thẳng DB, trả DTO) và giữ đường ghi qua use case là đủ; chỉ tách mô hình đọc/ghi khi truy vấn đọc cần hình dạng khác hẳn hoặc cần scale riêng. Chưa kết luận chắc chắn.
-
Event sourcing cố tình không có trong lộ trình này. Nó thay đổi cách bạn lưu trữ dữ liệu ở mức căn bản và hiếm khi là lựa chọn đúng cho dự án đầu tiên.
-
Bao nhiêu mapping là quá nhiều? Chưa có ngưỡng khách quan. Tín hiệu thực dụng: nếu mapper dài hơn entity nó ánh xạ, tầng của bạn đang lệch quá xa.
Hướng trả lời hiện tại
Dùng tín hiệu "mapper dài hơn entity" làm chuông báo; khi nó kêu, thử gộp hình dạng (ví dụ cho DTO và entity dùng chung kiểu) trước khi thêm tầng. Chưa kết luận chắc chắn.