logoMột hệ thống khó hiểu thì cũng khó thay đổi
DDD - Part 4: Aggregate (1)

Serie về Offline Concurrency Control  đã mang tới cái nhìn sơ lược về tranh chấp trong hệ thống Enterprise nơi mà hàng trăm, hàng nghìn, thậm chí hàng chục nghìn người đang thao tác trong cùng một hệ thống. Ở đó, tôi mới chỉ đưa ra cách triển khai Offline Optimistic – cách dễ dàng nhất để đối phó với vấn đề này. Tuy nhiên, ta có 4 cách để lựa chọn, trong đó có Coarse-Grain Lock, tạm dịch là Khóa Tổ Hợp. Theo đó, thay vì xử lý tương tranh theo hướng kỹ thuật như Pessimistic hay Optimistic, ta đặt yếu tố nghiệp vụ lên trước bằng cách: Gom những thứ có thể thay đổi và xung đột lại cùng nhau, trong một transaction. Điều này tương tự như cách thiết kế Aggregate trong DDD.

Đặt vấn đề

Bỏ qua vấn đề Concurrency phức tạp trên, giờ ta cùng xem lại cấu trúc một Entity mà ta đã có tới thời điểm này bao gồm những gì. Bài giới thiệu chung về Entity và Value Object đã cho thấy cấu trúc này:

Hình 1: Cấu trúc của một Entity điển hình trong DDD

Bên trong một Entity có ID, các Value Object và các hành vi nghiệp vụ Behavior. Các Value Object, bản thân chúng không cần định danh vì chúng chỉ mang lại giá trị và các Behavior có ý nghĩa trong Domain. Một Box cần có định danh để quản lý nhưng BoxSpec thì không vì đây chỉ là các giá trị tô điểm cho Box. Giờ cập nhật thêm logic nghiệp vụ cho bài toán này:

  • Người ta cần dán niêm phong (seal) cho mỗi hộp khi vận chuyển.
  • Seal có mã riêng, đảm tính duy nhất.
  • Seal có các trạng thái: Chưa bóc và đã bóc.
  • Cần ghi nhận thời điểm dán, bóc seal.

Giải pháp

Enterprise Domain Model

Cùng phân tích yêu cầu mới này:

  • Seal là một đối tượng mới cần có trong Domain.
  • Seal là một Entity do nó:
    • Có ID (SealCode)
    • Cần quản lý trạng thái thay đổi: Chưa bóc hay đã bóc, thời điểm xảy ra hành động đó.
  • Seal có một vài hành động nghiệp vụ: sealBox, breakSeal.
Hình 2: Cấu trúc sơ bộ của Seal Entity

Hành động sealBox mang ý định trực tiếp “dán seal vào Box” sẽ là ổn với nghiệp vụ này, Seal chỉ được dán vào Box. Thực tế, trong vận hành shipment, Seal có thể được dùng để niêm phong một bao hàng, một hộp, một thùng, … Như vậy, ta có thể tổng quát hóa các đối tượng đó thành một dạng như Container mà chúng phải kế thừa. Nhưng để đơn giản hóa ví dụ, ta vẫn áp dụng trực tiếp với Box nhưng với hành động mang applySeal(Box) thay vì applySeal(Container). Hoặc, có thể là applyTo(Box).

Bài toán yêu cầu ghi nhận lại hết thời điểm dán và bóc seal, ta cần một mảng bao gồm ba đối tượng SealStatus mà bên trong đó là trạng thái và thời điểm xảy ra tương ứng: Tạo Seal, dán Seal và bóc Seal. Tới đây, thiết kế của Seal đã gần hoàn thiện:

Hình 3: Class diagram thể hiện Seal Model

Vậy Domain tới lúc này có hai Entity cần phối hợp với nhau: Box Seal.

Hình 4: Domain model bao gồm Box và Seal Entities tương tác với nhau trong bài toán "Niêm phong hộp"

Bức hình này khiến tôi nhớ tới một Enterprise Domain Model – một mô hình chứa toàn bộ các đối tượng nghiệp vụ của doanh nghiệp. Ở đó, Box, Seal, Shipper, Truck hay Payment, Order, … là những đối tượng nghiệp vụ có vị trí và tiếng nói xuyên suốt trong toàn bộ Domain của doanh nghiệp.

Hình 5: Một ví dụ về Enterprise Domain Model với chằng chịt quan hệ giữa một núi Entities

Trong thực tế, bức tranh còn rắc rối hơn rất nhiều vì một doanh nghiệp có thể chứa hàng trăm cho tới hàng nghìn đối tượng có nghĩa. Việc quản lý mô hình này thực sự là một ác mộng với nhà phát triển. Thay đổi tại một thực thể làm thế nào để đánh giá được nó không làm ảnh hưởng ngoài mong muốn tới những đối tượng khác? Cùng làm rõ khía cạnh này ở ví dụ hiện tại của chúng ta.

Giả sử rằng Seal được tạo ra chỉ khi niêm phong Box. Nghĩa là, người dùng có một Box và muốn dán Seal, họ lấy phôi Seal và tạo ra một Seal mới rồi dán. Do Seal không được tạo trước nên chỉ khi cần dán mới tạo, và vì vậy việc quản lý danh sách Seal tạo trước là không cần thiết.

Use case này được thể hiện như sau:

Hình 6: Use case sealBox với Enterprise Domain Model

Hình trên cho thấy rõ luồng xử lý này:

