Domain Event - phần 2 xem tại đây.
Cơ chế (tiếp)
Publish Events
Ở phần 2, chúng ta đã thảo luận về cách Domain công bố Events mà nó sinh ra cho tầng Application:
- Trả về qua return của method, phá vỡ CQS.
- Sử dụng Aggregate làm Event Container.
Tại phần này ta đi tiếp một số cách khác nữa. Những cách này chủ yếu hướng đến giữ nguyên giao diện Aggregate nhưng tầng Application vẫn cần biết và qua đó, logic không bị rò rỉ sang Adapter.
Event Publisher trong Domain
Để đi tiếp, ta cùng xem lại mục tiêu thiết kế phần này:
- Domain chuyển trạng thái và sinh Events.
- Application lắng nghe và tùy ý xử lý các Events đó, Domain không cần quan tâm.
Từ mục tiêu trên, Domain tạo ra một Event Publisher mà thông qua đó, Application tiếp nhận và xử lý.
Hướng tiếp cận 1
Domain khai báo một "cổng" để Application tiếp nhận Events và tùy ý xử lý. Như trong hình sau:

Tôi sử dụng từ "Event Listener" thay vì "Event Publisher" như tiêu đề vì theo cách này, Domain không chủ động publish gì cả, Application lắng nghe và xử lý nên từ "Listener" có vẻ hợp lý hơn.
Trong hình 1, Order Aggregate sử dụng EventListener làm cánh cổng cho Application khai thác. Cả Order và EventListener đều phụ thuộc vào DomainEvent interface. Application muốn làm gì với Events có được?
- Publish Events lên một message queue, thông qua QueueEventPublisher - một implementation của EventListener. Hoặc
- Lưu Events xuống Outbox table theo cơ chế Transactional Outbox, thông qua EventStoreWriter - một implementation khác của EventListener.
Có một điều khó hiểu: Không thấy mũi tên phụ thuộc từ OrderManagementUseCase trỏ vào QueueEventPublisher hay EventStoreWriter. Điều này nghĩa là trong code Use Case không đề cập đến hai class này.
Cụ thể các đoạn code như sau.
Tầng Domain
EventListener - interface được Domain sử dụng:
public interface EventListener {
void onEvent(DomainEvent event);
}
Order sử dụng EventListener và DomainEvent:
public class Order implements Aggregate {
...
private final EventListener eventListener;
public Order(..., EventListener eventListener) {
...
this.eventListener = eventListener;
this.eventListener.onEvent(new OrderCreated(this));
}
...
}
public class OrderFactory {
private final EventListener eventListener;
public OrderFactory(EventListener eventListener) {
this.eventListener = eventListener;
}
...
}
EventListener được truyển vào qua tham số Order's constructor. OrderCreated event sinh ra sau đó và đưa vào EventListener này.
Theo đó, Application sử dụng một trong hai implementation QueueEventPublisher hoặc EventStoreWriter sẽ thực hiện ngay hành động của mình:
QueueEventPublisher:
public class QueueEventPublisher implements EventListener {
private final MessageQueue queue;
public QueueEventPublisher(MessageQueue queue) {
this.queue = queue;
}
@Override
public void onEvent(DomainEvent event) {
String eventData = serializeEvent(event);
queue.send("order-output-queue", eventData);
}
...
}
MessageQueue thực sự được inject bởi tầng Adapter, tạm thời bỏ qua cách code best-practises trong hàm onEvent, ta thấy tư tưởng là hàm này react với Event được đẩy vào từ Domain layer.
Tương tự với EventStoreWriter:
public class EventStoreWriter implements EventListener {
private final EventStore eventStore;
public EventStoreWriter(EventStore eventStore) {
this.eventStore = eventStore;
}
@Override
public void onEvent(DomainEvent event) {
eventStore.append(event);
}
}
Tầng Application
public class OrderManagementUseCase {
private final OrderFactory orderFactory;
private final OrderRepository orderRepository;
public OrderManagementUseCase(OrderRepository orderRepository, EventListener eventListener) {
this.orderFactory = new OrderFactory(eventListener);
this.orderRepository = orderRepository;
}
public void createOrder(...) {
// Khởi tạo các Value Objects cần thiết để gọi OrderFactory
...
Order order = orderFactory.createOrder(...);
orderRepository.save(order);
}
...
}
Đoạn code trên, EventListener thực sự được inject vào OrderManagementUseCase thông qua tầng Adapter. Lời gọi OrderFactory.createOrder(...) trả về Order mới và Application chỉ cần save là xong. Application use case không cần lấy Events ra rồi tường minh xử lý. Tiếp tục, cùng xem updateAddress:
public class OrderManagementUseCase {
...
public void updateDeliveryAddress(OrderId orderId, Address newAddress) {
Order order = orderRepository.findById(orderId);
if (order == null) {
throw new IllegalArgumentException("Order not found");
}
order.updateAddress(newAddress);
orderRepository.save(order);
}
...
}
Mọi việc có vẻ tiến triển tốt đẹp. Ta thấy use case cập nhật địa chỉ giao cũng cần phải giống use case tạo Order: Use case triệu hồi Order từ database lên rồi gọi hành vi nghiệp vụ của nó. Xong xuôi, save lại. Việc xử lý DeliveryAddressChanged event đã do EventListener implementation lo rồi. Rất tiếc... Ta muốn giao diện Order được sạch sẽ nên ta chỉ muốn đưa EventListener vào constructor để dùng. Cách này thành công với use case tạo mới Order nhưng còn với các use case khác đòi hỏi lấy Order từ database lên thì không thành công do EventListener là NULL.
Tới đây, ta có 2 cách để giải quyết. Cách 1: Thêm EventListener argument vào lời gọi orderRepository.findById như sau:
public class OrderManagementUseCase {
...
public void updateDeliveryAddress(OrderId orderId, Address newAddress) {
Order order = orderRepository.findById(orderId, this.eventListener);
if (order == null) {
throw new IllegalArgumentException("Order not found");
}
order.updateAddress(newAddress);
orderRepository.save(order);
}
...
}
Cách này khiến giao diện hàm thêm phức tạp.
Cách 2: Để tầng Adapter quyết định khi cài đặt OrderRepository implementation:
public class OrderRepositoryImpl implements OrderRepository {
private final OrderJpaRepository orderJpaRepository;
private final EventListener eventListener;
public OrderRepositoryImpl(OrderJpaRepository orderJpaRepository, EventListener eventListener) {
this.orderJpaRepository = orderJpaRepository;
this.eventListener = eventListener;
}
@Override
public Order findById(OrderId orderId) {
OrderData data = orderJpaRepository.loadOrderData(orderId.value());
return new Order(data.getId(), data.getItems(), data.getCustomerInfo(), data.getPaymentInfo(),
data.getDiscountCodes(), data.getTotalAmount(), eventListener);
}
...
}
Với cách này, giao diện repository được giữ nguyên nhưng logic bị rò rỉ sang Adapter. Do đó, cách 1 phù hợp hơn dù giao diện phức tạp hơn 1 tham số.
Tới đây, bạn có thể thấy rằng việc duy trì một EventListener bên trong Order khiến mọi thứ phức tạp hơn đáng kể, nhất là ở tầng Adapter. Tuy nhiên, vấn đề đó vẫn chưa là gì với vấn đề sau: Trùng lặp sinh OrderCreated event khi tái tạo dữ liệu từ database. Mục này chúng ta sẽ thảo luận ngay sau đây.
Vấn đề trùng lặp Domain Event khi tái tạo dữ liệu từ database
Ở đoạn code ngay bên trên, tại hàm OrderRepositoryImpl.indById() có gọi tới "new Order(...)". Ta thấy rằng:
- Thứ nhất, vi phạm nguyên tắc của chúng ta rằng Aggregate nào có Factory thì đó sẽ là điểm truy cập duy nhất để tạo Aggregate đó. Trường hợp này, OrderAggregate là Factory đó. Vậy, đứng ở Adapter, ta không thể gọi "new Order(...)" được.
- Thứ hai, giả sử, ta nới lỏng nguyên tắc trên, cho phép "new Order(...)" thì bên trong constructor này ta đang sinh ra OrderCreated event. Vậy sẽ xảy ra trùng lặp event với khi tạo mới - sự kiện đã xảy ra trước đó.
Cùng giải quyết từng vấn đề.
Với vấn đề thứ hai.
Ta chỉ cần đơn giản là chuyển việc sinh OrderCreated event sang hàm OrderFactory.createOrder(). Thực ra, đây là điểm đẹp nhất để sinh event này. Và cũng thuận lợi vì OrderFactory cũng đang giữ tham chiếu tới EventListener.
Code giờ đây sẽ thế này:
public class Order implements Aggregate {
...
private final EventListener eventListener;
public Order(..., EventListener eventListener) {
...
this.eventListener = eventListener;
}
...
}
public class OrderFactory {
private final EventListener eventListener;
public OrderFactory(EventListener eventListener) {
this.eventListener = eventListener;
}
public Order createOrder(OrderId orderId, List itemInputs, CustomerInfo customerInfo,
PaymentInfo paymentInfo, List discountCodes) {
// Khởi tạo các thuộc tính cần thiết của Order
...
Money totalAmount = calculateTotalAmount(orderItems, discountCodes);
Order order = new Order(orderId, orderItems, customerInfo, paymentInfo, discountCodes, totalAmount, eventListener);
this.eventListener.onEvent(new OrderCreated(order));
return order;
}
}
Order vẫn cần giữ tham chiếu tới EventListener để gửi Events cho các hành động nghiệp vụ khác. Tại OrderFactory, sau khi tạo Order mới, nó sinh ra OrderCreated event rồi gửi ra bên ngoài qua EventListener.
Nhưng đó là ta nới lỏng quy tắc chỉ có một điểm duy nhất tạo mới Order (hoặc constructor hoặc OrderFactory).
Với vấn đề thứ nhất.
Để giữ một điểm tạo Order duy nhất, với ví dụ này là OrderFactory, ta cần làm các việc sau:
- Vẫn cần dịch chuyển việc sinh OrderCreated event ra khỏi Constructor, đưa vào OrderFactory.
- Sử dụng Memento pattern như ví dụ này. Nếu không muốn dùng Memento, ta có thể tạo ra một class giống như OrderFactory nhưng chỉ cho mục đích tái tạo dữ liệu từ database. Dù bằng cách nào, class cần bổ sung cũng cần nằm ít nhất cùng package với Order để nó có thể sử dụng được constructor đang có access modifier là default.
Qua hai vấn đề trên, ta rút ra một quy tắc sinh event khi khởi tạo đối tượng như sau:
- Nếu có hai nơi tạo đối tượng, tại Aggregate và Factory, hãy sinh Event tại Factory vì constructor có thể tạo ra đối tượng không valid và cũng được dùng để tái tạo đối tượng từ database.
- Nếu duy trì quy tắc một nơi duy nhất tạo Aggregate, hoặc tại constructor hoặc tại Factory, đó sẽ là nơi đặt Event. Lúc này cần bổ sung quy tắc phân biệt khi tái tạo dữ liệu và khi tạo mới: bằng cờ, bằng Memento, bằng hai class mang trách nhiệm cụ thể cho tạo mới và tái tạo.
- Với trường hợp “lắp ghép” đối tượng phức tạp, ưu tiên cách thêm method tại Factory để tận dụng logic. Method có tên dạng "reconstructOrder", trong này không phát sinh Events.
Hướng tiếp cận 2
Việc duy trì một tham chiếu tới EventListener qua dependency injection gặp một số vấn đề sau:
- Rắc rối khi sử dụng do cần thêm tham số để inject.
- Các class implementation của EventListener thường chỉ làm một việc cho những event mà nó lắng nghe. Điều này là đúng với nguyên tắc đơn trách nhiệm. Ta thử hình dung, giả sử, Domain phát ra 10 loại Event khác nhau. Application của ta muốn nghe hết nhưng 5 loại đầu tiên ta muốn lưu vào event store, còn 5 loại sau ta muốn publish ngay thay vì lưu. Với cách làm trên, ta phải làm viết 1 EventListener implementation tiếp nhận hết 10 loại Event, sau đó bên trong thực hiện cả hai việc.
Hướng tiếp cận sau đây giúp giải quyết 2 vấn đề trên giúp code rõ ràng hơn.
Cách làm sơ bộ như sau:
- Domain vẫn sử dụng một Event Publisher nhưng lần này là Publisher thực sự: Nó bắn Events ra bên ngoài cho những Subscribers tiếp nhận.
- Event Publisher được sử dụng thuận tiện hơn bằng cách cài đặt ThreadLocal. Điều này giúp các Domain Object khác có thể tạo Event và triệu hồi Event Publisher được dễ dàng hơn, tại mọi nơi mà không cần cơ chế inject hay qua thuộc tính.
- Các Event Subscriber được Application tạo ra và chúng có thể đăng ký lắng nghe các loại Events khác nhau và có hành xử khác nhau.

(Xem tiếp bài viết sau)