Chương 16. Domain Event
Ở chương 15, ta nói về Use case: nơi điều phối một hành động nghiệp vụ.
Khi một use case hoàn thành một việc quan trọng, thường sẽ có nhiều phần khác trong hệ thống cần biết.
Ví dụ khách đặt bánh thành công:
- Bếp cần biết để chuẩn bị bánh.
- Payment cần biết để xử lý thanh toán.
- Notification cần biết để gửi thông báo.
- Analytics cần biết để ghi nhận hành vi.
- Inventory cần biết để cập nhật tồn kho.
- Support có thể cần biết nếu đơn có ghi chú đặc biệt.
Nếu PlaceOrderUseCase gọi trực tiếp tất cả các phần này, nó sẽ ngày càng phình to.
PlaceOrderUseCase
create order
call payment
call kitchen
call inventory
call notification
call analytics
call support
Ban đầu nhìn đơn giản.
Nhưng sau một thời gian, mỗi khi có thêm một bên muốn phản ứng với việc "đơn đã được đặt", ta lại phải sửa PlaceOrderUseCase.
Đây là dấu hiệu hệ thống đang bị dính chặt.
Domain event giúp ta xử lý vấn đề này.
Nói ngắn gọn:
> Domain event là một sự kiện nghiệp vụ quan trọng đã xảy ra trong domain.
Ví dụ:
OrderPlaced
PaymentSucceeded
PaymentFailed
GradingCompleted
SubmissionCreated
DeliveryFailed
RefundIssued
SeatReserved
Event không nói "hãy làm việc X".
Event nói:
> Việc Y đã xảy ra.
Các phần khác nghe event đó và tự quyết định có cần phản ứng hay không.
---
16.1. Ví dụ quán bánh: đặt hàng xong rồi ai làm gì?
Khách đặt một hộp bánh.
Use case đặt hàng làm việc chính:
PlaceOrderUseCase
tạo Order
lưu Order
ghi OrderPlaced
trả order_id cho khách
Sau đó, nhiều thứ có thể xảy ra:
OrderPlaced
-> Payment tạo payment intent
-> Kitchen tạo phiếu làm bánh
-> Inventory giữ nguyên liệu
-> Notification gửi tin xác nhận
-> Analytics ghi conversion
Điểm hay là PlaceOrderUseCase không cần biết tất cả phản ứng này.
Nó chỉ cần công bố sự thật:
OrderPlaced
Ai quan tâm thì nghe.
Ai không quan tâm thì bỏ qua.
Sau này nếu thêm hệ thống CRM muốn nhận thông tin khách đặt hàng, ta thêm handler mới:
OrderPlaced
-> CRM sync customer activity
Không cần sửa logic đặt hàng cốt lõi.
Đây là lợi ích lớn nhất của domain event:
> Tách việc đã xảy ra khỏi những phản ứng sau đó.
---
16.2. Domain event là gì?
Domain event là một bản ghi rằng một điều có ý nghĩa trong nghiệp vụ đã xảy ra.
Nó thường có vài đặc điểm:
- Được đặt tên ở thì quá khứ.
- Có ý nghĩa với người hiểu nghiệp vụ.
- Xảy ra sau khi sự thay đổi chính đã thành công.
- Chứa thông tin đủ để bên khác phản ứng.
- Không phụ thuộc quá sâu vào model nội bộ.
Ví dụ tên tốt:
OrderPlaced
OrderCancelled
PaymentSucceeded
PaymentFailed
GradingCompleted
SubmissionCreated
DeliveryFailed
RefundIssued
WalletDebited
SeatHoldExpired
Tên chưa tốt:
OrderUpdated
StatusChanged
DataChanged
ProcessFinished
TableSynced
ObjectSaved
Vì chúng quá mơ hồ.
StatusChanged không nói rõ:
- Status nào?
- Của context nào?
- Vì sao đổi?
- Ý nghĩa nghiệp vụ là gì?
Event tốt nên khiến người đọc hiểu:
> À, chuyện này vừa xảy ra trong hệ thống.
---
16.3. Event khác command thế nào?
Đây là điểm rất quan trọng.
Command là yêu cầu làm một việc.
Event là thông báo một việc đã xảy ra.
Ví dụ command:
PlaceOrder
CancelOrder
ChargePayment
SendEmail
RunGradingJob
Nó ở dạng mệnh lệnh:
> Hãy làm việc này.
Ví dụ event:
OrderPlaced
OrderCancelled
PaymentCharged
EmailSent
GradingCompleted
Nó ở dạng quá khứ:
> Việc này đã xảy ra.
So sánh:
| Command | Event | |---|---| | Yêu cầu làm gì đó | Thông báo đã xảy ra gì đó | | Thường có một bên xử lý chính | Có thể có nhiều bên nghe | | Có thể bị từ chối | Đã là sự thật đã xảy ra | | Ví dụ CancelOrder | Ví dụ OrderCancelled |
Nếu bạn đặt event tên như:
SendOrderEmail
thì đó thật ra là command, không phải event.
Event đúng hơn là:
OrderPlaced
Notification handler nghe event đó rồi quyết định gửi email.
---
16.4. Event khác message kỹ thuật thế nào?
Không phải message nào trong queue cũng là domain event.
Ví dụ message kỹ thuật:
ResizeImageJob
SyncSearchIndexJob
RetryHttpRequest
CleanTempFiles
SendEmailJob
Chúng là việc cần làm, thường mang tính kỹ thuật.
Domain event thì nói về nghiệp vụ:
ProductPublished
OrderPaid
DeliveryFailed
StudentSubmittedAssignment
GradingCompleted
Hai loại này có thể đi qua cùng một queue, nhưng ý nghĩa khác nhau.
Ví dụ:
OrderPlaced event
-> handler tạo SendEmailJob
OrderPlaced là domain event.
SendEmailJob là job kỹ thuật để gửi email.
Phân biệt được hai thứ này giúp kiến trúc rõ hơn.
---
16.5. Vì sao domain event hữu ích?
Domain event hữu ích vì nó giảm sự phụ thuộc trực tiếp.
Không có event:
PlaceOrderUseCase
-> PaymentService
-> KitchenService
-> NotificationService
-> AnalyticsService
-> InventoryService
Có event:
PlaceOrderUseCase
-> OrderPlaced
Payment listens to OrderPlaced
Kitchen listens to OrderPlaced
Notification listens to OrderPlaced
Analytics listens to OrderPlaced
Inventory listens to OrderPlaced
Use case chính nhẹ hơn.
Các phản ứng phụ độc lập hơn.
Thêm phản ứng mới dễ hơn.
Test use case chính cũng dễ hơn.
Nhưng event không miễn phí.
Nó làm hệ thống khó nhìn hơn nếu dùng bừa.
Ta sẽ bàn kỹ cả mặt lợi và mặt hại.
---
16.6. Khi nào nên dùng domain event?
Nên nghĩ đến domain event khi:
- Một việc xảy ra và nhiều bên cần biết.
- Phản ứng sau đó không cần làm ngay trong request chính.
- Muốn giảm coupling giữa các module/service.
- Muốn mở rộng hệ thống bằng handler mới.
- Muốn ghi lại sự kiện nghiệp vụ quan trọng.
- Muốn tích hợp với service khác qua queue/pub-sub.
Ví dụ:
OrderPlaced
-> gửi email
-> tạo phiếu bếp
-> ghi analytics
-> cập nhật recommendation
GradingCompleted
-> cập nhật tiến độ học
-> gửi thông báo
-> ghi chi phí AI
-> mở bài học tiếp theo
PaymentSucceeded
-> cập nhật order
-> ghi revenue
-> cấp quyền truy cập
-> gửi hóa đơn
Nếu chỉ có một bước đơn giản và cần làm ngay, event có thể chưa cần.
---
16.7. Khi nào chưa nên dùng event?
Event không phải thuốc chữa mọi bệnh.
Chưa nên dùng event nếu:
- Luồng rất đơn giản.
- Chỉ có một nơi xử lý.
- Cần kết quả ngay lập tức trong cùng request.
- Team chưa có monitoring/retry tốt.
- Event dùng để che giấu boundary chưa rõ.
- Dùng event làm code khó theo dõi hơn.
Ví dụ:
Admin đổi tên category.
Nếu không có bên nào cần phản ứng, không cần event CategoryNameChanged.
Admin sửa typo trong mô tả sản phẩm.
Không cần event nếu chỉ lưu database là xong.
Event có ích khi sự kiện đó có ý nghĩa với nhiều phần hoặc có giá trị nghiệp vụ rõ.
Đừng phát event cho mọi update nhỏ chỉ vì nghe hiện đại.
---
16.8. Event nên chứa gì?
Một event nên chứa đủ thông tin để bên nghe xử lý, nhưng không nên chứa toàn bộ model nội bộ.
Ví dụ OrderPlaced:
OrderPlaced
- event_id
- occurred_at
- order_id
- customer_id
- total_amount
- currency
- items
Tùy nhu cầu, items có thể chỉ gồm:
- product_id
- quantity
- unit_price
Không nên nhét toàn bộ object Order với mọi field:
OrderPlaced
- full_order_object_with_every_internal_field
Vì bên nghe sẽ phụ thuộc vào chi tiết nội bộ của Ordering.
Khi Order đổi field, event consumer có thể vỡ.
Event là contract.
Hãy thiết kế nó như API.
---
16.9. Event tối thiểu hay event giàu dữ liệu?
Có hai cách thiết kế phổ biến.
Cách 1: Event tối thiểu
Event chỉ chứa id và metadata:
OrderPlaced
- event_id
- occurred_at
- order_id
Bên nghe cần thêm dữ liệu thì gọi API hoặc query lại.
Ưu điểm:
- Event nhỏ.
- Ít lộ dữ liệu.
- Ít phụ thuộc schema.
Nhược điểm:
- Consumer phải gọi ngược lại service khác.
- Dễ tạo tải đọc.
- Nếu dữ liệu đã thay đổi, consumer có thể không thấy đúng snapshot thời điểm event xảy ra.
Cách 2: Event giàu dữ liệu
Event chứa dữ liệu cần thiết:
OrderPlaced
- order_id
- customer_id
- items
- total_amount
- delivery_address_snapshot
Ưu điểm:
- Consumer xử lý độc lập hơn.
- Ít cần gọi ngược.
- Giữ được snapshot tại thời điểm xảy ra.
Nhược điểm:
- Event lớn hơn.
- Phải quản lý version cẩn thận.
- Có thể lộ dữ liệu không cần thiết.
Không có đáp án duy nhất.
Quy tắc thực dụng:
> Event nên chứa đủ dữ liệu cho các consumer chính xử lý mà không phụ thuộc quá sâu vào model nội bộ.
---
16.10. Event phải là sự thật đã commit
Một event domain nên phản ánh sự thật đã được lưu thành công.
Ví dụ:
Không nên publish OrderPlaced ra ngoài khi transaction tạo order chưa commit.
Vì nếu transaction rollback, bên ngoài đã nghe một sự kiện không có thật.
Luồng nguy hiểm:
publish OrderPlaced
save order
commit failed
Kết quả:
- Payment tưởng có order.
- Kitchen tạo ticket.
- Nhưng order không tồn tại.
Cách tốt hơn:
begin transaction
save order
save OrderPlaced vào outbox
commit
outbox worker publish event
Đây là pattern Outbox.
Nói dễ hiểu:
> Ghi sự kiện vào cùng database transaction với thay đổi chính, rồi publish sau.
Nhờ vậy, nếu order lưu thành công thì event không bị mất.
Nếu order rollback thì event cũng rollback.
---
16.11. Outbox Pattern dễ hiểu
Giả sử tạo order thành công, nhưng hệ thống chết ngay trước khi publish event ra message queue.
Nếu không có outbox:
save order success
publish event failed
Order tồn tại, nhưng không ai biết.
Bếp không làm bánh.
Notification không gửi.
Analytics không ghi.
Outbox giải quyết bằng cách lưu event vào bảng riêng trong cùng transaction:
begin transaction
insert orders
insert outbox_events(OrderPlaced)
commit
Sau đó một worker riêng đọc outbox:
find unpublished events
publish to queue
mark as published
Nếu worker chết, nó chạy lại.
Event vẫn còn trong database.
Outbox không làm hệ thống đơn giản hơn về code, nhưng làm nó đáng tin hơn.
Với hệ thống quan trọng, đây là pattern rất đáng biết.
---
16.12. Event handler là gì?
Event handler là phần nghe event và phản ứng.
Ví dụ:
OrderPlaced
-> CreateKitchenTicketHandler
-> SendOrderConfirmationHandler
-> TrackOrderAnalyticsHandler
Mỗi handler nên làm một việc rõ ràng.
Handler không nên trở thành nơi gom mọi thứ.
Ví dụ không tốt:
OrderPlacedHandler
create kitchen ticket
send email
update analytics
sync CRM
notify support
Cách tốt hơn:
CreateKitchenTicketOnOrderPlaced
SendConfirmationOnOrderPlaced
TrackAnalyticsOnOrderPlaced
SyncCrmOnOrderPlaced
Tách handler giúp:
- Dễ retry riêng.
- Dễ tắt/bật phản ứng.
- Dễ test.
- Lỗi email không làm hỏng tạo phiếu bếp.
---
16.13. Handler đồng bộ hay bất đồng bộ?
Handler có thể chạy đồng bộ trong cùng process hoặc bất đồng bộ qua queue.
Handler đồng bộ
Ví dụ:
PlaceOrderUseCase
-> OrderPlaced
-> handler chạy ngay trong process
Ưu điểm:
- Dễ hiểu.
- Dễ debug.
- Ít hạ tầng.
Nhược điểm:
- Handler chậm làm request chậm.
- Handler lỗi có thể ảnh hưởng use case chính.
- Khó scale riêng.
Handler bất đồng bộ
Ví dụ:
PlaceOrderUseCase
-> outbox
-> message queue
-> worker handlers
Ưu điểm:
- Request chính nhanh hơn.
- Handler lỗi có thể retry riêng.
- Scale worker riêng.
- Phù hợp việc lâu.
Nhược điểm:
- Debug khó hơn.
- Cần monitoring.
- Có độ trễ.
- Cần idempotency.
Quy tắc thực dụng:
> Phản ứng nhanh, bắt buộc, cùng transaction thì có thể đồng bộ. Phản ứng chậm, có thể retry, không cần user chờ thì nên bất đồng bộ.
---
16.14. Event và queue khác nhau thế nào?
Queue là hạ tầng.
Event là ý nghĩa nghiệp vụ.
Ví dụ:
OrderPlaced
là event.
Nó có thể được gửi qua:
- Redis queue.
- RabbitMQ.
- Kafka.
- AWS SQS.
- Google Pub/Sub.
- Database outbox.
- In-memory event bus trong monolith.
Đừng nhầm công cụ truyền tin với bản thân thông điệp.
Cùng một event có thể đi qua nhiều hạ tầng khác nhau tùy giai đoạn hệ thống.
Trong monolith, event có thể chỉ là function call nội bộ.
Trong microservices, event có thể đi qua message broker.
Ý tưởng domain event vẫn giống nhau.
---
16.15. Event trong monolith
Trong monolith, domain event vẫn hữu ích.
Ví dụ:
ordering/
PlaceOrderUseCase
OrderPlaced event
kitchen/
CreateKitchenTicketOnOrderPlaced
notification/
SendConfirmationOnOrderPlaced
Ban đầu, event handler có thể chạy trong cùng process.
Hoặc event được lưu vào outbox rồi worker đọc.
Không cần microservices mới dùng event.
Domain event trong monolith giúp:
- Module ít gọi trực tiếp nhau hơn.
- Luồng nghiệp vụ rõ hơn.
- Sau này tách service dễ hơn.
- Phản ứng phụ được gom đúng chỗ.
Nhưng với monolith nhỏ, đừng làm quá.
Nếu chỉ có một action đơn giản, gọi function trực tiếp vẫn ổn.
---
16.16. Event trong microservices
Trong microservices, event thường quan trọng hơn vì service không nên sửa database của nhau.
Ví dụ:
payment-service
PaymentSucceeded
ordering-service
nghe PaymentSucceeded
cập nhật Order = PAID
notification-service
nghe PaymentSucceeded
gửi thông báo
accounting-service
nghe PaymentSucceeded
ghi nhận doanh thu
Payment không cần gọi từng service một.
Nó chỉ công bố:
PaymentSucceeded
Các service khác tự xử lý.
Điều này giúp hệ thống mở rộng hơn.
Nhưng cũng yêu cầu kỷ luật:
- Event contract rõ.
- Versioning rõ.
- Retry rõ.
- Monitoring rõ.
- Idempotency rõ.
- Dead letter queue hoặc cách xử lý message lỗi.
Microservices không có event tốt sẽ rất dễ thành một đống HTTP call chằng chịt.
---
16.17. Event và eventual consistency
Domain event thường dẫn đến eventual consistency.
Ví dụ:
Payment đã thành công, nhưng Order chưa kịp cập nhật PAID trong vài trăm mili giây hoặc vài giây.
Trạng thái tạm:
Payment = SUCCEEDED
Order = WAITING_PAYMENT
Sau khi Ordering xử lý event:
Order = PAID
Điều này không nhất thiết là lỗi.
Nó là một phần của thiết kế bất đồng bộ.
Nhưng hệ thống phải biết xử lý:
- UI hiển thị trạng thái đang cập nhật.
- Job retry nếu event xử lý thất bại.
- Có cơ chế reconcile nếu lệch lâu.
- Có metrics để biết backlog.
Eventual consistency không có nghĩa là mặc kệ dữ liệu lệch.
Nó nghĩa là:
> Ta chấp nhận lệch tạm thời trong giới hạn đã hiểu và có cách đưa hệ thống về đúng.
---
16.18. Event và idempotency
Message queue có thể gửi lại cùng một event.
Webhook cũng có thể gửi lại.
Outbox worker cũng có thể publish lại nếu không chắc đã thành công.
Vì vậy handler phải idempotent.
Nghĩa là:
> Xử lý cùng một event nhiều lần không gây sai dữ liệu.
Ví dụ:
PaymentSucceeded được xử lý hai lần.
Không được:
- Cộng doanh thu hai lần.
- Gửi hai hóa đơn nếu không muốn.
- Cấp quyền hai lần theo cách gây lỗi.
- Tạo hai kitchen ticket.
Cách xử lý:
- Mỗi event có
event_id. - Handler lưu event đã xử lý.
- Dùng unique constraint.
- Dùng idempotency key.
- Kiểm tra trạng thái hiện tại trước khi làm.
Ví dụ:
CreateKitchenTicketOnOrderPlaced
if ticket for order_id exists:
return
create ticket
Idempotency không phải chi tiết phụ.
Nó là điều kiện sống còn của event-driven system.
---
16.19. Event ordering
Một vấn đề khó hơn là thứ tự event.
Ví dụ:
OrderPlaced
OrderCancelled
Nếu consumer nhận OrderCancelled trước OrderPlaced thì sao?
Hoặc:
PaymentSucceeded
RefundIssued
Nếu nhận refund trước payment thì sao?
Trong hệ thống thật, thứ tự event không phải lúc nào cũng đảm bảo toàn cục.
Một số queue đảm bảo thứ tự trong cùng partition/key.
Một số không.
Vì vậy cần thiết kế handler cẩn thận:
- Có version hoặc sequence number.
- Có aggregate_id để xử lý theo key.
- Handler kiểm tra trạng thái hiện tại.
- Event đến sớm có thể retry sau.
- Có cơ chế reconcile.
Không cần làm quá ở hệ thống nhỏ.
Nhưng phải biết vấn đề này tồn tại.
Event không chỉ là "bắn message đi là xong".
---
16.20. Event versioning
Event là contract.
Khi event đã có consumer, đổi schema event giống như đổi API.
Ví dụ ban đầu:
OrderPlaced v1
- order_id
- customer_id
- total_amount
Sau này thêm:
OrderPlaced v2
- order_id
- customer_id
- total_amount
- currency
- delivery_address
Thêm field thường dễ hơn xóa field.
Đổi tên field hoặc đổi nghĩa field nguy hiểm hơn.
Quy tắc:
- Đừng xóa field đột ngột.
- Đừng đổi nghĩa field âm thầm.
- Có version nếu thay đổi lớn.
- Consumer nên chịu được field mới.
- Producer nên giữ compatibility khi có thể.
Event cũ có thể vẫn còn trong queue hoặc log.
Handler phải biết xử lý hoặc bỏ qua an toàn.
---
16.21. Event naming: đặt tên thế nào?
Event nên đặt tên bằng ngôn ngữ nghiệp vụ, ở thì quá khứ.
Tốt:
OrderPlaced
OrderCancelled
PaymentSucceeded
PaymentFailed
GradingCompleted
SubmissionCreated
DeliveryFailed
RefundIssued
SeatReserved
QuotaExceeded
Chưa tốt:
OrderUpdateEvent
PaymentEvent
HandleOrder
SendNotification
StatusChanged
DatabaseChanged
Một mẹo:
> Nếu có thể đọc tên event như câu "điều này đã xảy ra", tên đó thường ổn.
Ví dụ:
- Order placed.
- Payment succeeded.
- Grading completed.
- Delivery failed.
Event không nên là tên job.
SendEmail là việc cần làm.
OrderPlaced là lý do để email có thể được gửi.
---
16.22. Event metadata
Ngoài dữ liệu nghiệp vụ, event nên có metadata.
Ví dụ:
event_id
event_type
occurred_at
aggregate_id
aggregate_type
correlation_id
causation_id
producer
schema_version
Không phải hệ thống nhỏ nào cũng cần đủ ngay.
Nhưng vài field rất hữu ích.
event_id
Dùng để idempotency và tracking.
occurred_at
Thời điểm sự kiện xảy ra, không phải thời điểm consumer xử lý.
correlation_id
Giúp nối các event cùng một luồng.
Ví dụ một request checkout tạo ra:
OrderPlaced
PaymentStarted
PaymentSucceeded
KitchenTicketCreated
Các event cùng correlation_id giúp debug dễ hơn.
causation_id
Cho biết event này do event/command nào gây ra.
Không bắt buộc từ đầu, nhưng rất hữu ích khi hệ thống lớn.
---
16.23. Event và observability
Event-driven system khó debug hơn gọi trực tiếp.
Vì luồng không nằm trong một stack trace đơn giản.
Do đó cần observability:
- Log event được publish.
- Log handler bắt đầu/kết thúc.
- Log lỗi handler.
- Metrics số event chờ xử lý.
- Metrics retry.
- Dead letter queue.
- Trace theo correlation_id.
Ví dụ khi khách hỏi:
> Vì sao tôi đặt hàng rồi mà chưa có email?
Ta cần tìm được:
OrderPlaced có được tạo không?
Outbox có publish không?
Notification handler có nhận không?
Email provider có lỗi không?
Handler có retry không?
Nếu không có log/metrics, event-driven system rất dễ biến thành hộp đen.
Event tốt phải đi cùng khả năng quan sát tốt.
---
16.24. Dead Letter Queue là gì?
Dead Letter Queue, thường gọi là DLQ, là nơi chứa message xử lý thất bại quá nhiều lần.
Ví dụ:
Notification handler xử lý OrderPlaced
-> lỗi do email address không hợp lệ
-> retry 5 lần vẫn lỗi
-> đưa message vào DLQ
DLQ giúp:
- Không để một message lỗi chặn cả queue.
- Có nơi để kiểm tra thủ công.
- Có thể sửa lỗi rồi replay.
- Biết hệ thống đang gặp vấn đề gì.
Nếu không có DLQ, message lỗi có thể:
- Retry vô hạn.
- Làm nghẽn queue.
- Bị mất âm thầm.
Với hệ thống quan trọng, phải nghĩ đến DLQ hoặc cơ chế tương đương.
---
16.25. Event replay
Event replay nghĩa là xử lý lại event cũ.
Ví dụ:
- Search index bị lỗi, cần build lại từ event.
- Analytics tính sai, cần chạy lại từ tháng trước.
- Consumer mới cần đọc lại lịch sử.
Nếu event được lưu lâu dài, replay rất hữu ích.
Nhưng replay cũng nguy hiểm nếu handler không idempotent.
Ví dụ replay PaymentSucceeded không được cộng tiền hai lần.
Replay OrderPlaced không được gửi email cũ hàng loạt nếu không muốn.
Vì vậy cần phân biệt:
- Handler dùng cho side effect thật như gửi email.
- Handler dùng để build read model.
- Handler dùng cho analytics.
Không phải event nào cũng nên replay vào mọi handler.
---
16.26. Domain event và Event Sourcing khác nhau
Domain event không đồng nghĩa với Event Sourcing.
Domain event:
> Hệ thống phát sự kiện khi điều quan trọng xảy ra.
Event Sourcing:
> Trạng thái chính của hệ thống được lưu dưới dạng chuỗi event, rồi dựng lại state từ các event đó.
Ví dụ domain event bình thường:
orders table lưu trạng thái hiện tại
outbox_events lưu OrderPlaced để thông báo
Ví dụ event sourcing:
OrderCreated
OrderItemAdded
OrderConfirmed
OrderPaid
OrderDelivered
State của Order được dựng từ chuỗi event này.
Event Sourcing mạnh nhưng phức tạp hơn nhiều.
Không cần dùng Event Sourcing chỉ để có domain event.
Hầu hết hệ thống thực dụng có thể dùng:
- Database trạng thái hiện tại.
- Domain event/outbox để thông báo thay đổi.
Như vậy đã đủ tốt trong rất nhiều trường hợp.
---
16.27. Domain event và audit log khác nhau
Audit log ghi lại ai làm gì, khi nào.
Domain event thông báo sự kiện nghiệp vụ đã xảy ra để hệ thống phản ứng.
Ví dụ audit log:
Admin 123 changed order 456 status from PENDING to CANCELLED at 10:31
Ví dụ domain event:
OrderCancelled
- order_id
- reason
- cancelled_by
- occurred_at
Hai thứ có thể liên quan, nhưng mục đích khác.
Audit log phục vụ:
- Truy vết.
- Pháp lý.
- Bảo mật.
- Điều tra.
Domain event phục vụ:
- Giao tiếp giữa module/service.
- Kích hoạt phản ứng.
- Xây read model.
- Tách coupling.
Đừng dùng audit log lộn thành event bus nếu nó không được thiết kế cho việc đó.
---
16.28. Domain event và integration event
Trong DDD, đôi khi người ta phân biệt:
- Domain event nội bộ.
- Integration event gửi ra ngoài service/module.
Ví dụ trong Payment Context:
Domain event nội bộ:
PaymentCaptured
Integration event công bố cho service khác:
PaymentSucceeded
Hai event có thể giống nhau, nhưng không bắt buộc.
Vì sao cần phân biệt?
Vì domain event nội bộ có thể chứa chi tiết mà bên ngoài không cần biết.
Integration event là contract public hơn, cần ổn định hơn.
Trong dự án nhỏ, có thể không cần tách rõ.
Nhưng khi service lớn và nhiều consumer, nên nghĩ:
> Event nào chỉ dùng trong context, event nào là lời hứa với bên ngoài?
---
16.29. Ví dụ AI Judge với domain event
Bài toán chấm bài bằng AI rất hợp để dùng event.
Luồng:
Student submits assignment
-> SubmissionCreated
-> GradingJobCreated
-> GradingStarted
-> GradingCompleted hoặc GradingFailed
Khi SubmissionCreated:
- Grading tạo job.
- Notification có thể báo đã nhận bài.
- Analytics ghi event nộp bài.
Khi GradingCompleted:
- Learning cập nhật tiến độ.
- Notification báo có kết quả.
- Analytics ghi thời gian và chi phí.
- Certificate kiểm tra điều kiện hoàn thành.
Event mẫu:
GradingCompleted
- event_id
- occurred_at
- grading_job_id
- submission_id
- student_id
- assignment_id
- score
- max_score
- model_name
Cần chú ý:
- Nếu worker retry, không được phát
GradingCompletednhiều lần gây side effect sai. - Nếu notification lỗi, không được làm mất kết quả chấm.
- Nếu analytics chậm, không được ảnh hưởng học viên xem điểm.
- Nếu Learning chưa cập nhật ngay, UI cần biết trạng thái đang đồng bộ.
Domain event giúp AI Judge tách rõ:
Chấm bài là core.
Thông báo, analytics, mở bài tiếp theo là phản ứng.
---
16.30. Ví dụ Payment với event
Payment là nơi event rất quan trọng.
Luồng:
Payment gateway gửi webhook
-> HandlePaymentWebhookUseCase
-> Payment Aggregate xác nhận thành công
-> PaymentSucceeded
Sau đó:
PaymentSucceeded
-> Ordering đánh dấu order đã thanh toán
-> Accounting ghi nhận doanh thu
-> Notification gửi biên nhận
-> Fulfillment bắt đầu xử lý đơn
Không nên để webhook handler tự gọi tất cả:
handle webhook
update payment
update order
create invoice
send email
call fulfillment
Webhook handler nên tập trung vào Payment.
Payment thành công thì phát event.
Các context khác nghe và làm phần của mình.
Đặc biệt với Payment:
- Webhook có thể gửi lại.
- Thứ tự webhook có thể lộn.
- Cần verify chữ ký.
- Cần idempotency.
- Cần lưu raw payload để debug.
Event giúp tách phần payment core khỏi phản ứng sau thanh toán.
---
16.31. Ví dụ DeliveryFailed
Khi giao hàng thất bại:
DeliveryTask.mark_failed(reason)
-> DeliveryFailed
Event:
DeliveryFailed
- delivery_task_id
- order_id
- failed_reason
- failed_at
- attempt_number
Các phản ứng:
Support tạo ticket chăm sóc khách
Ordering cập nhật trạng thái hiển thị
Notification báo khách
Delivery có thể lên lịch giao lại
Analytics ghi tỷ lệ thất bại
Nếu tất cả nằm trong DeliveryService.mark_failed, service này sẽ biết quá nhiều.
Event giúp Delivery nói:
> Việc giao hàng đã thất bại.
Rồi mỗi bên tự xử lý theo ngôn ngữ của mình.
---
16.32. Event không thay thế thiết kế use case
Một lỗi thường gặp:
> Không biết luồng nên thiết kế thế nào, cứ bắn event cho linh hoạt.
Event không thay thế tư duy nghiệp vụ.
Bạn vẫn cần biết:
- Use case chính là gì?
- Aggregate nào thay đổi?
- Transaction nằm ở đâu?
- Event nào có ý nghĩa?
- Ai là nguồn sự thật?
- Handler nào bắt buộc?
- Handler nào có thể trễ?
- Nếu handler fail thì sao?
Nếu không biết những điều này, event chỉ làm hệ thống khó hiểu hơn.
Event tốt đến sau việc hiểu domain.
Không phải trước.
---
16.33. Event không nên là cách gọi hàm vòng vo
Đôi khi người ta dùng event để né dependency, nhưng kết quả là khó đọc hơn.
Ví dụ:
UserClickedButton
-> ValidateInputHandler
-> SaveDataHandler
-> ReturnResponseHandler
Nếu đây chỉ là một luồng đồng bộ đơn giản, event làm mọi thứ rối hơn.
Event phù hợp khi có sự kiện nghiệp vụ thật và nhiều phản ứng độc lập.
Không phù hợp để thay thế mọi function call.
Một câu hỏi tốt:
> Nếu không có handler nào nghe event này, sự kiện này vẫn có ý nghĩa nghiệp vụ không?
Nếu câu trả lời là không, có thể đó chỉ là một implementation detail.
---
16.34. Event và bảo mật dữ liệu
Event có thể đi đến nhiều consumer.
Vì vậy phải cẩn thận với dữ liệu nhạy cảm.
Không nên đưa vào event nếu không cần:
- Mật khẩu.
- Token.
- Thông tin thẻ.
- Dữ liệu cá nhân quá chi tiết.
- Nội dung riêng tư.
- Secret key.
Ví dụ PaymentSucceeded không nên chứa toàn bộ thông tin thẻ.
Chỉ cần:
payment_id
order_id
amount
currency
paid_at
payment_method_type
Event là contract lan rộng.
Dữ liệu đã phát đi thì khó thu lại.
Thiết kế event phải nghĩ đến bảo mật ngay từ đầu.
---
16.35. Event và quyền được quên
Nếu hệ thống lưu event lâu dài, cần nghĩ đến dữ liệu cá nhân.
Ví dụ event chứa:
- Email.
- Số điện thoại.
- Địa chỉ.
- Tên thật.
Sau này nếu người dùng yêu cầu xóa dữ liệu cá nhân, event log xử lý thế nào?
Các hướng có thể là:
- Không đưa dữ liệu cá nhân vào event nếu không cần.
- Dùng id và để consumer tự lấy dữ liệu có kiểm soát.
- Mã hóa một số field.
- Có cơ chế redact/anonymize event cũ.
- Đặt retention policy.
Không phải dự án nào cũng phải giải quyết đầy đủ ngay.
Nhưng nếu event chứa dữ liệu nhạy cảm, phải biết bạn đang tạo một lịch sử dữ liệu khó sửa.
---
16.36. Bảng phân loại nhanh
| Loại thông điệp | Ý nghĩa | Ví dụ | |---|---|---| | Command | Yêu cầu làm việc gì đó | CancelOrder, RunGradingJob | | Domain event | Điều nghiệp vụ đã xảy ra | OrderCancelled, GradingCompleted | | Integration event | Event public cho service khác | PaymentSucceeded | | Job | Việc kỹ thuật cần chạy | SendEmailJob, ResizeImageJob | | Audit log | Bản ghi truy vết | Admin changed order status |
Nhìn tên message, hãy hỏi:
> Đây là yêu cầu, sự thật đã xảy ra, việc kỹ thuật, hay bản ghi truy vết?
Phân biệt rõ thì hệ thống dễ hiểu hơn.
---
16.37. Một cấu trúc event thực dụng
Một event có thể có cấu trúc như sau:
{
"event_id": "evt_123",
"event_type": "OrderPlaced",
"schema_version": 1,
"occurred_at": "2026-05-11T10:00:00Z",
"aggregate_type": "Order",
"aggregate_id": "order_456",
"correlation_id": "req_789",
"data": {
"order_id": "order_456",
"customer_id": "cust_001",
"total_amount": 350000,
"currency": "VND"
}
}
Không cần máy móc copy y hệt.
Nhưng các ý chính nên có:
- Event này là gì?
- Xảy ra khi nào?
- Xảy ra với đối tượng nào?
- Phiên bản schema nào?
- Dữ liệu nghiệp vụ là gì?
- Có thể trace theo luồng nào?
---
16.38. Bài tập thiết kế event
Lấy một use case trong hệ thống của bạn.
Ví dụ:
- Học viên nộp bài.
- Chấm bài xong.
- Khách đặt hàng.
- Thanh toán thành công.
- Giao hàng thất bại.
- Hoàn tiền xong.
Trả lời:
1. Sau use case này, điều gì thật sự đã xảy ra? 2. Tên event ở thì quá khứ là gì? 3. Event này có ý nghĩa với ai? 4. Ai cần nghe event này? 5. Handler nào phải chạy ngay? 6. Handler nào có thể chạy sau? 7. Event cần chứa dữ liệu gì? 8. Có dữ liệu nhạy cảm nào không nên đưa vào không? 9. Nếu event được xử lý hai lần thì có sao không? 10. Nếu handler fail thì retry thế nào? 11. Nếu event đến sai thứ tự thì sao? 12. Có cần outbox không?
Nếu trả lời được, bạn đã đi xa hơn rất nhiều so với việc chỉ "thêm queue".
---
16.39. Những lỗi phổ biến
Lỗi 1: Event tên quá chung
StatusChanged
DataUpdated
EntitySaved
Không nói rõ nghiệp vụ.
Lỗi 2: Event chứa toàn bộ database row
Consumer phụ thuộc vào schema nội bộ.
Thay đổi model là vỡ event.
Lỗi 3: Publish trước khi commit
Event báo một sự thật chưa chắc đã tồn tại.
Lỗi 4: Handler không idempotent
Event retry là tạo dữ liệu trùng hoặc side effect trùng.
Lỗi 5: Không có monitoring
Event lỗi âm thầm, không ai biết queue đang nghẽn.
Lỗi 6: Dùng event thay mọi function call
Hệ thống nhỏ mà luồng nào cũng qua event, debug rất mệt.
Lỗi 7: Không version event
Producer đổi schema, consumer vỡ.
Lỗi 8: Dữ liệu nhạy cảm phát tán qua event
Event đi nhiều nơi hơn bạn nghĩ.
Lỗi 9: Handler làm quá nhiều việc
Một handler xử lý cả email, analytics, CRM, support, accounting.
Khó retry riêng và khó debug.
---
16.40. Checklist trước khi dùng domain event
Trước khi thêm event, hãy hỏi:
- Event này có phải sự kiện nghiệp vụ thật không?
- Tên event có ở thì quá khứ không?
- Ai là producer?
- Ai là consumer?
- Event có cần public ra ngoài context không?
- Event chứa dữ liệu gì?
- Có dữ liệu nhạy cảm không?
- Event được tạo sau khi transaction commit bằng cách nào?
- Có cần outbox không?
- Handler có idempotent không?
- Nếu handler fail thì retry/DLQ ra sao?
- Có cần đảm bảo thứ tự không?
- Có cần schema version không?
- Có log/metrics để debug không?
Nếu chưa trả lời được phần lớn câu hỏi, khoan dùng event cho luồng quan trọng.
---
16.41. Tóm tắt bằng một luồng
Luồng đặt bánh:
Frontend
-> PlaceOrderUseCase
-> Order Aggregate tạo đơn
-> Save Order + Save OrderPlaced vào Outbox
-> Commit
-> Outbox Worker publish OrderPlaced
-> Các handler xử lý:
- CreateKitchenTicket
- SendOrderConfirmation
- TrackAnalytics
- ReserveInventory
Điểm quan trọng:
- Use case chính chỉ làm việc cốt lõi.
- Event nói sự thật đã xảy ra.
- Handler phản ứng độc lập.
- Outbox giúp event không mất.
- Handler phải idempotent.
- Monitoring giúp biết event đang chạy ra sao.
---
16.42. Kết luận của chương
Domain event là cách hệ thống nói:
> Một điều quan trọng trong nghiệp vụ vừa xảy ra.
Nó giúp các phần khác phản ứng mà không làm use case chính dính chặt vào mọi thứ.
Domain event đặc biệt hữu ích khi:
- Một sự kiện có nhiều bên quan tâm.
- Phản ứng có thể chạy sau.
- Muốn tách module/service.
- Muốn mở rộng hệ thống bằng consumer mới.
- Muốn xây luồng event-driven thực dụng.
Nhưng event cũng mang theo chi phí:
- Debug khó hơn.
- Cần retry.
- Cần idempotency.
- Cần outbox nếu không muốn mất event.
- Cần versioning khi có nhiều consumer.
- Cần monitoring tốt.
Thông điệp quan trọng nhất:
> Domain event không phải là "dùng queue cho hiện đại". Domain event là đặt tên rõ cho những sự thật nghiệp vụ đã xảy ra, rồi để các phần liên quan phản ứng một cách có kiểm soát.
Sau chương này, ta đã có đủ nền tảng để bước sang phần tiếp theo: các cách giao tiếp trong hệ thống.
Ở đó, ta sẽ so sánh rõ hơn HTTP/API, Queue, Pub/Sub, Event Streaming, Webhook, Polling, SSE và WebSocket: dùng cái nào, khi nào, và vì sao.