logoMột hệ thống khó hiểu thì cũng khó thay đổi
Catalog - Thiết kế Product: Brand (3): Aggregate Root

Như vậy, chúng ta đã đi qua ba lựa chọn thiết kế thuộc tính Brand cho Product. Chúng đều có những ưu, nhược điểm khác nhau, và tới giờ, chưa có giải pháp nào thật sự phù hợp. Trông qua, có vẻ việc thiết kế một thuộc tính thôi cũng mất khá nhiều thời gian. Nhưng bạn đừng lo, chúng ta đang ở giai đoạn mài dũa kỹ năng thiết kế DDD mà. Một khi đã tạo thành một lối mòn trong tư duy thiết kế, hầu như bạn không cần phải nghĩ quá nhiều như vậy.

Đi tới lựa chọn thứ tư nào!

Brand là Aggregate Root

Một khi Brand trở thành Aggregate, Product (hiện cũng đang là Aggregate) không được tham chiếu trực tiếp tới nó. Nghĩa là code sau không tuân theo chuẩn DDD:

public class Product {
    private ProductId id;
    private String title;
    private Brand brand; // Phi chuẩn
    ...
}

Thay vào đó, Product tham chiếu tới Brand qua BrandId, khi đó, sơ đồ class chuyển thành như sau:

Hình 1: Brand trở thành Aggregate Root, do đó, Product tham chiếu gián tiếp qua BrandId

Code Product có dạng sau:

public class Product {
    private ProductId id;
    private String title;
    private String brandCode; // brandId
    ...
}

Giả sử BrandId là BrandCode: String. Brand có cần tham chiếu tới Product qua ProductId không?

Brand tham chiếu Product qua ProductId

Mô hình này có thể hiểu là: Một Brand luôn cần biết có những Product nào mà nó bao gồm.

Giả sử là có, code sẽ như sau:

public class Brand {
    ...
    private List productIds;
    ...
}

Phiền ghê! Khi tạo mới một Product, tôi cần tác động tới hai Aggregate: Product và Brand, vì thiết kế trên cho thấy Brand cần được cập nhật ProductId mỗi khi Product mới thuộc Brand đó được tạo ra. Do quy tắc của DDD đề ra: Một transaction chỉ tác động một Aggregate nên cách làm trở thành như sau:

Hình 2: Luồng nghiệp vụ hoàn thành qua 2 transaction: Tạo mới Product và Đăng ký Product vào Brand

Code trông như sau, tại use case tạo mới Product:

// Code tại Application Use Case: Transaction 1
@Override
public void execute(String title, String brandCode) {
    Brand brand = this.brandRepository.findByCode(brandCode);
    if  (brand == null) {
        throw new RuntimeException("Brand not found");
    }

    ProductIdGenerator productIdGenerator = new ProductIdGenerator(
            new IntegerRandomGenerator()
    );
    ProductId id = productIdGenerator.generate();
    Product product = new Product(id, title, brand);
    publish(productCreatedEvent); // Giả sử code như thế này

    this.productRepository.save(product);
}

Tại use case đăng ký Product với Brand:

// Code tại Application Use Case: Transaction 2
@Override
public void registerProductToBrand(ProductId productId, String brandCode) {
    Brand brand = this.brandRepository.findByCode(brandCode);
    Product product = this.productRepository.findById(productId);

    brand.registerProduct(product);

    this.brandRepository.save(brand);
}

Code trên đã lược hóa một số đoạn kiểm tra null, nhưng xử lý cơ bản là thế. Đây chính là vấn đề phát sinh khi ta tách một Aggregate thành nhiều Aggregate nhỏ hơn: Eventual Consistency.

Bạn có thể thấy rằng mối quan hệ giữa Brand và Product là khá mật thiết, thể hiện ở:

Product product = new Product(id, title, brand);

Ta truyền hẳn Brand object vào để tạo mới Product thay vì chỉ brandCode. Hay như:

brand.registerProduct(product);

Ta cũng truyền hẳn Product object vào đăng ký dù bên trong Brand chỉ chứa ProductId. 

Bạn có thể thay bằng cách đơn giản hơn (sử dụng brandCode, ProductId). 

Mối quan hệ mật thiết này khiến cho hai Aggregate này không thể tách rời, vậy thì tại sao ta lại cần phải triển khai Eventual Consistency? Nếu đã vậy, ta hãy tác động chúng trong cùng một transaction:

