Ở phần trước, chúng ta đã xem xét cách thiết kế coi Brand như một Value Object trang trí cho Product. Nếu có nhu cầu snapshot dữ liệu tại một thời điểm (sau này sẽ xuất hiện use case cần điều này trong Marketplace) thì đây là một lựa chọn rất phù hợp. Tuy nhiên, trước mắt ta đang thấy những khó chịu với thiết kế này với dữ liệu thiếu nhất quán ở Product. Để khắc phục, cụ thể là bất cứ khi nào Brand thay đổi, các Product sử dụng nó cũng có được thông tin cập nhật, thì ta cần biến Brand trở thành Entity: Một Brand có vòng đời riêng.
Lúc trước, Admin quản lý danh sách whitelist Value của Brand thì giờ quản lý chính Brand. Cũng như mọi Entity khác, Brand cần có Identity, một số thuộc tính, một số hành vi, ...
Nested-Entity
Cụm từ Nested-Entity cho thấy rằng Brand không đứng độc lập, nghĩa là nó không phải Aggregate Root, mà nó sẽ nằm trong một Aggregate Root khác, ở đây là Product.
Giả sử Brand có các thuộc tính cơ bản: code (mã ngắn gọn), name, description, và status (ACTIVE, INACTIVE, PENDING):
public class Brand { private String code; private String name; private String description; private BrandStatus status; ...}
Hãy khoan nói về BrandId. Code Product như sau:
public class Product { private ProductId id; private String title; private Brand brand; ...}
Sơ đồ Class thể hiện tương ứng:
Góc nhìn về Aggregate
Product là Aggregate Root, còn Brand là Entity bên trong. Hãy nhớ lại các quy tắc Aggregate trong các bài viết này: Aggregate-1, Aggregate-2.
Aggregate là ranh giới bao quanh các Entity và Value Object, nó là điểm truy cập duy nhất đến mô hình cho các client muốn sử dụng. Một số góc nhìn:
- Aggregate là thiết kế hướng đối tượng: Có trạng thái và hành vi. Trạng thái chỉ được thay đổi thông qua hành vi.
- Aggregate là cài đặt tính đóng gói: Nó che đi nội dung, cấu trúc bên trong, chỉ chìa ra những hành vi mang ý nghĩa trong Domain.
- Aggregate là mô hình nghiệp vụ, có thể coi nó là một module nghiệp vụ trong Bounded Context.
- Aggregate là ranh giới transaction - một yếu tố đến từ kỹ thuật thuần túy.
Trên đây là các góc nhìn chủ yếu của tôi về Aggregate. Theo bạn, góc nhìn nào quan trọng nhất?
Để trả lời câu hỏi này, hãy thử hình dung nếu không có nó sẽ ra sao. Lúc này ta chỉ có Entity và Value Object. Vẫn rất ổn vì thực ra nghiệp vụ được cài đặt vào Entity và Value Object. Hãy xem lại ví dụ này về Transaction Script. Ở kết quả cuối cùng của quá trình refactoring, ta có code như sau:
@Overridepublic void assignShipment(String shipmentID) { Shipment shipment = shipmentRepository.findById(shipmentID) .orElseThrow(() -> new ShipmentNotFoundException("Shipment with ID " + shipmentID + " not found.")); Driver driver = driverFinder.findDriver(shipment); shipment.assignDriver(driver); driver.assignShipment(shipment, feeCalculator); driverRepository.save(driver); shipmentRepository.save(shipment);}
Đoạn code này là dạng triển khai Domain Model tổng quát - tức một dạng thiên về hướng đối tượng mà thiếu mất các quy tắc chặt chẽ hơn của DDD. Phương thức assignShipment này chắc chắn cần chạy trong một Transaction (giả sử 1 db) để đảm bảo tính chất giao dịch. Nhưng bạn có thể thấy, trong transaction đó, có hai Entity được thay đổi trạng thái thông qua hai lời gọi shipment.assignDriver() và driver.assignShipment(). Cả hai sau đó được save xuống db đảm bảo ACID.
Không có vấn đề gì quá trầm trọng, ta đi vào những thứ tinh vi hơn bên dưới:
- Vấn đề hiệu năng và tương tranh. Một transaction của ta cần cập nhật đồng thời vào 2 models: Brand và Product (tôi gọi là model vì hai bảng này có thể có xung quanh những bảng phụ khác). Khi nhiều giao dịch diễn ra đồng thời, thời gian lock bảng sẽ dài hơn, thậm chí dẫn tới deadlock.
- Vấn đề về scaling. Đây có lẽ là lý do quan trọng nhất. Nếu trong tương lai, ta muốn tách Brand và Product riêng ra hai triển khai vật lý tách biệt (nghĩa là 2 services), giao dịch trên trở thành một giao dịch phân tán thay vì cục bộ, làm tăng đáng kể độ phức tạp của hệ thống cũng như các yếu tố khác như hiệu năng, tính sẵn sàng.
Vì thế, hãy nhìn Aggregate dưới dạng một ranh giới giao dịch cục bộ.
Hoạt động của Product và Brand
Quay lại thiết kế ở Hình 1 bên trên, Product là Aggregate Root nên để truy cập, thao tác với Brand, ta cần đi qua Product.
Tạo mới Brand
Do thiết kế này, ta không thể làm điều sau:
Brand brand = new Brand("apple", ...);brandRepository.save(brand);
Bởi Product là Root, mọi thao tác với Brand phải qua nó. Thật tréo ngoe khi tạo mới Brand lại cần phải có Product gán vào:
String aBrandCode = "apple";Product product = new Product(anId, aTitle, aBrandCode, ...);
Bên trong Product constructor:
public Product(ProductId id, String title, String brandCode, ...) { ... this.brand = new Brand(brandCode, ...);}
Hoặc ta có thể theo cách này:
Brand brand = new Brand("apple", ...);Product product = new Product(anId, aTitle, brand, ...);productRepository.save(product);
Cả hai cách đều thể hiện vòng đời của Brand được quản lý bởi Product.
Ta không thể tạo mới Brand mà không có Product đi cùng. Và còn xa hơn nữa, ta không thể chia sẻ cùng một Brand với nhiều Product khác nhau. Chỉ những yếu tố này thôi đã cho thấy đây là một thiết kế không chạy được. Điều này dẫn chúng ta đến một thiết kế khác, ngược chiều lại: Brand là Aggregate Root, Product là Nested-Entity.
Aggregate Root
Khi Brand là Aggregate Root, còn Product là Nested-Entity, sơ đồ class đảo chiều:
Band lúc này sẽ quản lý vòng đời của các Product thuộc về nó. Nghe khá hợp lý, giống như Order quản lý OrderItems vậy.
public class Brand { private String code; private String name; private String description; private BrandStatus status; private List products; ...}
Ta sẽ thấy tính tự nhiên của thiết kế này qua một số use case sau:
Tạo mới/cập nhật Brand
Brand brand = new Brand("apple", ...);brandRepository.save(brand);
Brand được tạo mới mà không cần có Product.
Tạo Product
Để tạo Product, ta có thể làm giống như trường hợp trước khi thiết kế nested Brand: Tạo Product trước rồi đưa vào Brand hoặc tạo bên trong Brand. Đây là cách thứ hai:
public class Brand { ... public void createProduct(String productTitle, ...) { ProductIdGenerator productIdGenerator = new ProductIdGenerator( new IntegerRandomGenerator()); ProductId id = productIdGenerator.generate(); Product product = new Product(id, productTitle, ...); this.products.add(product); }}
Ta đã tạo thêm một hành vi cho Brand: createProduct() của chính Brand đó. Code client sẽ như sau:
// Code tại Application Use Casepublic void createProduct(String brandCode, String productTitle, ...) { Brand brand = brandRepository.findByBrandCode(brandCode); brand.createProduct(productTitle, ...); brandRepository.save(brand);}
Để cập nhật Product, hãy hình dung, trên ứng dụng có một form quản lý Product, User mở Product đó ra rồi chỉnh sửa và click "Save". Thao tác chỉnh sửa chỉ duy nhất một Product nhưng phía Domain cần làm gì với thiết kế này?
Brand cần mở ra cách thức cập nhật Product:
public class Brand { ... public void updateProduct(ProductId productId, String productTitle, ...) { Product product = findProductById(productId); product.manageAttributes(productTitle, ...); }}
Code sử dụng:
// Code tại Application Use Casepublic void updateProduct(String brandCode, ProductId productId, String productTitle, ...) { Brand brand = brandRepository.findByBrandCode(brandCode); brand.updateProduct(productId, productTitle, ...); brandRepository.save(brand);}
Có thể thấy một mô-tip: Lấy lên Brand, thực hiện chỉnh sửa Brand hoặc thêm mới/chỉnh sửa Product, save Brand xuống. Đó đúng là việc quản lý vòng đời của một Aggregate trong DDD.
Vì danh sách Product nằm trong Brand - là một Aggregate Root, và mục đích của Aggregate là nhằm tạo thành một ranh giới local transaction, nên dù chỉ chỉnh sửa một Product, ta cũng cần lưu lại toàn bộ Brand và danh sách Product của nó. Xử lý chân thực nhất là thực hiện câu lệnh UPDATE cho toàn bộ những thứ này trong database. Theo chiều ngược lại, khi lấy lên một Product cụ thể của Brand, ta cũng cần lấy toàn bộ Brand và do đó, lấy theo toàn bộ danh sách Product của Brand đó. Các vấn đề xuất hiện:
- Rủi ro OOM. Trên một Marketplace, số lượng Product của một Brand có thể rất lớn, việc tải toàn bộ danh sách này khi lấy lên Brand đó cố thể gây tràn bộ nhớ ứng dụng. Một cách khắc phục mang tính kỹ thuật là sử dụng Lazy-Load: Khi cập nhật một Product cụ thể, ta chỉ load Brand lên nhưng chỉ kèm theo duy nhất Product đó. Khi lưu xuống db, ta cũng chỉ cập nhật Product đó thôi. Lúc này ta có hàm
BrandRepository.findByBrandCodeAndProductId(brandCode, productId). Tôi đánh giá, đây là giải pháp mang tính chữa cháy hơn là một thiết kế chuẩn vì triển khai Lazy-Load hoàn toàn phụ thuộc vào tầng Adapter trong Hexagonal hơn là Domain quyết định. - Concurrency. Xem lại mô tả trên ta thấy, một User chỉnh sửa một Product. Nếu một User phụ trách toàn bộ Product của một Brand thì không có gì xảy ra. Nhưng thực tế là có thể có nhiều User cùng sửa những Product khác nhau của cùng một Brand: User A sửa Product A, User B sửa Product B của cùng một Brand X. Bức tranh mỗi người thấy là khác nhau:
- User A: thấy Brand X và Product A đã chỉnh theo ý mình, còn Product B thì giữ nguyên.
- User B: thấy Brand X và Product B đã chỉnh theo ý mình, còn Product A thì giữ nguyên.
- Cả hai cùng save xuống database và dẫn tới các vấn đề Concurrency. Một giải pháp cho tình huống này là: cài đặt version cho Brand X hoặc sử dụng khóa bi quan trên X. Hiệu năng của ứng dụng vì thế bị kéo xuống do một thời điểm chỉ có thể cập nhật một Product của một Brand. Hoặc gây trải nghiệm người dùng kém đi do "lời xin lỗi" xuất hiện thường xuyên.
- Hiệu năng và khả năng mở rộng. Khi Product gắn chặt với Brand, khả năng mở rộng của Domain kém đi do chúng gắn chặt với nhau. Nếu tách ra, chúng có thể được độc lập về khả năng này.
(Còn nữa)
