Gom code theo layer, module hay aggregate?
Câu hỏi bài này trả lời: một thay đổi nghiệp vụ đi qua những file nào, và cây thư mục nào giúp tìm đúng nơi sở hữu quy tắc mà vẫn giữ phụ thuộc rõ ràng?
Cần biết trước: hàm/class, import và transaction. Bài so ba cách bố trí cùng một ứng dụng đặt hàng giả lập, không chọn framework hay triển khai microservice. Các cây và đường dẫn đã kiểm nhất quán; phần hành vi là đặc tả minh họa, chưa phải ứng dụng chạy được.
Ba khái niệm trả lời ba câu hỏi
| Khái niệm | Câu hỏi | Ví dụ trong bài |
|---|---|---|
| Layer | Thành phần chịu trách nhiệm kỹ thuật gì, phụ thuộc vào đâu? | HTTP nhận request, application điều phối, domain giữ quy tắc, adapter lưu SQL |
| Module | Nhóm code sở hữu vấn đề nghiệp vụ nào? | ordering và inventory |
| Aggregate | Nhóm đối tượng nào phải giữ quy tắc nhất quán qua một cửa cập nhật? | Order cùng OrderItem, trạng thái và sự kiện hủy |
Có thể kết hợp cả ba: chia module trước, giữ layer trong mỗi module, gom các kiểu domain theo aggregate. Chúng không phải ba kiến trúc loại trừ nhau; bài so ba mức bố trí để thấy mỗi mức làm rõ điều gì.
Tên folder không tự tạo bounded context, transaction hay service. Module inventory chỉ có ý nghĩa nếu sở hữu quyết định về tồn/reservation và có hợp đồng sử dụng rõ; một folder chung toàn bộ logic vẫn có thể phụ thuộc chằng chịt vào phần còn lại.
Giữ nguyên nghiệp vụ để so
Ứng dụng có đặt và hủy đơn, giữ và trả tồn kho. Order giữ các dòng hàng, mỗi dòng tham chiếu sản phẩm bằng product_id, quantity dương; dữ liệu giá là snapshot của đơn. StockItem sở hữu số lượng của một sản phẩm trong inventory, không trở thành entity con của Order chỉ vì Order cần sản phẩm đó.
Order là cửa cập nhật trạng thái của các dòng và của đơn. Application nạp Order qua OrderStore, gọi hành vi rồi lưu; SqlOrderStore thực hiện lưu trữ, còn OrdersHttp chuyển dữ liệu request thành lệnh. OrderStore là port của use case trong ví dụ này; nó không chứa query SQL.
Ở inventory, StockItem giữ 0 <= reserved <= on_hand; giữ/trả tồn đi qua hành vi của nó. Order chỉ lưu mã sản phẩm và reservation cần liên hệ, không tự sửa lượng tồn. Quy tắc hủy thuộc ordering, còn việc trả reservation phải được inventory kiểm lại và xử lý trùng theo hợp đồng riêng.
Các file ở ba cây đều là cùng 18 file, cùng vai trò. Phần notification/thanh toán chưa nằm trong case; không dùng chúng để làm một phương án trông phức tạp hơn phương án khác.
A — Layer ở ngoài, kiểu domain ở trong
app/
|-- interfaces/
| |-- OrdersHttp.py
| `-- InventoryHttp.py
|-- application/
| |-- PlaceOrder.py
| |-- CancelOrder.py
| |-- ReserveStock.py
| |-- ReleaseStock.py
| |-- OrderStore.py
| `-- InventoryStore.py
|-- domain/
| |-- entities/
| | |-- Order.py
| | |-- OrderItem.py
| | `-- StockItem.py
| |-- enums/
| | `-- OrderStatus.py
| `-- events/
| `-- OrderCancelled.py
|-- infrastructure/
| |-- SqlOrderStore.py
| `-- SqlInventoryStore.py
`-- tests/
|-- TestPlaceOrder.py
|-- TestCancelOrder.py
`-- TestReleaseStock.py
Nhìn tầng kỹ thuật dễ: toàn bộ adapter ở một chỗ, toàn bộ entry HTTP ở một chỗ. Khi đọc một hành vi đặt hàng, cần đi qua các thư mục trên cùng; đồng thời entities/ gom Order và StockItem dù hai kiểu thuộc hai vấn đề khác nhau.
Cấu trúc này vẫn có thể bảo vệ Order qua method, có test và có module logic rõ. Đừng suy rằng cây theo loại đồng nghĩa anemic domain hoặc sai DDD. Điểm yếu cần đo là chi phí tìm code và phụ thuộc thật, không phải tên Entities.
B — Module ở ngoài, layer ở trong
app/
`-- modules/
|-- ordering/
| |-- interfaces/
| | `-- OrdersHttp.py
| |-- application/
| | |-- PlaceOrder.py
| | |-- CancelOrder.py
| | `-- OrderStore.py
| |-- domain/
| | |-- Order.py
| | |-- OrderItem.py
| | |-- OrderStatus.py
| | `-- OrderCancelled.py
| |-- infrastructure/
| | `-- SqlOrderStore.py
| `-- tests/
| |-- TestPlaceOrder.py
| `-- TestCancelOrder.py
`-- inventory/
|-- interfaces/
| `-- InventoryHttp.py
|-- application/
| |-- ReserveStock.py
| |-- ReleaseStock.py
| `-- InventoryStore.py
|-- domain/
| `-- StockItem.py
|-- infrastructure/
| `-- SqlInventoryStore.py
`-- tests/
`-- TestReleaseStock.py
Người sửa hủy đơn bắt đầu ở ordering/; người sửa cách giữ tồn bắt đầu ở inventory/. Layer vẫn có trách nhiệm như A. Nhiều aggregate trong một module có thể làm domain/ đông; khi đó mới xét gom theo cụm domain.
Quyết định được che giấu: ordering sở hữu chuyển trạng thái Order; inventory sở hữu cách tính và giữ tồn. Hai module trao đổi qua use case/contract hoặc sự kiện, không sửa trực tiếp entity hay SQL adapter của nhau. Cây này không đòi hai database hoặc hai deploy độc lập.
C — Trong module, domain gom theo aggregate
app/
`-- modules/
|-- ordering/
| |-- interfaces/
| | `-- OrdersHttp.py
| |-- application/
| | |-- PlaceOrder.py
| | |-- CancelOrder.py
| | `-- OrderStore.py
| |-- domain/
| | `-- OrderAggregate/
| | |-- Order.py
| | |-- OrderItem.py
| | |-- OrderStatus.py
| | `-- OrderCancelled.py
| |-- infrastructure/
| | `-- SqlOrderStore.py
| `-- tests/
| |-- TestPlaceOrder.py
| `-- TestCancelOrder.py
`-- inventory/
|-- interfaces/
| `-- InventoryHttp.py
|-- application/
| |-- ReserveStock.py
| |-- ReleaseStock.py
| `-- InventoryStore.py
|-- domain/
| `-- StockItemAggregate/
| `-- StockItem.py
|-- infrastructure/
| `-- SqlInventoryStore.py
`-- tests/
`-- TestReleaseStock.py
Order, trạng thái và sự kiện cùng cụm nên dễ đọc hơn khi có nhiều aggregate. Đó là cách thể hiện quyền sở hữu domain trên đĩa; cơ chế bảo vệ vẫn là API của root, test và lưu trữ có kiểm soát.
CancelOrder, HTTP và SQL adapter vẫn ở layer tương ứng, không nhét mọi file liên quan Order vào OrderAggregate/. Một use case có thể điều phối nhiều ranh giới; aggregate không sở hữu framework hay giao thức HTTP. Aggregate StockItem một entity vẫn hợp lệ nếu có invariant riêng; không cần thêm entity con để folder có vẻ giống mẫu.
Cùng thay đổi: thêm quy tắc và lý do hủy
Yêu cầu mới: đơn đã gửi (SHIPPED) không được hủy; hủy cần lý do không chỉ gồm khoảng trắng; hủy lại đơn CANCELLED không sinh thêm sự kiện. Root sở hữu quyết định chuyển trạng thái. HTTP chỉ kiểm định dạng request; caller khác vẫn phải đi qua quy tắc của root.
Phác thảo hành vi, không phải code của một ứng dụng đã chạy:
Order.cancel(reason):
nếu status == CANCELLED: trả về không có event mới
nếu status == SHIPPED: từ chối, không đổi status
nếu reason.trim() rỗng: từ chối, không đổi status
status = CANCELLED
tạo OrderCancelled(order_id, reason.trim())
Thứ tự có chủ đích: yêu cầu hủy lặp là no-op, không ghi đè lý do hủy đầu tiên. Chính sách đó là lựa chọn nghiệp vụ của case, không phải quy tắc phổ quát. Kiểm authorization ở use case trước khi gọi root; tính idempotent không cho phép người lạ hủy đơn.
CancelOrder nhận lý do, nạp root, gọi cancel, rồi lưu trạng thái và sự kiện theo một ranh giới commit đã chọn. Inventory nhận mã Order từ sự kiện để trả reservation; lý do mới không buộc nó sửa code nếu không thuộc hợp đồng inventory. Không giả định commit của Order và xử lý inventory là một transaction phân tán tự động.
| File thay đổi | A — layer/type | B — module/layer | C — module/aggregate |
|---|---|---|---|
| Order.py | domain/entities/Order.py | modules/ordering/domain/Order.py | modules/ordering/domain/OrderAggregate/Order.py |
| OrderCancelled.py | domain/events/OrderCancelled.py | modules/ordering/domain/OrderCancelled.py | modules/ordering/domain/OrderAggregate/OrderCancelled.py |
| CancelOrder.py | application/CancelOrder.py | modules/ordering/application/CancelOrder.py | modules/ordering/application/CancelOrder.py |
| OrdersHttp.py | interfaces/OrdersHttp.py | modules/ordering/interfaces/OrdersHttp.py | modules/ordering/interfaces/OrdersHttp.py |
| TestCancelOrder.py | tests/TestCancelOrder.py | modules/ordering/tests/TestCancelOrder.py | modules/ordering/tests/TestCancelOrder.py |
Cả ba đều sửa 5 file; gom thư mục không tự giảm công việc nghiệp vụ. A đi qua năm khu vực kỹ thuật; B/C giữ toàn bộ thay đổi dưới ordering, còn C đặt hai kiểu domain cùng cụm. Trong case nhỏ này, C thêm tên aggregate mà chưa giảm số file so với B. Khi root/enum/event tách nhiều nơi và nhiều aggregate cùng tồn tại, tính cục bộ có thể đáng giá hơn; phải kiểm trên lịch sử thay đổi của dự án.
Hướng phụ thuộc và phép kiểm hành vi
Trong cả ba cây, application dùng domain và port; adapter thực hiện port; HTTP gọi use case, còn wiring chọn adapter. Domain không import HTTP client, SQL driver hoặc lớp lưu cụ thể. Đây là hợp đồng của ví dụ; kiểm bằng import/build rule mới cưỡng chế được, đổi tên folder không cưỡng chế được.
| Case cần test | Kết quả mong đợi | Điều test bảo vệ |
|---|---|---|
| CONFIRMED, lý do hợp lệ | CANCELLED, một event chứa lý do đã trim | Quy tắc và payload |
| SHIPPED, lý do hợp lệ | Từ chối, trạng thái giữ nguyên, không event | Không hủy sau gửi |
| CONFIRMED, lý do trắng | Từ chối, trạng thái giữ nguyên | Lý do bắt buộc |
| CANCELLED, yêu cầu lặp | Không event mới, giữ lý do đầu | Không phát sinh trả tồn lặp từ root |
| Hai caller cùng nạp CONFIRMED | Một commit hợp lệ hoặc phát hiện xung đột | Concurrency ở persistence, không chỉ method trong RAM |
Case cuối cần transaction/version check hoặc cơ chế tương đương ở adapter. Hai instance Order riêng có thể cùng thấy CONFIRMED và cùng tạo event trong RAM; aggregate root không tự khóa database. Phát sự kiện ra ngoài còn cần chống phát/trả tồn trùng ở nơi lưu và nơi nhận.
Các case là acceptance criteria để người đọc triển khai, chưa có số test chạy hay benchmark của ba ứng dụng. Phép kiểm bài này kiểm cây/path và nội dung tài liệu; nó không chứng minh import đúng hay concurrency đã giải quyết.
Chọn và đổi cấu trúc theo chi phí thật
| Hoàn cảnh | Cách bố trí đáng thử | Chi phí cần giữ trong tầm kiểm soát |
|---|---|---|
| Ứng dụng nhỏ, ít quy tắc, đội quen layer | A có thể đủ | Theo dõi file theo một nghiệp vụ, tránh domain dùng adapter trực tiếp |
| Nhiều nhóm nghiệp vụ, quyền sở hữu rõ | B | Hợp đồng module, tránh gọi vào chi tiết nội bộ module khác |
| Nhiều aggregate và kiểu phụ thuộc riêng mỗi root | C bên trong B | Xác định invariant đúng; tránh tách aggregate chỉ theo bảng |
Lấy vài thay đổi đã xảy ra: tìm root mất bao lâu, phải mở bao nhiêu vùng code, import nào đi qua biên và có sửa nhầm invariant không. Đếm file là một chỉ dấu; năm file nằm cạnh nhau vẫn có thể khó sửa nếu hợp đồng rối. Không suy “nhiều repository OSS dùng” thành “tối ưu cho đội mình”.
Chuyển A → B theo một nghiệp vụ trước: giữ hành vi/test, di chuyển code, sửa import/namespace, wiring, discovery test và cấu hình ORM/serializer nếu chúng dựa đường dẫn/tên. B → C chủ yếu đổi vị trí các kiểu domain, nhưng phải rà tên public được serialize hay reflection tìm class. Đổi folder thường không cần migration database nếu schema không đổi; không hứa chi phí luôn thấp.
Tránh vừa đổi folder vừa đổi mô hình dữ liệu, tách service và sửa chính sách hủy trong một lượt. Với mỗi bước, hỏi quyết định nào được giấu sau biên và caller cần biết gì. Nếu không trả lời được, tên module/aggregate có thể chỉ thêm thao tác điều hướng.
Lỗi thường gặp và học tiếp
- Root nằm trong folder aggregate nhưng controller sửa status trực tiếp: biên trên cây chưa thành biên hành vi.
- Shared/ chứa mọi kiểu dùng hai lần: cùng tên chưa chắc cùng ý nghĩa, và shared có thể buộc các module đổi cùng nhau.
- Mỗi bảng thành một aggregate: xác định bằng invariant và đơn vị thay đổi nhất quán, không chỉ hình dạng SQL.
- Gom theo module rồi lấy module làm service luôn: quyền sở hữu code và quyền triển khai là hai quyết định riêng, cần xét giao tiếp/dữ liệu/vận hành.
Deadlock qua hai session minh họa transaction và thứ tự lấy khóa: cây aggregate không làm các vấn đề đó tự biến mất. Tiêu chí hoàn tất có thể kiểm chứng giúp biến nhận định kiến trúc thành điều có thể review.
Nguồn sơ cấp đọc ngày 2026-10-03:
- Microsoft, Design a microservice domain model: entity, root và invariant. Bài áp dụng nguyên lý cho ví dụ một ứng dụng, không mặc định microservice.
- Microsoft, Design a DDD-oriented microservice: layer logic và hướng phụ thuộc. Vị trí port trong bài là lựa chọn thiết kế của ví dụ, không tuyên bố mọi mẫu đặt port cùng nơi.