// Code tại Application Use Case
@Override
public void execute(String title, String brandCode) {
    Brand brand = this.brandRepository.findByCode(brandCode);
    if  (brand == null) {
        throw new RuntimeException("Brand not found");
    }

    ProductIdGenerator productIdGenerator = new ProductIdGenerator(
            new IntegerRandomGenerator()
    );
    ProductId id = productIdGenerator.generate();
    Product product = new Product(id, title, brand);
    brand.registerProduct(product);

    this.productRepository.save(product);
    this.brandRepository.save(brand);
}

Vấn đề có vẻ được giải quyết. Nhưng thật ra, việc duy trì một danh sách ProductId trong Brand vẫn đặt ra một vấn đề về bộ nhớ và duy trì trong database: Khi một Brand lớn với hàng nghìn cho tới hàng chục nghìn Product.

Giờ hãy thử nhìn qua khía cạnh lưu trữ model này dưới database.

Relational Database: Option 1

Nếu sử dụng một Relational Database, có lẽ sẽ là hai bảng: BRANDS và PRODUCTS trong đó, PRODUCTS tham chiếu tới BRANDS qua một FK BRAND_ID:

Hình 3: Dưới một RD, bảng PRODUCTS có BRAND_ID tham chiếu tới BRANDS

Bạn có thấy điều gì không?

Ta chỉ cần một transaction 1 là dữ liệu đã đạt được tính nhất quán rồi: Lưu Product mới được tạo ra, trong đó có BrandId, thế là xong. Còn transaction 2 hoạt động sẽ hoạt động thế này:

// Code tại Application Use Case: Transaction 2
@Override
public void registerProductToBrand(ProductId productId, String brandCode) {
    <1> Brand brand = this.brandRepository.findByCode(brandCode);
    <2> Product product = this.productRepository.findById(productId);

    <3> brand.registerProduct(product);

    <4> this.brandRepository.save(brand);
}

Tại dòng <1>, Brand lấy lên đã có ProductId của Product vừa tạo ra. Vậy các dòng sau là vô nghĩa. 

Liệu chúng ta đã làm điều gì sai khi tách thành hai Aggregate như vậy? Tách ra hai Aggregate là một sự ngầm định về tách biệt transaction, mà tách biệt transaction ngầm định một sự tách biệt về dữ liệu. Do cách thiết kế data model ở hình 3 có sự ánh xạ trực tiếp giữa hai bảng, nên sự tách biệt ở đây rất hạn chế, ít nhất theo thứ tự của hai transaction trong use case này. Ta cần tạo ra một ranh giới về dữ liệu cho mô hình này.

Relational Database: Option 2

Hình 4: Mối quan hệ giữa BRANDS và PRODUCTS thông qua bảng trung gian BRAND_PRODUCTS

Data model trong hình 4 đã tạo ra bảng quan hệ riêng giữa BRANDS và PRODUCTS: BRAND_PRODUCTS. Các transaction 1 & 2 hoạt động như sau:

  1. Transaction 1: Cập nhật PRODUCTS và BRAND_PRODUCTS. Trong đó, BRAND_ID, là NULLABLE, có giá trị NULL.
  2. Transaction 2: Cập nhật BRAND_PRODUCTS: Phần Adapter chạy qua List<ProductId> và thực hiện UPSERT.

Sau cả hai transaction, dữ liệu đạt nhất quán.

Vẫn có một sự thiếu tự nhiên trong cách xử lý này, kết quả là vẫn như mô hình 1 bên trên: Tại transaction 1, ta vẫn có thể cập nhật đầy đủ BRAND_PRODUCTS với BRAND_ID và PRODUCT_ID đầy đủ chứ không nhất thiết phải chia nhỏ ra thành hai transaction như vậy. Dẫn tới transaction 2 vẫn có kết cục là thừa. Điều này xảy ra vẫn vì dù có bảng quan hệ riêng, hai bảng vẫn có sự gắn kết chặt chẽ với nhau, tương tự như model 1. Ta đi tới lựa chọn thứ ba:

Relational Database: Option 3

Hình 5: Hai bảng BRANDS và PRODUCTS đã tách biệt nhau hoàn toàn, tạo thành hai model khác nhau

Hình 5 cho thấy một mô hình dữ liệu lỏng lẻo hơn với ranh giới transaction không va chạm:

  1. Transaction 1: Cập nhật riêng bảng PRODUCTS với đầy đủ thuộc tính, có BRAND_ID.
  2. Transaction 2: Cập nhật hai bảng BRANDS và BRAND_PRODUCTS, trong đó bảng BRAND_PRODUCTS chứa thông tin cho thấy một Brand bao gồm bao nhiêu Product.

