Production-shaped Dataverse / Dynamics 365 plugin patterns in C#: a reusable plugin base, business-unit-scoped security enforcement, parent rollup/sync, flag-driven field projection, and lifecycle stamping. Every plugin runs in the sandbox runtime (net462) and is exercised end to end with an in-memory Dataverse.
These are original implementations of patterns that recur in enterprise Dataverse
solutions, built from scratch against a fictional Contoso event-management app on the
evt_ publisher prefix - a generic events domain (events, budgets, partners, team
members). No proprietary code or client data. Built with an AI-assisted workflow that I designed, directed, and reviewed. A hygiene lint in the test suite keeps the
source portable and on the evt_ demo naming convention. Not affiliated with or
endorsed by Microsoft.
Most plugin code bases repeat the same ceremony in every class: fetch the services from the
provider, guard the message and entity, wrap everything in a try/catch that converts
failures into an InvalidPluginExecutionException, and reach into InputParameters and the
entity images by hand. That boilerplate buries the ten lines of logic that actually matter,
and it drifts between plugins.
This catalog factors the ceremony into a PluginBase + PluginContext, and pulls the
decision logic out of the pipeline plumbing so it can be unit-tested as pure functions.
The security rule is the clearest example: role resolution, the access-level ladder, and the
allow/deny check are three separate, dependency-free pieces, each tested in isolation, with
the IPlugin doing nothing but wiring.
src/
Framework/
PluginBase.cs Abstract base: service resolution, tracing, exception funnel
PluginContext.cs Convenience view over the pipeline (Target, images, services)
EntityExtensions.cs Null- and type-safe attribute accessors
Schema/
EventSchema.cs Central logical-name / role / access-level constants
Security/
AccessLevel.cs The access-level ladder (None < Basic < Local < Global)
AccessLevelPolicy.cs PURE: roles -> level, and (level, BUs, owner) -> allow/deny
RoleResolver.cs Direct + team-inherited role names (two-source join)
EnforceAppendToScopePlugin.cs
Rollup/
EventSummaryBuilder.cs Rollup composition + "update only on change" loop guard
RebuildSummaryPlugin.cs Rebuild when the event itself changes
SyncToParentPlugin.cs Base + two child-entity subclasses (was N copy-pasted plugins)
Projection/
ProjectTeamRolesPlugin.cs Flag-driven owner projection with flip detection
Lifecycle/
StampStatusChangePlugin.cs Option-set change detection via the pre-image
tests/
Pure/ Fast tests over the policy / builder / query code (no fake)
Plugins/ End-to-end plugin execution on an in-memory Dataverse
Infrastructure/ Hand-built service provider + recording service
| Area | Pattern | What it demonstrates |
|---|---|---|
| Security | EnforceAppendToScopePlugin |
Enforces true per-relationship write scope via a business-unit-scoped AppendTo ladder (Global/Local/Basic) - the correct fix when one table-wide AppendTo privilege must serve two conflicting needs (org-wide linking vs. per-relationship isolation) that role config alone cannot separate. Centralizes the rule in one auditable place and returns a clear denial message. Reads run as SYSTEM so the check is not blocked by the restriction it enforces. |
RoleResolver |
Resolves direct + team-inherited roles. Querying only systemuserroles is a classic bug: it works for admins (usually direct-assigned) but silently misses team-inherited roles, so a grant-based scope check falsely denies everyone whose access comes through a team. |
|
AccessLevelPolicy |
The whole security decision as pure functions, so every branch (Global/Local/Basic, missing BU, missing owner, unknown level) is unit-tested without a Dataverse connection. | |
| Rollup / Sync | EventSummaryBuilder |
Builds an event's denormalized summary line and writes it back only when it changed - the guard that stops the write from re-triggering the plugin (the plugin infinite-loop trap). |
SyncToParentPlugin |
One base captures the shared Create/Update/Delete + Target/pre-image extraction; each child entity is a two-property subclass. Replaces a set of near-identical copy-pasted plugins. | |
RebuildSummaryPlugin |
Skips an update that carries only the summary field, closing the self-trigger loop directly. | |
| Projection | ProjectTeamRolesPlugin |
Projects boolean leadership flags onto denormalized owner lookups; clears a field only when its flag genuinely flipped off (decided from the pre-image), computed by overlaying the Target delta onto the pre-image. |
| Lifecycle | StampStatusChangePlugin |
Stamps a timestamp only when an option-set actually changes; writes into the Target during PreOperation to avoid a second round-trip. |
Two tiers, both on net462 so the plugins run under the same runtime Dataverse uses:
- Pure tests over the policy, builder, extension, and query-builder code - no fake, no I/O, exhaustive over the branches.
- Plugin tests execute each plugin end to end against an in-memory Dataverse
(FakeXrmEasy for the store) through
a hand-built service provider, so the test controls the exact message, images, and acting
user the sandbox would supply. A recording service captures the writes a plugin issues, so
a "field cleared" assertion checks the actual Update request rather than depending on how
the fake represents an absent value. The role resolver's team-inherited join is proved by
seeding the real
systemuserroles/teamroles/teammembershipintersect rows. - A hygiene lint (
HygieneGateTests) runs in the same suite and fails the build on any publisher prefix other than the repo's ownevt_demo convention (scanned inside string/backtick literals), plus any dash glyph or non-ASCII character in C# source. It is deliberately content-agnostic - a positive allowlist of the one intended prefix rather than a denylist, so the rule cannot drift as the schema grows.
Where the fake differs from a real org, tests are scoped honestly: an in-memory Retrieve
does not synthesize a lookup's display name from the target's primary field or surface
FormattedValues from option-set metadata, so name/label rendering is asserted at the pure
EventSummaryBuilder layer rather than claimed through the fake.
61 tests. FakeXrmEasy is pinned to its 1.x line, which is the free/open tier (2.x and 3.x require a registered license key); its own end-of-life deprecation notice is silenced in the test project on purpose.
dotnet test # build + run the full test suite (includes the hygiene gate)
dotnet build -c ReleaseThis repo builds unsigned so it compiles on any machine with a modern .NET SDK (the
net462 reference assemblies come from a NuGet package - no Framework targeting pack needed).
Registering a plugin assembly in Dataverse requires a strong name: generate a key
(sn -k key.snk) and turn on SignAssembly / AssemblyOriginatorKeyFile at deploy time.
Each plugin's XML doc comment states its intended registration (message, stage, mode, images,
filtering attributes).
MIT. See LICENSE.