Sau khi có được ProductId, ta đi tới các thuộc tính quan trọng nhất của Product. Đây là những thuộc tính xuất hiện trong các use case quan trọng nhất về nó.
Từ kinh nghiệm tạo ProductId, ta thấy rằng, hầu hết các thuộc tính của Product đều có thể tạo những Value Object chuyên trách. Nhưng không nên lạm dụng điều đó. Hãy cân nhắc tạo mới khi muốn đặt trọng tâm vào các yếu tố sau:
- Value Object có thể đóng gói invariants quan trọng với Aggregate hoặc tốt hơn là cho cả Bounded Context. Điều này giúp chúng có khả năng được dùng lại trên phạm vi rộng hơn và đảm bảo logic nghiệp vụ mỗi khi được sử dụng.
- Value Object giúp tăng tính đọc hiểu cho những thuộc tính quan trọng. Thường thì những thuộc tính này để chia sẻ giữa các Aggregate khác nhau.
Cùng sử dụng những cân nhắc này để rà soát lại các thuộc tính của Product hiện tại.
Title
Title hay Name của Product, Admin là người tạo ra thông tin này.
Thông thường, đây là trường văn bản tự do, được kiểm tra về độ dài ký tự. Nếu mạnh mẽ hơn, có thể tích hợp bộ kiểm tra ngôn ngữ để tránh sai chính tả. Ta sẽ không tạo class Title, mà sử dụng String cơ bản. Invariants liên quan đến Title sẽ được thực thi tại hai nơi: Tại Product’s constructor hoặc tại method setTitle. Điều này dẫn tới cần triển khai cơ chế self-validation cho thông tin này.
Self-validation: là cơ chế tự xác thực mà theo đó, một đối tượng phải luôn chịu trách nhiệm đảm bảo trạng thái của chính nó là hợp lệ ngay từ khi được tạo ra và trong suốt vòng đời của nó.
ProductId mà ta đã tạo đã có cơ chế này. Giờ ta tiếp tục áp dụng chuyên tắc thiết kế này vì nó là tốt trong hầu hết trường hợp đối tượng có trạng thái.
Quy tắc hay Invariants cho Title như sau:
- Chuẩn hóa giá trị truyền vào bằng cách loại bỏ các ký tự trắng ở hai đầu chuỗi, rút gọn các ký tự trắng liền nhau về một, v.v.
- Nếu Title là NULL hoặc Blank, ném ra exception.
Các thao tác này là phổ biến và có thể dùng lại tại nhiều nơi, không gắn chặt vào một Bounded Context cụ thể nào, do đó, ta sẽ viết chúng ở class utility: StringUtils.
public final class StringUtils { public static String cleanAndNormalize(String input) { if (input == null) { return null; } return input.trim().replaceAll("\\s+", " "); } public static boolean isNullOrBlank(String input) { return input == null || input.isBlank(); }}
Triển khai Title tại Product:
public class Product { ... private String title;
public Product(ProductId id, String title) { ... String normalizedTitle = StringUtils.cleanAndNormalize(title); if (StringUtils.isNullOrBlank(normalizedTitle)) { throw new RuntimeException("Title is null or blank"); } this.title = normalizedTitle; } ...}
Code trên chưa đảm bảo self-validation cho Title. Trong danh sách use case của Catalog, có một ý: Manage Attributes khá chung chung. Mô tả này có thể hiểu là có chức năng cập nhật các thông tin trên một form thông tin Product: Người dùng sửa một số thông tin và bấm "Save", toàn bộ các thông tin đó được lưu trong cùng một transaction. Hãy giả sử, Title nằm trong số các thông tin đó.
Vậy, ta cần một hành vi trên Product đảm nhận cập nhật thông tin. Nên chọn tên là gì? Ý tưởng xuất hiện đầu tiên là updateInformation(), updateAttributes(), updateState(), ... Đặt tên một hành vi quan trọng như vậy là một quyết định cũng tương đối quan trọng, nhất là khi ta đang thực hành DDD. Hãy chú ý sử dụng Ubiquitous Language ở bất cứ khi nào có thể.
Nếu không ai nhắc tới hành vi này, ta có thể tự đưa ra, ví dụ: updateInformation(). Nhưng với ví dụ này, ta có một căn cứ trên use case: Manage Attributes. Vậy ta sẽ sử dụng hành vi manageAttributes() trên Product. Đây có thể coi là một minh họa về sử dụng Ubiquitous Language trong Bounded Context Catalog cho Product.
Hiện tại, ta chỉ có Title làm parameter nên để đơn giản, ta có như sau:
public void manageAttributes(String newTitle) { String normalizedTitle = StringUtils.cleanAndNormalize(newTitle); if (StringUtils.isNullOrBlank(normalizedTitle)) { throw new RuntimeException("Title is null or blank"); } this.title = normalizedTitle;}
Do newTitle là một giá trị mới truyền vào, hoàn toàn có thể vi phạm các Invariant đang có, nên nó cũng cần được validate như tại constructor. Để khắc phục, ta sẽ triển khai self-invalidation cho Title nhưng trong phạm vi Product:
public class Product { ... public Product(ProductId id, String title) { assert id != null; this.id = id;
setTitle(title); }
public void manageAttributes(String newTitle) { setTitle(newTitle); } private void setTitle(String title) { String normalizedTitle = StringUtils.cleanAndNormalize(title); if (StringUtils.isNullOrBlank(normalizedTitle)) { throw new RuntimeException("Title is null or blank"); } this.title = normalizedTitle; } ...}
Có cần phương thức getTitle() không? Chưa cần thiết vì hiện tại, chưa thấy có use case nào sử dụng tới nó. Hãy tạo thói quen này khi thiết kế đối tượng: Không tạo các phương thức không cần thiết, nhất là getters/setters khi chưa có yêu cầu.
Như vậy, Title có thể coi là ổn. Ta đi tới thuộc tính quan trọng tiếp theo.
Brand
Brand hay thương hiệu chính thức của sản phẩm (Apple, Nike, Sony), là điều kiện lọc quan trọng khi khách hàng tìm kiếm sản phẩm, thường được chuẩn hóa theo brand whitelist để tránh việc Merchant nhập liệu không rõ ràng, thiếu nhất quán, sai lệch. Do đó, có một nhóm use case quản lý Brand. Điều này sẽ ảnh hưởng lớn đến mô hình Product mà ta đang xây dựng.
Quyết định 1: Brand là một class riêng biệt: Brand. Vì marketplace cần quản lý danh sách Brand.
Quyết định 2: Brand nên là Aggregate, Entity, hay Value Object? Đây là nội dụng ta sẽ bàn chi tiết.
Lựa chọn 1: Brand là Value Object
Value Object là những đối tượng có giá trị Value, được so sánh với nhau bằng Value và không có Identity.
Code Brand có dạng sau:
public class Brand { private final String value;
public Brand(String value) { // normalize and validate ... this.value = value; }
@Override public boolean equals(Object o) {...}
@Override public int hashCode() {...}}
Marketplace quản lý danh sách Brand cho phép (whitelist), nhưng vì Brand là Value Object nên ta hiểu rằng họ quản lý danh sách giá trị cho phép của Brand. Các giá trị đó là các String value: "Apple", "Nike", "Sony", ...
Cách dùng như sau:
public void createProduct(..., String brandValue) { Brand brand = new Brand(brandValue); Product product = new Product(..., brand);}
Vấn đề đặt ra: Dù brandValue là whitelist, tức các giá trị định nghĩa trước để truyền vào, ta đang không có cách kiểm tra xem giá trị đó có đúng thuộc whitelist hay không.
Giải pháp thường gặp là biến Brand thành một enum - một dạng Value Object bị giới hạn:
public enum Brand { APPLE("Apple"), SAMSUNG("Samsung"), GOOGLE("Google");
private final String displayName;
Brand(String displayName) { this.displayName = displayName; }
public String displayName() { return displayName; }}
Cách này tuy giải quyết được vấn đề này nhưng lại đặt ra vấn đề khác về tính mềm dẻo và khả năng quản lý. Marketplace có hàng nghìn cho tới hàng triệu Brand, ta không thể hardcode như vậy được. Giải pháp khác đến một cách tự nhiên: Chứa danh sách Value này trong database.
Tuy Value nằm trong database, tức sẽ có một "id" nào đó, nhưng Brand vẫn đang được coi là Value Object. Hãy xem cách áp dụng:
Đầu tiên, user sử dụng UI lựa chọn một Brand Value trên màn hình khi tạo mới Product. Khi submit, thông tin được truyền tới phương thức createProduct như cũ: public void createProduct(..., String brandValue) {...)
brandValue cần nằm trong database - nơi định nghĩa whitelist, nó sẽ được truy vấn để khởi tạo một Brand hoàn chỉnh:
public void createProduct(..., String brandValue) { Brand brand = brandRepository.findByValue(brandValue); if (brand == null) { throw new InvalidBrandException("Brand is invalid"); } Product product = new Product(..., brand);}
Ta thường gặp Repository dành cho Aggregate thì giờ đã gặp Repository dành cho Value Object. Đây là điều không bị hạn chế trong DDD. Giờ ta đi xa hơn một chút nữa với Product hiện có.
Giả sử, thời gian đầu, Admin tạo một Brand có tên "VinMart". Có nhiều Product được tạo ra sau đó sử dụng Brand("VinMart") này. Giả sử các sản phẩm mà VinMart tạo ra như sau:
Sau đó, thương hiệu này đổi chủ dẫn tới đổi tên thành "WinMart". Marketplace cập nhật whitelist: thay thế "VinMart" thành "WinMart". Những sản phẩm được tạo với "VinMart" sẽ không thành công. Vấn đề là: Các Product cũ bên trên không được cập nhật sang "WinMart".
Đây chính là điểm khác biệt cốt lõi khi mô hình hóa một đối tượng là Value Object với khi nó là Entity.
Value Object là một bản snapshot, khi ta thay đổi giá trị của nó, lập tức một Value mới được tạo ra còn Value cũ bị xóa đi hoặc mất kết nối. Value không có vòng đời như Entity nên nó không cần một định danh để duy trì.
Giả sử, lúc này ta tạo ra Product mới là "Bánh kem" thì trong database có dữ liệu như sau:
Tại sao Product với Brand cũ lại vẫn tồn tại trong database dù giá trị của nó trong bảng whitelist đã không còn? Bởi vì trong Product, ta chỉ lưu Brand với Value, không hề có ánh xạ bảng.
Nếu muốn sự nhất quản về thương hiệu để tiện truy vấn (thường là thế), tăng trải nghiệm khách hàng: Cần chạy một batchjob để migrate dữ liệu.
Có vẻ như Brand là một Value Object gây ra một số vấn đề bất tiện, nhất là khi Brand có sự quản lý riêng, tức nó có vòng đời riêng. Điều này dẫn tới lựa chọn thiết kế nó là một Entity.
(Còn nữa)
