The Dataverse plugin execution pipeline has four stages that look interchangeable the first time you see them: pre-validation, pre-operation, post-operation, and post-operation-asynchronous. Tutorials show examples at each stage without explaining why this stage rather than the next.
The reasons become obvious when you hit the bugs. Over the past year we fixed four production issues that each came down to "plugin registered at the wrong stage." Here they are, with the corrected stage and the rule that generalizes each fix.
The four stages, briefly
- Stage 10 - Pre-validation: before the platform applies security checks. Useful for read-replacement scenarios; rarely the right stage for business logic.
- Stage 20 - Pre-operation: inside the database transaction, before the actual write. Can modify the Target entity, block the operation by throwing, or perform related writes that must commit with the main write.
- Stage 40 - Post-operation (synchronous): inside the database transaction, after the write. Has access to the written row's server-assigned values (GUID, createdOn). Can still roll back the transaction by throwing.
- Stage 40 - Post-operation (asynchronous): outside the database transaction, queued for later execution by the async service. Cannot influence the main operation; runs eventually.
Bug 1: the rollup that blamed the wrong child
Plugin registered: post-operation asynchronous on Order create. Purpose: recalculate the parent Account's "total active orders" rollup.
Symptom: an order is created; a user immediately refreshes the account; the total is unchanged. Ten seconds later, a different user refreshes; the total is now correct.
Root cause: the plugin was async. The write transaction committed, the UI returned, the user refreshed - all before the async plugin dequeued the rollup recalculation request. The 10-second gap was the async service's queue lag.
Fix: move to post-operation synchronous. The rollup update completes inside the original transaction; by the time the UI returns, the parent account is already updated.
Rule: when the user's next action depends on seeing the result, post-operation synchronous is required. Async is for side effects the user doesn't wait for (emails, external notifications).
Bug 2: the GUID that was sometimes null
Plugin registered: pre-operation on Order create. Purpose: log an audit entry with the Order's ID.
Symptom: the audit log entries sometimes had null in the order_id column. Other times they had the correct GUID. No clear pattern.
Root cause: pre-operation runs before the write. For new rows, the server-assigned GUID is not yet set (it will be assigned during the write). Reading Target.Id at pre-operation returns Guid.Empty for new rows unless the caller assigned an ID before calling Create - which most callers did not.
The GUIDs that were correct came from bulk integration jobs that pre-assigned IDs client-side. The null ones came from the UI, which let the server assign.
Fix: move the plugin to post-operation. By this stage, Target.Id is the real GUID, populated by the platform during the write.
Rule: if you need the server-assigned GUID, createdOn, or any auto-populated field, post-operation is the earliest safe stage.
Bug 3: the cascading plugin that ate itself
Plugin registered: post-operation synchronous on Contact update. Purpose: when a contact's email changes, update the linked opportunities to cache the new email.
Symptom: a contact email change triggered an update to each linked opportunity. Each opportunity update triggered other plugins on opportunity. One of those plugins, on certain conditions, wrote back to the contact. Which triggered the email-change plugin again. Which triggered opportunity updates. Infinite loop, until the platform's recursion detection kicked in (default depth 8) and killed the request with a "plugin recursion limit exceeded" error.
Root cause: synchronous plugins in a chain each participate in the same transaction. Recursion detection exists precisely to prevent this, but it only triggers after you've burned 8 levels of stack - which for a complex scenario can mean dozens of database writes that then roll back.
Fix: move the contact email sync to post-operation asynchronous. The async service runs each step independently and has its own recursion detection that is more forgiving (or at least, detects cycles at the plugin-step level rather than stack-depth).
Rule: plugins that chain-trigger other plugins should be async unless the chain needs to be transactionally atomic. Sync chaining is fragile.
Bug 4: the validation that ran too late
Plugin registered: post-operation synchronous on Contract create. Purpose: validate that the contract dates don't overlap with existing contracts for the same customer; throw to reject if they do.
Symptom: the validation worked correctly - duplicates were rejected. But the rejection rolled back the entire transaction, including unrelated pre-operation and post-operation work that had already run. Users saw a validation error but their supporting data (log entries, notifications initiated in post-operation) had also disappeared.
Root cause: post-operation plugins can roll back the transaction by throwing, but they roll back everything - pre-operation work, post-operation work, the main write. For validation that should reject the operation without side effects, post-operation is too late.
Fix: move to pre-operation. The plugin throws before any other work happens; the transaction rolls back cleanly with nothing else touched.
Rule: if the purpose of the plugin is to validate and potentially reject, register it pre-operation. Post-operation rollback works but corrupts side effects.
The simple stage-selection checklist
When registering a new plugin, we ask four questions:
- Does it validate and potentially reject? → Pre-operation.
- Does it modify the Target row? → Pre-operation (so the modifications land with the write).
- Does it need the server-assigned GUID or related fields? → Post-operation synchronous.
- Is it a side effect the user doesn't wait for? → Post-operation asynchronous.
If two of these apply, the priority order is: validate first, modify second, access-GUID third, async for side effects last.
The one meta-rule
Plugins in the wrong stage are not "bugs" exactly - they work until they encounter an edge case that exposes the stage's semantics. The four bugs above each ran fine for weeks in Dev before failing in UAT under realistic data.
The remedy is not to memorize the stage semantics. It is to register every plugin with a reason comment that explains why this stage:
When someone six months later touches the plugin, the reason tells them whether moving it to a different stage is safe. Without the reason, they guess. When they guess wrong, the fourth bug above is what they ship.