Đầu tiên, tham số của hàm là BoxId kết hợp với một mã Seal được tạo ra: Box cần có trước và hành động dán Seal kiêm luôn tạo Seal. Seal sẽ không có trạng thái tạo ra mà không được dán. Thứ hai, seal.applySeal(box) chắc chắn chuyển trạng thái đồng thời hai Entity Box và Seal vì Seal cần gắn vào một Box nên nó cần tham chiếu tới Box qua BoxId, Box cũng cần cho thấy nó đã được niêm phong và có thể cần tham chiếu tới Seal qua SealCode. Thứ ba, là hệ quả của “thứ hai”, sau khi Box và Seal bị thay đổi, ta phải lưu thay đổi đó vào database để đảm bảo vòng đời của chúng, điều này thể hiện qua hai lời gọi boxRepository.save(box)sealRepository.save(seal). Bên trong hàm seal.applySeal(box):

Hình 7: Invariants thể hiện trong Seal.applySeal()

Invariant được thể hiện như sau:

  • Kiểm tra xem Seal này đã được dán vào Box nào chưa, nếu có thì báo lỗi.
  • Kiểm tra Box này đã được dán Seal chưa, nếu có cũng báo lỗi.
  • Box chỉ được dán nếu đang ở trong kho.
  • Thỏa mãn hết điều kiện thì dán bằng cách ánh xạ hai đối tượng với nhau.

Nhận xét:

  • Tốt: Use case làm đúng vai trò orchestration của nó: Gọi Box, tạo mới Seal, cho hai đối tượng nghiệp vụ tương tác với nhau và save xuống database.
  • Vấn đề: Logic nghiệp vụ bị phân tán. Nghiệp vụ dán và bóc seal bị phân tán vào hai đối tượng Seal và Box khiến cho chúng bị phụ thuộc chặt chẽ vào nhau về source code và logic nghiệp vụ. Điều này đáng lẽ ra không thành vấn đề nhưng vì ta đang sử dụng mô hình Enterprise Domain Model nên sự phụ thuộc này gây ra khó khăn để duy trì Invariants liên quan. Liệu vấn đề này có gợi ý cho bạn về hướng thiết kế: Gom hai thực thể này vào với nhau?
  • Vấn đề: Tính toàn vẹn dữ liệu có thể bị ảnh hưởng. Box và Seal đang nằm trong một biển Entities, có những use case khác cập nhật chỉ Box hoặc Seal và do đó, chỉ BoxRepository hay SealRepository được gọi. Tính nhất quán trong cả tầng ứng dụng và database có thể bị vi phạm. Điều này lại gợi ý cho ta về hướng thiết kế: Gom hai lời gọi này vào cùng một transaction nhưng chỉ thông qua một lời gọi.

Từ hai gợi ý tới từ hai vấn đề trên, ta thấy rằng nên thiết kế Seal và Box đi cùng nhau, hay cụ thể hơn là đối tượng này cần wrap đối tượng còn lại để sao cho chúng thỏa mãn cả hai điều kiện: Logic nghiệp vụ thống nhất, transaction được đảm bảo. Đó là một Aggregate. Với ví dụ này, ta có Box Aggregate.

Cách tiếp cận Aggregate

Một Aggregate trong DDD về bản chất, là một đối tượng tổ hợp (đúng như tên gọi) bao gồm ranh giới nhất quán (ở đây, cần đảm bảo nhất quán giữa Box và Seal), bên trong có các Entity (là Seal), các Value Object như một Entity thông thường. Aggregate còn một điểm truy cập duy nhất mà qua đó client tương tác, gọi là Aggregate Root (trường hợp này là Box).

Hình 8: Box trở thành Aggregate bao quanh Seal bên trong với ranh giới và APIs bổ sung

Box giờ đây không còn là một Entity thuần túy nữa, nó đã trở thành một Aggregate chứa bên trong là một hoặc nhiều Seal (với ví dụ này là một). Seal nằm bên trong ranh giới mà Box tạo ra nên bên ngoài không thể truy cập trực tiếp vào Seal được nữa, tất cả đều thông qua Box. Điều này thể hiện rõ ở hành vi applySealbreakSeal đã được chuyển sang Box – một Aggregate Root. Class Box giờ sẽ trông như thế này:

Hình 9: Code của Box Aggregate bao gồm Seal và nghiệp vụ niêm phong

SealCode đang được ủy quyền cho client tự tạo, ta có thể có những lựa chọn khác như bài này đã đề cập. Trên hết, Seal đã nằm bên trong Box và vòng đời của Seal nằm trọn vẹn trong Box. Thông thường một Box sẽ có nhiều Seal trên hành trình, khi đó, ta cần chuyển thành một List. Bạn có thể thắc mắc: Entity khác sử dụng Seal bên trong cũng giống như Box thì sao? Đây là một vi phạm trong DDD: Với một Bounded Context, một Entity chỉ có thể nằm trong một Aggregate mà thôi.

Use case giờ chuyển thành:

Hình 10: Use case sealBox giờ chuyển thành làm việc với Box Aggregate

Client, tức use case, có một điểm truy cập duy nhất là Box. Họ thao tác với Box và save duy nhất Box xuống database trong một transaction. Seal bên trong do đó được đảm bảo nhất quán với Box.
Tổng quát hơn, ta có Aggregate có cấu trúc như sau:

Hình 11: Cấu trúc một Aggregate trong DDD

Giờ đây, ta đã có trong tay Aggregate – một thành phần quen thuộc và quan trọng của DDD. Khi nói về Entity, một cách mặc định nó là Aggregate nếu nó không nằm trong một Entity khác. Factories, Repositories cũng đều nhắm tới Aggregate là chính.

(Còn nữa...)

Cách tiếp cận Eventual Consistency

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