Mô hình này hoạt động hoàn hảo với hai use case bên trên của chúng ta. Xa hơn nữa, nếu ta có nhu cầu quản lý Brand và Product ở hai Bounded Context khác nhau, mô hình này vẫn hỗ trợ: Chỉ đơn giản là tách code ra (điều này khá dễ dàng) và tách 2 nhóm bảng này ra (điều này giờ đây cũng hoàn toàn khả thi).

Với mô hình dữ liệu này, các use case như Xóa Brand, Remove Product khỏi Brand cũng vẫn hoạt động tốt.

Điều này dẫn ta tới một mô hình dữ liệu kiểu khác nữa, rất phù hợp với Aggregate: Document Database (ví dụ như MongoDB).

Document Database: Option 4

Trong Document Database (DD) như MongoDB, document là đơn vị lưu trữ dữ liệu cơ bản, tương đương với table trong Relational Database (RD). Về cơ bản (best practice), một transaction trong DD chỉ bao gồm một thao tác trong một document. Điều này rất phù hợp với Aggregate. Hãy xem data model lúc này:

Hình 6: Hai document Brands và Products trong DD

Data model trong hình 6 cho thấy: Document Brands và Products đều có các field (thuộc tính) tương ứng với các thuộc tính cố hữu của Brand và Product trong Domain Model ta đang có. Ngoài ra, document Brands còn có một field khác chứa danh sách ProductId: ProductIds. Các transaction 1 & 2 hoạt động như sau:

  • Transaction 1: Cập nhật document Products, trong này có chứa field BrandId.
  • Transaction 2: Cập nhật document Brands với danh sách ProductId up-to-date.

Khi đọc, ta không cần thực hiện các phép JOIN các bảng như trong RD mà chỉ cần chỉ định một document là xong, rồi đưa nó vào một Aggregate tương ứng. Một sự trùng lặp đáng ngạc nhiên giữa Aggregate và Document.

Vấn đề Concurrency

Dù với mô hình lưu trữ dữ liệu thế nào thì Domain Model của chúng ta cũng vẫn như hình 1. Nếu đã quen với vấn đề Concurrency, bạn thấy ngay rủi ro của nó và khiến bạn cần điều chỉnh tinh vi hơn luồng xử lý. Hãy tưởng tượng, Marketplace có những chức năng quản lý Product và Brand, trong đó có use case cập nhật Brand cho Product. Việc cập nhật này có thể như sau: 

  • Ban đầu, Mỳ Tôm (Product) thuộc thương hiệu WinMart (Brand). 
  • Sau đó, Admin cần chuyển Mỳ Tôm sang thương hiệu Hảo Hảo. Giả sử là vậy, thực tế hoàn toàn có thể xảy ra việc thay đổi tương tự như vậy.

Use case này có thể cài đặt theo các cách khác nhau:

  • Cách 1: Tại màn hình Brand, có danh sách các ProductId, Admin thực hiện loại bỏ ProductId mong muốn ra ngoài, rồi bấm "Save". Sau đó, tại màn hình Brand mới, Admin tìm kiếm hoặc nhập ProductId mới để bổ sung vào danh sách hiện có, rồi cũng "Save".
  • Cách 2: Tại màn hình Product, Admin tìm Brand mới tương ứng rồi thay thế Brand cũ, rồi bấm "Save".

Cả hai cách này, tại transaction 2, nếu xử lý không khéo có thể gây mất mát dữ liệu do Concurrency. Chi tiết, các bạn hãy xem tại series này: Offline Concurrency Control - Phần 1: Hiện tượng

Kết luận

Với mô hình domain mà Brand cần biết những Product nào thuộc về nó, dù đã trải qua bốn lựa chọn lưu trữ dữ liệu cho mô hình này thì vấn đề cơ bản nhất vẫn luôn tồn tại: Danh sách ProductId trong Brand có thể rất lớn. Điều này có thể không phải là vấn đề nếu ứng dụng của ta là nhỏ nhưng nếu không phải, đó sẽ là một điểm nghẽn hiệu năng cũng như tính Concurrency. Hãy đi đến lựa chọn khác: Brand không cần biết các ProductId thuộc về.

Brand không tham chiếu ProductId

<Tiếp tục cập nhật>

 

 

 

 

Bình luận
Gửi bình luận
Bình luận