Xây dựng Apex Trigger Framework chuẩn: Handler, Context và kiểm soát Recursion
Nếu bạn từng làm việc với Salesforce đủ lâu, chắc hẳn đã ít nhất một lần gặp cảnh: một object có 4-5 trigger rải rác, mỗi trigger vài trăm dòng logic viết trực tiếp, không ai dám sửa vì sợ vỡ chỗ khác. Bài viết này chia sẻ một pattern khá kinh điển nhưng vẫn bị bỏ qua rất nhiều trong thực tế: one trigger per object, ủy quyền toàn bộ logic cho một handler class, kèm cơ chế kiểm soát recursion tử tế.
Vì sao không nên viết logic trực tiếp trong trigger?
Salesforce cho phép nhiều trigger trên cùng một object, nhưng thứ tự thực thi giữa các trigger đó không được đảm bảo. Nếu bạn có AccountTrigger1 và AccountTrigger2 cùng chạy trên before update, bạn không có cách nào chắc chắn cái nào chạy trước - điều này gây ra bug rất khó tái hiện khi hệ thống lớn dần.
Giải pháp chuẩn: mỗi object chỉ có đúng một trigger, và trigger đó chỉ làm một việc duy nhất - gọi vào handler.
trigger AccountTrigger on Account (
before insert, before update, before delete,
after insert, after update, after delete, after undelete
) {
new AccountTriggerHandler().run();
}
Base Handler Class
Thay vì viết if/else theo Trigger.isBefore, Trigger.isInsert... trong từng trigger, ta đẩy toàn bộ logic điều phối vào một base class dùng chung cho mọi object:
public virtual class TriggerHandler {
private static Map<String, LoopCount> loopCountMap = new Map<String, LoopCount>();
private static Set<String> bypassedHandlers = new Set<String>();
private TriggerContext context;
private Boolean isTriggerExecuting;
public TriggerHandler() {
this.setTriggerContext();
}
public void run() {
if (!validateRun()) return;
addToLoopCount();
switch on context {
when BEFORE_INSERT { beforeInsert(); }
when BEFORE_UPDATE { beforeUpdate(); }
when BEFORE_DELETE { beforeDelete(); }
when AFTER_INSERT { afterInsert(); }
when AFTER_UPDATE { afterUpdate(); }
when AFTER_DELETE { afterDelete(); }
when AFTER_UNDELETE { afterUndelete(); }
}
}
// Các hàm virtual để subclass override — mặc định rỗng
protected virtual void beforeInsert() {}
protected virtual void beforeUpdate() {}
protected virtual void beforeDelete() {}
protected virtual void afterInsert() {}
protected virtual void afterUpdate() {}
protected virtual void afterDelete() {}
protected virtual void afterUndelete() {}
// ... setTriggerContext(), validateRun(), loop control bên dưới
}
Handler cụ thể cho từng object chỉ cần override đúng những sự kiện nó quan tâm:
public class AccountTriggerHandler extends TriggerHandler {
protected override void beforeUpdate() {
for (Account acc : (List<Account>) Trigger.new) {
Account oldAcc = (Account) Trigger.oldMap.get(acc.Id);
if (acc.Industry != oldAcc.Industry) {
acc.Industry_Changed_Date__c = System.now();
}
}
}
protected override void afterUpdate() {
AccountService.recalculateHealthScore(
new List<Account>((List<Account>) Trigger.new)
);
}
}
Ưu điểm: mỗi handler chỉ implement đúng phần mình cần, code review dễ hơn nhiều vì logic nằm gọn trong 1-2 method, và test class chỉ cần gọi thẳng vào method thay vì phải insert/update DML để trigger chạy.
Kiểm soát Recursion — phần hay bị bỏ qua
Đây là chỗ 80% các bài tutorial về trigger framework dừng lại, nhưng thực tế production lại là nơi mọi thứ vỡ trận. Vấn đề: một after update gọi DML update lại chính record đó (ví dụ để recalculate một rollup field) sẽ khiến trigger chạy lại - nếu không kiểm soát, bạn dễ dính lỗi "Maximum trigger depth exceeded" hoặc tệ hơn là silent infinite recalculation ngốn hết governor limit.
Cách xử lý phổ biến nhất: đếm số lần trigger chạy trong 1 transaction, giới hạn ở một ngưỡng hợp lý.
private class LoopCount {
private Integer max;
private Integer count;
public LoopCount() {
this.max = 5; // ngưỡng mặc định
this.count = 0;
}
public Boolean exceeded() {
return count > max;
}
public void increment() {
count++;
}
}
Trong run(), trước khi thực thi logic, handler sẽ tăng counter tương ứng với handlerName + context và kiểm tra ngưỡng - nếu vượt, throw exception hoặc log lỗi thay vì để Salesforce tự cắt ở governor limit cứng (thường là 16 lần lồng nhau).
Cơ chế Bypass — cứu cánh khi cần import dữ liệu hàng loạt
Một tình huống rất thực tế: bạn cần chạy data migration hoặc batch job update 50.000 record, nhưng trigger validation/automation trên object đó lại không cần thiết (hoặc gây lỗi) trong ngữ cảnh migration. Thay vì comment code trigger ra rồi deploy lại - rất rủi ro - ta thêm cơ chế bypass tĩnh:
public static void bypass(String handlerName) {
TriggerHandler.bypassedHandlers.add(handlerName);
}
public static void clearBypass(String handlerName) {
TriggerHandler.bypassedHandlers.remove(handlerName);
}
public static Boolean isBypassed(String handlerName) {
return TriggerHandler.bypassedHandlers.contains(handlerName);
}
Batch job chỉ cần gọi TriggerHandler.bypass('AccountTriggerHandler') trước khi chạy DML, và toàn bộ logic trong handler đó sẽ bị skip mà không cần đụng vào trigger hay deploy lại metadata.
Kết luận
Pattern "một trigger, một handler, có kiểm soát recursion và bypass" không phải kỹ thuật mới - nó gần như là chuẩn mực bất thành văn trong cộng đồng Salesforce (nhiều phiên bản open-source như của Kevin O'Hara hay dlrs đã áp dụng từ lâu). Nhưng trong thực tế triển khai cho khách hàng, mình vẫn thường xuyên gặp codebase thiếu hẳn phần recursion control và bypass - vốn là h
ai thứ chỉ thực sự "đau" khi hệ thống đã chạy production được vài tháng và data lớn dần lên.
Nếu bạn đang refactor một Salesforce org có nhiều trigger rải rác, đây là điểm khởi đầu đáng để đầu tư thời gian sớm, trước khi technical debt dồn lại thành một cục khó gỡ.
Bài viết được đội ngũ kỹ sư Salesforce tại SapotaCorp tổng hợp từ kinh nghiệm triển khai thực tế.
All rights reserved