From powerplatform-bestpractices
Best practices for Dataverse plug-ins and Custom APIs. Use when the user is writing, scaffolding (pac plugin init), registering, or debugging a Dataverse plug-in (IPlugin, PluginBase, pre/post-image, sync/async steps) or a Custom API (bound/unbound, request/response parameters). Also use when the user asks about early-bound classes, pac modelbuilder, pac plugin push, ColumnSet, or InvalidPluginExecutionException.
How this skill is triggered — by the user, by Claude, or both
Slash command
/powerplatform-bestpractices:ppbp-dv-pluginsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
No official Microsoft skill exists for this topic. Dataverse plug-in authoring (IPlugin, Custom APIs, PAC CLI plugin workflow) is not covered by any official skill in the `dataverse:*` or `code-apps-preview:*` namespaces.
No official Microsoft skill exists for this topic. Dataverse plug-in authoring (IPlugin, Custom APIs, PAC CLI plugin workflow) is not covered by any official skill in the dataverse:* or code-apps-preview:* namespaces.
This skill owns the following paths in the canonical repository layout (see ppbp-overview):
<repo-root>/
└── plugins/
└── <plugin-name>/ # One subfolder per Dataverse Plugin project (scaffolded by pac plugin init)
├── <PluginName>.csproj
├── <PluginName>.snk
├── PluginBase.cs
└── Models/ # Early-bound classes generated by pac modelbuilder build
Each plugin lives in its own subfolder. Never place .csproj files directly under plugins/.
pac plugin init — never create plugin projects by hand; it generates PluginBase.cs, the strong-name key (.snk), and the correct .csproj NuGet package configuration.PluginBase (generated by pac plugin init) and override ExecuteDataversePlugin — this base class standardises context resolution and exception wrapping. Never implement IPlugin.Execute directly in a pac-init project.pac modelbuilder build — never use late binding (entity["attribute"]); it bypasses compile-time safety and column security.Retrieve calls — never use ColumnSet(true); it ignores column security and wastes bandwidth.IOrganizationService once per execution from serviceFactory.CreateOrganizationService(context.UserId) so the call runs under the triggering user's security context.FullyQualifiedWebApiName on Custom API request/response parameters — it controls how they appear in the Web API and Power Automate.InvalidPluginExecutionException (not generic exceptions) to surface user-facing errors; use OperationStatus.Failed only for business rule failures.AssemblyVersion and FileVersion in .csproj before every deployment — Dataverse uses the version to detect assembly changes; deploying without a version bump can silently skip the update or cause deployment conflicts.pac plugin init --outputDirectory <ProjectName> --author "<Author>"
Generates: PluginBase.cs, <ProjectName>.snk, <ProjectName>.csproj, sample plugin file.
After init: set <RootNamespace>, update NuGet metadata (Company, Description), leave <PluginId> empty until first deployment.
pac modelbuilder build \
--outdirectory <ProjectName>/Models \
--namespace <Company>.<Product>.Models \
--environment https://<org>.crm.dynamics.com/ \
--emitfieldsclasses \
--generateGlobalOptionSets \
--entitynamesfilter "table1;table2;table3"
Do not add --generatesdkmessages — it floods the project with SDK message classes that are not needed.
In .csproj, bump both fields:
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
Use Major.Minor.Patch.Build: Patch for bug fixes, Minor for new features, Major for breaking changes.
dotnet clean && dotnet build -c Release
dotnet clean is mandatory — without it the NuGet package is not regenerated and the version bump is silently ignored.
Output: bin/Release/net462/<ProjectName>.dll and bin/Release/<ProjectName>.<Version>.nupkg.
First deployment — pac plugin push requires an existing plugin GUID. Register the assembly once via the Plugin Registration Tool, then store the returned GUID in .csproj:
<PluginId>xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</PluginId>
Subsequent deployments — read the GUID from .csproj and push:
PLUGIN_ID=$(dotnet msbuild <ProjectName>.csproj -getProperty:PluginId -nologo)
pac plugin push --pluginId "$PLUGIN_ID" --pluginFile ./bin/Release/net462/<ProjectName>.dll
Never use --solution-name — it creates a solution dependency and breaks across environment boundaries.
Skipping dotnet clean before a release build silently deploys the old assembly even when .csproj has been updated with a new version number — the NuGet package is not regenerated. Always run dotnet clean && dotnet build -c Release.
pac plugin push --pluginId requires a GUID that only exists after the assembly has been registered at least once. There is no CLI-only path for the initial registration — the Plugin Registration Tool (available via NuGet: Microsoft.CrmSdk.XrmTooling.PluginRegistrationTool) must be used once to create the record and obtain the GUID, which is then stored in <PluginId> in .csproj for all subsequent deployments.
| Anti-pattern | Correct approach |
|---|---|
| Storing state in instance fields — Dataverse reuses plug-in instances across calls; instance fields accumulate state from previous executions. | Declare all working variables inside ExecuteDataversePlugin(). |
Calling the organisation service in PreValidation — The transaction has not started; service calls create a nested transaction and can cause deadlocks. | Move data access to PreOperation or PostOperation. |
| Missing plug-in registration update after schema changes — If a step uses an explicit image with a fixed column set, new columns are silently excluded. | Update the image's attribute list in the Plugin Registration Tool after every schema change that affects step images. |
| Unbound Custom API used for record-scoped operations (or bound for global operations) — Choosing the wrong type breaks the Web API path. | Unbound for global operations, bound for record-scoped operations. |
| Catching all exceptions silently — Swallows failures in asynchronous steps where no UI feedback exists. | Log to the Dataverse trace log (ITracingService) and re-throw, or throw InvalidPluginExecutionException. |
Deploying with --solution-name — Creates a solution dependency, is slower, and breaks across environment boundaries. | Store the plugin GUID in <PluginId> in .csproj and use pac plugin push --pluginId ... --pluginFile .... |
Skipping dotnet clean before a release build — The NuGet package is not regenerated; a version bump in .csproj is silently ignored. | Always run dotnet clean && dotnet build -c Release. |
| Resolving security roles by display name across Business Units — Every BU has its own copy of each role with the same display name; querying by name returns all copies with no deterministic result. | Query by ParentRootRoleId (the root role's GUID) filtered to the target BU — see example below. |
Security role resolution — Wrong vs Correct:
| Wrong | Correct | |
|---|---|---|
| Query filter | name == "My Role" | parentrootroleid == rootRoleId AND businessunitid == targetBuId |
// Wrong — returns every BU's copy of the role
var wrong = service.RetrieveMultiple(new QueryExpression("role")
{
ColumnSet = new ColumnSet("roleid"),
Criteria = { Conditions = { new ConditionExpression("name", ConditionOperator.Equal, roleName) } }
});
// Correct — returns exactly the BU-specific instance
var correct = service.RetrieveMultiple(new QueryExpression("role")
{
ColumnSet = new ColumnSet("roleid"),
Criteria =
{
Conditions =
{
new ConditionExpression("parentrootroleid", ConditionOperator.Equal, rootRoleId),
new ConditionExpression("businessunitid", ConditionOperator.Equal, targetBuId)
}
}
});
var roleId = correct.Entities.Single().Id;
This skill covers Dataverse plug-ins and Custom APIs only. It does not cover:
ppbp-dv-metadatappbp-almppbp-code-apps / ppbp-generative-pagesnpx claudepluginhub tchinnin/powerplatform-bestpractices-skillsSearches MemPalace before answering questions about past work, people, projects, or prior decisions. Returns verbatim stored content instead of guessing from model memory.
Guides Payload CMS config (payload.config.ts), collections, fields, hooks, access control, APIs. Debugs validation errors, security, relationships, queries, transactions, hook behavior.
Implements vector databases with Pinecone, Weaviate, Qdrant, Milvus, pgvector for semantic search, RAG, recommendations, and similarity systems. Optimizes embeddings, indexing, and hybrid search.