Skip to content

mikhail-traitel/dataverse-plugin-patterns

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Dataverse Plugin Patterns

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.

Why it is shaped this way

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.

Architecture

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

Pattern catalog

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.

Testing

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 / teammembership intersect rows.
  • A hygiene lint (HygieneGateTests) runs in the same suite and fails the build on any publisher prefix other than the repo's own evt_ 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 Release

Deploying (note)

This 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).

License

MIT. See LICENSE.

About

C# Dataverse plugin patterns: security enforcement, rollup, projection, lifecycle - 61 xUnit tests on an in-memory Dataverse. Fictional Contoso events schema, evt_ prefix.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages