Patterns & Pitfalls¶
A running list of project-tested guidance that doesn't fit cleanly under Project Setup, Plugin Packages, or Registration Attributes on their own.
Plugins are stateless — let Executor handle it¶
DGT-SRV-130 — Executor.Execute(IServiceProvider) clones itself before
invoking your Execute() override, so per-invocation state never leaks across calls to the same registered
step even though Dataverse may reuse the plugin type instance. You still need to avoid storing
mutable state in static fields — the clone-per-call only protects instance fields.
No batch requests inside plugins¶
DGT-SRV-140 — Don't use ExecuteMultipleRequest or
ExecuteTransactionRequest inside a plugin. The plugin already runs inside the pipeline
transaction — batching gains nothing there and only extends how long the transaction is held
open. Batch APIs are for external clients and integrations, not for sandbox code. See
Microsoft's business-logic best practices.
No custom threading inside plugins¶
DGT-SRV-150 — Don't spawn threads or use Parallel.*/Task.Run inside a
plugin — parallel execution in sandbox code is not supported
and produces failures that don't reproduce locally. If work is too slow for the synchronous
pipeline, it belongs in an asynchronous step or outside Dataverse entirely (see
Cloud & Integration), not on a background thread.
Synchronous steps on reads are exceptional¶
DGT-SRV-160 — Register synchronous steps on Retrieve or
RetrieveMultiple only in justified exceptions. Such a step runs on every read of the
table — every view, every subgrid, every lookup — and its latency is added to all of them.
Custom data providers for virtual tables are the intended mechanism
for shaping reads; a RetrieveMultiple plugin is almost always the wrong layer.
Don't use context.Depth as loop control¶
DGT-SRV-170 — Don't write if (context.Depth > 1) return; to "prevent
loops" — it also silently disables your plugin for every legitimate cascade, workflow, or other
plugin that triggers it. Prevent update loops with
FilterAttributes and precise
registration instead; the platform's infinite-loop protection is a safety net, not a design
element. See
Microsoft's guidance on the execution context.
Query only the columns you need¶
DGT-SRV-180 — Every query states an explicit ColumnSet with the columns
the code actually reads — never new ColumnSet(true) / AllColumns. Wide queries burn
sandbox worker memory,
transfer data you throw away, and are a
query-throttling
risk on large tables.
Prefer ExecutionResult.Skipped over silent no-ops¶
When a plugin determines early on that there's nothing to do (e.g. the triggering attribute
didn't actually change in a way that matters), return ExecutionResult.Skipped rather than
just falling through. This is directly assertable in
unit tests and makes traced logs distinguish "ran
and did nothing" from "ran and succeeded."
Use InvalidPluginExecutionException deliberately for user-facing messages¶
DGT-SRV-190 — Executor treats an InvalidPluginExecutionException with Status = OperationStatus.Succeeded
as a non-error result (ExecutionResult.Ok) before re-throwing — this is the supported pattern
for surfacing a validation message to the user without it being logged/traced as a failure. Use
it for that purpose specifically, not as a general-purpose control-flow exception.
Filter attributes instead of re-checking inside the plugin¶
DGT-SRV-200 — Use [PluginRegistration(... FilterAttributes = new[] { Account.LogicalNames.Name })]
rather than registering an unfiltered step and checking if (target.Contains(Account.LogicalNames.Name)) at the
top of Execute(). The
filtered registration means the step doesn't fire — and doesn't consume a pipeline execution —
when an unrelated field changes, which matters for both performance and for keeping plugin
trace logs free of no-op runs.
Prefer formula and calculated fields over rollups where equivalent¶
Where a value can be expressed as a formula or calculated column, prefer that over a rollup
field — rollups carry an asynchronous recalculation job and tighter limits per environment,
and are harder to reason about in a managed-solution context (see
Naming Conventions for the IsCustomizable = false
recommendation on these). Reach for a rollup only when the aggregation genuinely needs it
(e.g. cross-record aggregation a formula column can't express).
Don't bypass dgtp push registration with manual changes¶
If you find yourself about to add or edit a step in the Plugin Registration Tool directly,
stop — see Registration Attributes. The
fix for "the tool doesn't let me express X declaratively yet" is to raise it against
Digitall.Plugins.Registration, not to special-case that one step manually outside source
control.
Don't hand-roll pre-image diffing¶
Use IsEntityAttributeValueChanged<T>/IsEntityAttributeValueNew/GetEntityAttributeValue<T>
instead of writing your own target.Contains(...)/preImage.Contains(...)/null-check chain to
answer "did this field actually change" — it's easy to get the "new but no pre-image" and
"unchanged empty string vs. null" edge cases subtly wrong by hand, and these are already
covered.
Keep dependent-plugin shared models nullable-disabled¶
DGT-SRV-210 — If multiple plugin packages share a generated early-bound model (rather than each
generating its own), keep SuppressNullableSupport: true (see
Early-Bound Models) consistent across every project consuming that shared
model — a mismatch here is a common source of confusing compiler warnings/errors that look
unrelated to the actual change that introduced them.