Business rule patterns: picking the right timing
Before, after, async, or display. A decision-shaped recipe for the four business rule timings, the double-update bug, the async previous gotcha, and the discipline that keeps rules maintainable.
The problem
Every instance I have ever audited has at least one business rule doing its job at the wrong time: a before rule calling an integration and adding two seconds to every save, an after rule calling current.update() and firing itself, an async rule comparing against previous and silently doing nothing. The script is usually fine. The timing is wrong, and timing is the one dropdown people pick by habit instead of by decision. This recipe is the decision.
The quick version
| You need to | Timing |
|---|---|
| Change fields on the record being saved | before |
| Work with other records once this one is saved | after |
| Do anything slow: integrations, bulk updates, heavy queries | async |
| Ship server data to the form for client scripts | display |
And the shape of each, condensed. A before rule mutates current and never calls update():
(function executeRule(current, previous /*null when async*/) {
// The engine writes the record when before rules finish. No update() here.
if (current.assignment_group.nil() && current.category == 'network') {
var grp = new GlideRecord('sys_user_group');
grp.addQuery('name', 'Network Operations'); // stable name, not a sys_id
grp.setLimit(1);
grp.query();
if (grp.next())
current.assignment_group = grp.getUniqueValue();
}
})(current, previous);
An after rule touches related records:
(function executeRule(current, previous /*null when async*/) {
if (!current.active && previous.active) {
var task = new GlideRecord('incident_task');
task.addQuery('incident', current.getUniqueValue());
task.addActiveQuery();
task.query();
while (task.next()) {
task.state = 3; // Closed Complete
task.work_notes = 'Closed automatically: parent incident resolved.';
task.update();
}
}
})(current, previous);
An async rule does the slow thing after the user is gone, and does not trust previous:
(function executeRule(current, previous /*null when async*/) {
// previous really is null here. Decide from current, or from a field
// an earlier rule stamped for you.
try {
var rm = new sn_ws.RESTMessageV2('Incident Webhook', 'postResolved');
rm.setStringParameterNoEscape('number', current.getValue('number'));
rm.execute();
} catch (ex) {
gs.error('Incident Webhook failed for ' + current.getValue('number') + ': ' + ex);
}
})(current, previous);
And a display rule loads g_scratchpad for the client:
(function executeRule(current, previous /*null when async*/) {
g_scratchpad.callerVip = current.caller_id.vip.toString() == 'true';
})(current, previous);
Where each timing fires
The four timings are four positions around one database operation. Once you can see the sequence, the decision table above stops being something you memorize.
Walking through each timing
Before: the record is still clay. Before rules run with current in memory, pre-write. Set fields, derive values, enforce defaults, or setAbortAction(true) to refuse the save entirely. Because the engine writes the record when before rules finish, calling current.update() here is the classic double-update bug: you get two writes, doubled audit history, re-fired rules, and occasionally a recursion warning in the logs. If you remember one thing from this recipe: in a before rule, assignment is enough.
After: the record exists, now do the ripple effects. After rules run post-write, in the same transaction, so the record is real and consistent when you touch things that reference it. This is the home for parent-child work like the task-closing example, counters on related records, and anything that must be done before the user's response returns. That last part is also the cost: the user is still waiting. Which is the cue for the next timing.
Async: same idea as after, minus the waiting user. An async rule is queued as a scheduled job and runs when a worker picks it up, usually within seconds. Anything slow belongs here: outbound REST calls, notification-adjacent fan-out, recalculations across many records. The user's save takes the same half second whether your integration takes one second or thirty. The trade is looser guarantees: it runs later, ordering with other jobs is not promised, and previous is not available.
Display: the odd one out. Display rules do not react to a write at all. They run when a form is being prepared, and exist almost entirely to populate g_scratchpad, so client scripts get server data without a GlideAjax round trip. If a client script needs data that is knowable at load time, a display rule is the cheapest possible delivery.
Gotchas
In async rules, previous is null. A condition like previous.active that works in an after rule silently evaluates against nothing in async, and the rule just never does its work. If the decision depends on what changed, make it in a before or after rule and stamp the outcome on a field the async rule can read.
The other timing-adjacent trap is the condition. A business rule has a filter condition builder and a scripted condition field, and they are not equivalent in cost. The filter condition is evaluated cheaply before your script is ever loaded; a condition buried inside the script means the rule compiles and runs on every single insert or update on the table just to decide it has nothing to do. On a busy table like task, a hundred rules doing that is real overhead. Put the cheap checks, active is true, state changes to resolved, in the builder, and keep the script for actual work. Use current.isNewRecord(), changes(), and changesTo() inside the script only for what the builder cannot express.
And then there is setWorkflow(false). Inside a business rule, treat it as a smell. It suppresses other business rules, notifications, SLAs, and engines for the updates that follow, which means you are hiding cause from effect inside your own automation, and six months later nobody can explain why closing an incident stopped sending emails. Almost always it appears because rules are triggering each other in a loop, and the honest fix is tightening conditions so each rule fires only when it should. The legitimate home for setWorkflow(false) is one-off data fix scripts, where you pair it with autoSysFields(false) deliberately and document it. In a rule that runs forever, it is a confession.
One rule, one job
The rules that survive years of admins are small and honestly named. A rule called "Incident utils" that sets fields, closes tasks, and calls a webhook cannot be safely modified by anyone but its author, and after eighteen months, not even them. Split by job and name by contract: I use the pattern table, timing, and what it does, so the list view reads like documentation.
Incident: before update: derive assignment group from category
Incident: after update: close child tasks on resolve
Incident: async update: notify status webhook
Incident: display: load caller flags to scratchpad
When a before rule and an after rule both need to know that state just changed, do the comparison once in the before rule and stamp a field or scratch value. Every downstream rule reads the stamp. One comparison, one truth.
Variations worth knowing
Async on insert for enrichment. A pattern I use constantly: a before rule stamps defaults, and an async rule enriches the record from an external system a few seconds later. Users get an instant save, the record fills itself in while nobody is watching.
Order matters within a timing. Rules in the same timing run by their order value, lowest first. If rule B depends on a field rule A sets, give them explicit orders and comment why. Relying on accidental ordering is a slow-motion outage.
Query rules. There is a fifth timing, query, which appends conditions before a table is read. It is the mechanism behind a lot of row-level visibility filtering. Different job entirely, but worth knowing it exists so you recognize it when data seems to vanish from lists.
Flows as the alternative. For after-and-async style logic that process owners need to see and modify, Flow Designer is often the better home these days. My split: field derivation stays in before rules, visible business process moves to flows, and anything already working and documented stays where it is.