Vấn đề
Nhiều nghiệp vụ khác nhau đều cần chung một luồng duyệt nhiều cấp có deadline từng cấp, mỗi công ty cấu hình một kiểu. Nhưng nếu engine đọc bảng của nghiệp vụ, hoặc nghiệp vụ import ruột engine, thì sáu tháng sau không ai tách ra được nữa.
Thêm ba chuyện khó: cấu hình duyệt vẫn bị sửa trong lúc đơn đang chạy; hai người duyệt bấm cùng lúc; và sếp duyệt một phát cả trăm đơn.
Cách làm
Một class công khai duy nhất là toàn bộ bề mặt của engine. Service bên trong không export, và lint chặn import sâu.
Đường về bị đảo chiều: engine phát domain event, nghiệp vụ subscribe. Nghiệp
vụ tự đăng ký nguồn log vào một registry lúc boot, để lịch sử của nó ghép được
vào timeline duyệt mà engine không cần biết bảng nào. Quan hệ giữa module dùng
entity_type + entity_id có index, không dùng khoá ngoại xuyên module.
Lúc submit thì snapshot toàn bộ cây workflow kèm deadline, nên sửa cấu hình sau đó không viết lại lịch sử.
Approve và reject mang optimistic lock, trả 409 nếu ghi trên bản cũ; duyệt
một cấp sẽ đánh dấu các peer là ignored và mở cấp kế tiếp trong cùng
transaction.
Bulk là code path riêng: một transaction, gom câu lệnh theo bảng đúng thứ tự thay vì khoá lẫn nhau; item hỏng thì bỏ qua kèm mã lỗi chứ không giết cả lô.
SLA dùng hai job delay như đồng hồ báo thức, không phải nguồn sự thật: job nổ thì đọc lại DB, cấp đã xử lý rồi thì không làm gì — nên không bao giờ phải huỷ job. Id job tất định nên reschedule không đẻ mail trùng. Redis chết thì mất nhắc nhở nhưng không chặn duyệt.
Ranh giới của failure domain này đáng vẽ hẳn ra:
Kết quả
50 file TypeScript đứng sau một class gateway duy nhất, 11 mã lỗi có tài liệu, 7 sơ đồ luồng, và 18 user story được viết ngược lại từ code đã ship.
Notification có chủ sở hữu rõ ràng: engine sở hữu toàn bộ mail thuộc luồng duyệt và trả về đúng danh sách người nhận để không ai phải đoán. Mọi dispatch chạy sau commit và fail-soft — mail lỗi không bao giờ undo được một lần duyệt.