Plugin Registration Tool and Step Deployment
1. Conceptual Foundation
The Microsoft Dataverse Event Framework is the core engine that enables developers to intercept platform events and inject custom business logic. When a data operation occurs in Dataverse—such as creating a record, updating an attribute, or deleting a row—the platform processes the request through a highly structured execution pipeline. Developers can register custom code, known as plug-ins, to execute at specific points within this pipeline.
The Dataverse Event Pipeline and Database Transactions
The Dataverse execution pipeline is divided into four distinct stages, three of which are accessible to custom plug-ins:
- Pre-Validation (Stage 10): This stage executes before any database transaction starts and before security validation checks are performed. It is primarily used for basic validation logic that does not require database locks. Because it runs outside the main database transaction, throwing an exception here avoids the performance overhead of rolling back a transaction. However, if a Pre-Validation step is triggered by an operation that is already inside an active transaction (such as a child operation initiated by a Post-Operation plug-in), it will participate in that existing transaction.
- Pre-Operation (Stage 20): This stage executes within the database transaction and before the main database operation (the SQL
INSERT,UPDATE, orDELETEstatement) is executed. It is the ideal stage for modifying the attributes of the target record before they are committed to the database. Any changes made to the target entity in this stage are automatically included in the database write without requiring an explicitUpdateservice call. - Main Operation (Stage 30): This stage is reserved for internal platform use. It executes the core database operation. Custom plug-ins cannot be registered in this stage.
- Post-Operation (Stage 40): This stage executes within the database transaction but after the core database operation has completed. It is used to perform actions on related records, call external web services, or modify output parameters. If a plug-in registered in this stage modifies the primary entity, it must issue an explicit
Updatecall, which will trigger a new execution pipeline.
+---------------------------------------------------------------------------------+
| Dataverse Event Pipeline |
+---------------------------------------------------------------------------------+
| [Stage 10: Pre-Validation] |
| - Runs outside database transaction (typically) |
| - Ideal for input validation and lightweight checks |
+---------------------------------------------------------------------------------+
|
v
+---------------------------------------------------------------------------------+
| ====================== Database Transaction Boundary ======================= |
| |
| [Stage 20: Pre-Operation] |
| - Runs inside database transaction |
| - Ideal for modifying target record attributes before commit |
| |
| [Stage 30: Main Operation] |
| - Platform core execution (SQL INSERT/UPDATE/DELETE) |
| - Not customizable |
| |
| [Stage 40: Post-Operation] |
| - Runs inside database transaction |
| - Ideal for child record operations and external integrations |
| - Supports Asynchronous execution (runs outside transaction via Async Queue) |
| |
| ============================================================================= |
+---------------------------------------------------------------------------------+
The Dataverse Plug-in Data Model
Plug-in registrations are represented as physical records within Dataverse system tables. Understanding this underlying data model is critical for advanced deployment and Application Lifecycle Management (ALM):
- PluginAssembly (
pluginassembly): Represents the compiled .NET assembly file. The binary content of the assembly is stored as a base64-encoded string in thecontentcolumn of this table. It also stores metadata such as the assembly name, version, public key token, culture, and isolation mode. - PluginType (
plugintype): Represents a specific class within the assembly that implements theIPlugininterface. A single assembly can contain multiple plug-in types. This table links back to the parentPluginAssemblyvia thepluginassemblyidlookup. - SdkMessageProcessingStep (
sdkmessageprocessingstep): Represents the registration of a plug-in type to execute on a specific message (e.g.,Create), primary entity (e.g.,account), and pipeline stage (e.g.,PostOperation). It defines the execution mode (synchronous or asynchronous), execution order (rank), and links to the unsecure and secure configurations. - SdkMessageProcessingStepImage (
sdkmessageprocessingstepimage): Represents pre-images or post-images of the target record. Images capture the state of the record's attributes before or after the core database operation, allowing plug-ins to compare old and new values without executing expensiveRetrieverequests. - SdkMessageProcessingStepSecureConfig (
sdkmessageprocessingstepsecureconfig): Stores sensitive configuration data passed to the plug-in constructor. This table is isolated from standard customization transport and is readable only by System Administrators.
Secure vs. Unsecure Configurations
When registering an SdkMessageProcessingStep, developers can provide two configuration strings: unsecure and secure.
- Unsecure Configuration: Stored directly in the
configurationcolumn of theSdkMessageProcessingSteptable. This string is included when exporting the step as part of a solution. It is visible to any user with read access to the customization metadata. It is ideal for non-sensitive parameters such as threshold values, business unit mappings, or feature flags. - Secure Configuration: Stored in the
secureconfigcolumn of theSdkMessageProcessingStepSecureConfigtable. This data is stored in a separate table with restricted access. Non-administrative users and standard service principals cannot read this table. Crucially, secure configurations are not included in exported solutions. They must be deployed and configured per environment using automated deployment scripts, API calls, or manual entry via the Plugin Registration Tool. Secure configurations are designed for sensitive data such as API keys, connection strings, and service credentials.
Sandbox Isolation Mode
All plug-ins deployed to Microsoft Dataverse (SaaS) must run in Sandbox Isolation Mode. This is a highly secure, restricted execution environment managed by the Dataverse Sandbox Service.
Architecturally, the Sandbox Service executes plug-ins within isolated worker processes (Microsoft.Crm.Sandbox.WorkerProcess.exe) on dedicated servers. This isolation prevents custom code from accessing local server resources, modifying system registries, or interfering with other tenant processes. The sandbox enforces strict security boundaries:
- No Local File System Access: Plug-ins cannot read or write to the local disk.
- No Registry Access: Access to the Windows Registry is completely blocked.
- Restricted Network Access: Outbound network calls are restricted to standard HTTP and HTTPS protocols (ports 80 and 443). Connections must be established using DNS names; direct IP address routing is blocked.
- No GAC Deployment: Assemblies cannot be registered in the Global Assembly Cache (GAC). They must be stored directly in the Dataverse database.
2. Architecture & Decision Matrix
When designing custom business logic in the Power Platform, developers must choose the most appropriate extensibility mechanism. The table below compares the five primary approaches:
| Feature / Dimension | Dataverse Plug-ins (C#) | Power Automate Cloud Flows | Azure Functions | Low-Code Plug-ins (Power Fx) | Power Apps Component Framework (PCF) |
|---|---|---|---|---|---|
| Complexity | High (Requires C# expertise, strong naming, and SDK knowledge) | Low to Medium (Visual designer, low-code expressions) | Medium to High (Requires Azure development and API management) | Low (Uses Power Fx formulas directly in Dataverse) | High (Requires TypeScript, React, and web development skills) |
| Scalability | High (Executes directly on Dataverse web servers within platform limits) | Medium (Subject to API request limits and throttling) | Extremely High (Serverless scaling, independent of Dataverse resources) | High (Executes natively within the Dataverse engine) | Client-side (Scales with the user's device performance) |
| Execution Context | Native (Full access to execution pipeline, pre/post images, and transactions) | External (Triggered via webhooks, runs asynchronously outside transaction) | External (Triggered via webhooks or service bus, runs asynchronously) | Native (Executes within the Dataverse pipeline, supports pre/post operations) | Client-side (Executes within the context of the model-driven or canvas app UI) |
| Offline Support | No (Requires active connection to Dataverse database) | No (Requires cloud connectivity to execute triggers) | No (Requires cloud connectivity) | No (Requires active cloud connection) | Yes (Can run offline within Power Apps Mobile offline database context) |
| Licensing | Included in standard Power Apps/Dataverse licenses | May require Premium licenses depending on connectors used | Requires separate Azure subscription and consumption costs | Included in standard Power Apps/Dataverse licenses | Included in standard Power Apps/Dataverse licenses |
| PL-400 Exam Relevance | Critical (Primary focus of custom development questions) | High (Focus on integration, triggers, and run-time limits) | High (Focus on webhooks, service bus integration, and serverless offloading) | Medium (Emerging topic, focus on low-code extensibility) | Critical (Primary focus of client-side custom control questions) |
Architectural Decision Flowchart
To determine when to use a Dataverse Plug-in over other technologies, apply the following decision rules:
- Is synchronous execution required? If the business logic must block the user interface, validate data before it is written, or modify attributes before they are committed to the database, use a Dataverse Plug-in (or Low-Code Plug-in). Power Automate and Azure Functions are asynchronous and cannot run synchronously within the database transaction.
- Does the operation need to roll back on failure? If the operation must participate in the database transaction so that any failure rolls back all changes made across multiple tables, use a Dataverse Plug-in.
- Is it a long-running process? If the execution time exceeds 2 minutes, do not use a Plug-in. Dataverse enforces a hard 2-minute timeout on plug-ins. Instead, offload the process to an Azure Function or Power Automate Cloud Flow using an asynchronous pattern.
- Does it require heavy external integration or third-party libraries? If the logic depends on large NuGet packages, native C++ libraries, or complex external network protocols, use Azure Functions. Dataverse sandbox assemblies are limited in size and cannot load native libraries.
3. Step-by-Step Implementation Guide
This guide details the process of creating, registering, configuring, and deploying a custom C# plug-in assembly and step.
Step 1: Project Setup in Visual Studio
- Open Visual Studio and select Create a new project.
- Choose Class Library (.NET Framework) and click Next.
- Warning: You must target .NET Framework 4.6.2. Dataverse plug-ins do not support .NET Core, .NET 5/6/7/8, or standard .NET Standard libraries.
- Name the project
Enterprise.Dataverse.Pluginsand click Create. - Open the NuGet Package Manager Console and install the core Dataverse SDK assemblies:
Install-Package Microsoft.CrmSdk.CoreAssemblies -Version 9.0.2.56
- Right-click the project in Solution Explorer and select Properties.
- Navigate to the Signing tab.
- Check Sign the assembly.
- In the Choose a strong name key file dropdown, select New....
- Set the key file name to
EnterpriseKeyand uncheck Protect my key file with a password. Click OK. - Rename the default
Class1.csfile toAccountValidationPlugin.cs.
Step 2: Download the Plugin Registration Tool (PRT)
The Plugin Registration Tool is distributed via NuGet. The modern and supported method to download it is using the Power Platform CLI (PAC):
- Open a PowerShell terminal.
- Verify your PAC CLI installation:
pac install latest
- Download and launch the Plugin Registration Tool:
This command downloads the latest version of the PRT to your local file cache and launches the WPF user interface.pac tool prt
Step 3: Connect to the Dataverse Environment
- In the Plugin Registration Tool, click + Create New Connection.
- In the connection dialog:
- Select Office 365.
- Check Display list of available organizations.
- Check Show Advanced.
- Select the appropriate Region (e.g., Don't Know, North America).
- Enter your administrative credentials.
- Click Login.
- If prompted, select your target development environment from the list and click Login.
Step 4: Register the Assembly
- In the PRT menu, click Register and select Register New Assembly.
- In the Register New Assembly dialog:
- Under Step 1, click the ellipses (...) button and browse to the compiled DLL:
Enterprise.Dataverse.Plugins\bin\Debug\Enterprise.Dataverse.Plugins.dll - Under Step 2, ensure your assembly is checked.
- Under Step 3 (Isolation Mode), select Sandbox.
- Under Step 4 (Location), select Database.
- Under Step 1, click the ellipses (...) button and browse to the compiled DLL:
- Click Register Selected Plugins.
- A confirmation dialog will appear showing the number of registered plug-ins. Click OK.
+-----------------------------------------------------------------------------+
| Register New Assembly |
+-----------------------------------------------------------------------------+
| |
| 1. Select Assembly: [ C:\Dev\Enterprise.Plugins\bin\Debug\plugin.dll ] [...]|
| |
| 2. Select Plugins to Register: |
| [x] Enterprise.Dataverse.Plugins.AccountValidationPlugin |
| |
| 3. Isolation Mode: |
| ( ) None (x) Sandbox |
| |
| 4. Location: |
| (x) Database ( ) Disk ( ) GAC |
| |
| [ Register Selected Plugins ] [ Cancel ] |
+-----------------------------------------------------------------------------+
Step 5: Register the Message Processing Step
- In the PRT tree view, locate and expand the (Assembly) Enterprise.Dataverse.Plugins node.
- Right-click (Plugin) Enterprise.Dataverse.Plugins.AccountValidationPlugin and select Register New Step.
- In the Register New Step dialog, configure the properties:
- Message:
Update - Primary Entity:
account - Filtering Attributes: Click the ellipses, uncheck All Attributes, check the
nameandtelephone1attributes, and click OK. - Event Pipeline Stage of Execution:
PreOperation(Stage 20) - Execution Mode:
Synchronous - Execution Order (Rank):
1(Ensures this plug-in runs first if other plug-ins are registered on the same stage). - Deployment:
Server - Unsecure Configuration: Enter the following JSON string:
{ "MaxNameLength": 100, "RequirePhone": true }
- Secure Configuration: Enter the following sensitive configuration:
<secureSettings><apiKey>prod-sec-key-99281-x</apiKey></secureSettings>
- Message:
- Click Register New Step.
+-----------------------------------------------------------------------------+
| Register New Step |
+-----------------------------------------------------------------------------+
| |
| Message: [ Update ] |
| Primary Entity: [ account ] |
| Secondary Entity: [ ] |
| Filtering Attr: [ name, telephone1 ] |
| |
| Execution Order: [ 1 ] Stage: [ PreOperation (Stage 20) ] |
| Execution Mode: (x) Synchronous ( ) Asynchronous |
| |
| Unsecure Config: |
| [ { "MaxNameLength": 100, "RequirePhone": true } ] |
| |
| Secure Config: |
| [ <secureSettings><apiKey>prod-sec-key-99281-x</apiKey></secureSettings> ] |
| |
| [ Register New Step ] [ Cancel ] |
+-----------------------------------------------------------------------------+
Step 6: Register an Entity Image
- In the PRT tree view, right-click the newly created step
Enterprise.Dataverse.Plugins.AccountValidationPlugin: Update of accountand select Register New Image. - In the Register New Image dialog:
- Under Image Type, check Pre Image (This captures the state of the record before the update occurs).
- Name:
PreImage - Entity Alias:
PreImage - Parameters: Click the ellipses, select the
nameandtelephone1attributes, and click OK.
- Click Register Image.
4. Complete Code Reference
The following is a complete, production-grade, compilable C# plug-in class that implements the IPlugin interface. It demonstrates how to handle both secure and unsecure configurations, parse JSON and XML inputs, utilize the tracing service, and execute validation logic using pre-images.
//-----------------------------------------------------------------------
// <copyright file="AccountValidationPlugin.cs" company="Enterprise">
// Copyright (c) Enterprise. All rights reserved.
// </copyright>
// <summary>
// Validates Account entity updates. Demonstrates secure/unsecure configurations,
// pre-images, and robust error handling.
// </summary>
//-----------------------------------------------------------------------
namespace Enterprise.Dataverse.Plugins
{
using System;
using System.IO;
using System.Runtime.Serialization.Json;
using System.Text;
using System.Xml;
using Microsoft.Xrm.Sdk;
/// <summary>
/// Represents the unsecure configuration JSON structure.
/// </summary>
[System.Runtime.Serialization.DataContract]
public class UnsecureConfig
{
[System.Runtime.Serialization.DataMember]
public int MaxNameLength { get; set; }
[System.Runtime.Serialization.DataMember]
public bool RequirePhone { get; set; }
}
/// <summary>
/// Plug-in that validates account updates.
/// </summary>
public class AccountValidationPlugin : IPlugin
{
private readonly string unsecureConfigRaw;
private readonly string secureConfigRaw;
private readonly UnsecureConfig unsecureConfig;
private readonly string secureApiKey;
/// <summary>
/// Initializes a new instance of the <see cref="AccountValidationPlugin"/> class.
/// </summary>
/// <param name="unsecure">The unsecure configuration string.</param>
/// <param name="secure">The secure configuration string.</param>
public AccountValidationPlugin(string unsecure, string secure)
{
this.unsecureConfigRaw = unsecure;
this.secureConfigRaw = secure;
// Parse unsecure JSON configuration if present
if (!string.IsNullOrWhiteSpace(this.unsecureConfigRaw))
{
try
{
using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(this.unsecureConfigRaw)))
{
var serializer = new DataContractJsonSerializer(typeof(UnsecureConfig));
this.unsecureConfig = (UnsecureConfig)serializer.ReadObject(ms);
}
}
catch (Exception ex)
{
// Fallback to defaults if parsing fails
this.unsecureConfig = new UnsecureConfig { MaxNameLength = 100, RequirePhone = false };
}
}
else
{
this.unsecureConfig = new UnsecureConfig { MaxNameLength = 100, RequirePhone = false };
}
// Parse secure XML configuration if present
if (!string.IsNullOrWhiteSpace(this.secureConfigRaw))
{
try
{
var xmlDoc = new XmlDocument();
xmlDoc.LoadXml(this.secureConfigRaw);
var apiKeyNode = xmlDoc.SelectSingleNode("//apiKey");
if (apiKeyNode != null)
{
this.secureApiKey = apiKeyNode.InnerText;
}
}
catch (Exception)
{
this.secureApiKey = string.Empty;
}
}
}
/// <summary>
/// Executes the plug-in logic.
/// </summary>
/// <param name="serviceProvider">The service provider.</param>
public void Execute(IServiceProvider serviceProvider)
{
if (serviceProvider == null)
{
throw new ArgumentNullException(nameof(serviceProvider));
}
// Obtain the execution context
var context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
// Obtain the tracing service
var tracingService = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
tracingService.Trace("AccountValidationPlugin: Execution started.");
// Verify the execution context properties
if (context.InputParameters.Contains("Target") && context.InputParameters["Target"] is Entity)
{
var targetEntity = (Entity)context.InputParameters["Target"];
// Verify the entity logical name matches the expected target
if (targetEntity.LogicalName != "account")
{
tracingService.Trace("AccountValidationPlugin: Target entity is not account. Exiting.");
return;
}
// Retrieve the Pre-Image registered with the alias "PreImage"
Entity preImageEntity = null;
if (context.PreEntityImages.Contains("PreImage"))
{
preImageEntity = context.PreEntityImages["PreImage"];
tracingService.Trace("AccountValidationPlugin: PreImage successfully retrieved.");
}
try
{
// Rule 1: Validate Account Name Length
if (targetEntity.Contains("name"))
{
string newName = targetEntity.GetAttributeValue<string>("name");
tracingService.Trace($"AccountValidationPlugin: Validating name length. New Name: {newName}");
if (!string.IsNullOrEmpty(newName) && newName.Length > this.unsecureConfig.MaxNameLength)
{
throw new InvalidPluginExecutionException(
OperationStatus.Failed,
(int)PluginErrorCodes.NameTooLong,
$"The account name cannot exceed {this.unsecureConfig.MaxNameLength} characters.");
}
}
// Rule 2: Validate Telephone Requirement
if (this.unsecureConfig.RequirePhone)
{
tracingService.Trace("AccountValidationPlugin: Phone number requirement check enabled.");
string currentPhone = null;
// Check if target contains the phone number (it is being updated)
if (targetEntity.Contains("telephone1"))
{
currentPhone = targetEntity.GetAttributeValue<string>("telephone1");
}
// Otherwise, fall back to the pre-image value
else if (preImageEntity != null && preImageEntity.Contains("telephone1"))
{
currentPhone = preImageEntity.GetAttributeValue<string>("telephone1");
}
if (string.IsNullOrWhiteSpace(currentPhone))
{
throw new InvalidPluginExecutionException(
OperationStatus.Failed,
(int)PluginErrorCodes.PhoneRequired,
"A primary telephone number is required for this account.");
}
}
// Demonstrate usage of secure configuration API key
if (!string.IsNullOrEmpty(this.secureApiKey))
{
tracingService.Trace("AccountValidationPlugin: Secure API Key is configured. Processing external validation token.");
// Secure API key would be used here for outbound service calls
}
}
catch (InvalidPluginExecutionException)
{
throw;
}
catch (Exception ex)
{
tracingService.Trace($"AccountValidationPlugin: Unexpected exception occurred: {ex}");
throw new InvalidPluginExecutionException(
OperationStatus.Failed,
(int)PluginErrorCodes.UnexpectedError,
"An unexpected error occurred during account validation.",
ex);
}
}
tracingService.Trace("AccountValidationPlugin: Execution completed successfully.");
}
}
/// <summary>
/// Custom error codes for the plug-in.
/// </summary>
public enum PluginErrorCodes
{
UnexpectedError = 200001,
NameTooLong = 200002,
PhoneRequired = 200003
}
}
Solution Customizations XML Representation
When a plug-in assembly and its steps are added to an unmanaged solution and exported, they are defined in the customizations.xml file. The following XML snippet shows the exact schema representation of the PluginAssembly and SdkMessageProcessingStep elements:
<?xml version="1.0" encoding="utf-8"?>
<ImportExportXml xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<PluginAssemblies>
<PluginAssembly AssemblyName="Enterprise.Dataverse.Plugins" LatestVersionDependencyAllowed="false" PublicKeyToken="a1b2c3d4e5f6g7h8" Version="1.0.0.0" IsolationMode="2" SourceType="0" Description="Enterprise Dataverse Plugin Assembly">
<PluginTypes>
<PluginType Decription="Validates Account entity updates." Name="Enterprise.Dataverse.Plugins.AccountValidationPlugin" TypeName="Enterprise.Dataverse.Plugins.AccountValidationPlugin">
<FriendlyName>AccountValidationPlugin</FriendlyName>
</PluginType>
</PluginTypes>
</PluginAssembly>
</PluginAssemblies>
<SdkMessageProcessingSteps>
<SdkMessageProcessingStep Name="Enterprise.Dataverse.Plugins.AccountValidationPlugin: Update of account" SdkMessageProcessingStepId="{9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d}" Stage="20" Mode="0" Rank="1" InvocationSource="0" SupportedDeployment="0" StateCode="0" StatusCode="1">
<PluginTypeId Name="Enterprise.Dataverse.Plugins.AccountValidationPlugin" />
<SdkMessageId>Update</SdkMessageId>
<PrimaryEntityName>account</PrimaryEntityName>
<FilteringAttributes>name,telephone1</FilteringAttributes>
<Configuration>{"MaxNameLength": 100, "RequirePhone": true}</Configuration>
<Images>
<Image EntityAlias="PreImage" ImageType="0" Name="PreImage" Id="{8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d}">
<Attributes>name,telephone1</Attributes>
</Image>
</Images>
</SdkMessageProcessingStep>
</SdkMessageProcessingSteps>
</ImportExportXml>
5. Configuration & Environment Setup
Solution Publisher Prefix Rules
Every component created in a Dataverse environment is prefixed with the customization prefix of the solution's publisher. When deploying plug-ins, ensure that:
- The solution publisher prefix (e.g.,
ent_) is consistently applied to all custom tables, columns, and custom APIs. - The assembly name and namespaces do not strictly require the publisher prefix, but the Step Name and Friendly Name should contain the prefix or follow a strict corporate naming convention to prevent naming collisions in the target environment.
Environment Variables in Dataverse
Because secure configurations do not transport across environments, developers should use Dataverse Environment Variables to store non-sensitive configuration values that vary by environment (e.g., external service URLs).
Environment Variable Schema Definitions
Environment variables consist of two tables:
- Environment Variable Definition (
environmentvariabledefinition): Defines the schema, display name, data type (String, Number, Boolean, JSON, Secret), and default value. - Environment Variable Value (
environmentvariablevalue): Stores the environment-specific value. This record is excluded from managed solutions, allowing target environments to maintain their own values.
Reading Environment Variables in C# Plug-ins
To read an environment variable value within a plug-in, execute a query against the environmentvariabledefinition and environmentvariablevalue tables using the IOrganizationService:
public string GetEnvironmentVariable(IOrganizationService service, string schemaName)
{
string query = $@"
<fetch version='1.0' output-format='xml-platform' mapping='logical' no-lock='true'>
<entity name='environmentvariabledefinition'>
<attribute name='defaultvalue' />
<filter type='and'>
<condition attribute='schemaname' operator='eq' value='{schemaName}' />
</filter>
<link-entity name='environmentvariablevalue' from='environmentvariabledefinitionid' to='environmentvariabledefinitionid' link-type='outer' alias='val'>
<attribute name='value' />
</link-entity>
</entity>
</fetch>";
EntityCollection results = service.RetrieveMultiple(new FetchExpression(query));
if (results.Entities.Count > 0)
{
Entity definition = results.Entities[0];
// Check if there is an environment-specific value
if (definition.Contains("val.value") && definition["val.value"] is AliasedValue aliasedVal)
{
return aliasedVal.Value.ToString();
}
// Fallback to default value
if (definition.Contains("defaultvalue"))
{
return definition.GetAttributeValue<string>("defaultvalue");
}
}
return null;
}
6. Security & Permission Matrix
To register, configure, and execute plug-ins, specific security privileges must be granted to developers and service principals. The following matrix defines these requirements:
| Principal | Permission / Privilege | Scope | Reason |
|---|---|---|---|
| Developer / Deployment SPN | prvCreatePluginAssembly | Organization | Required to register and upload a new PluginAssembly binary to the database. |
| Developer / Deployment SPN | prvWritePluginAssembly | Organization | Required to update an existing PluginAssembly binary. |
| Developer / Deployment SPN | prvCreateSdkMessageProcessingStep | Organization | Required to register a new execution step on a plug-in type. |
| Developer / Deployment SPN | prvWriteSdkMessageProcessingStep | Organization | Required to modify step properties, unsecure configurations, or filtering attributes. |
| Developer / Deployment SPN | prvCreateSdkMessageProcessingStepSecureConfig | Organization | Required to write or update the secure configuration string of a step. |
| System Administrator | Full Access | Organization | Only System Administrators can read the SdkMessageProcessingStepSecureConfig table. |
| Interactive User | Read on Customization Metadata | Organization | Required to trigger the execution of synchronous plug-ins registered on platform messages. |
| Dataverse Sandbox Service Account | Network Access (Outbound) | External Endpoint | Required to execute outbound HTTP/HTTPS calls from sandboxed plug-ins. |
7. Pipeline Execution Internals
Transaction Boundaries and Rollbacks
Synchronous plug-ins registered in the Pre-Operation (Stage 20) and Post-Operation (Stage 40) stages execute within the same database transaction as the core operation.
- If any synchronous plug-in throws an
InvalidPluginExecutionException, the platform aborts the execution pipeline, cancels the database operation, and rolls back the entire transaction. - Any database modifications made by the plug-in or by subsequent steps in the pipeline are completely undone.
- Asynchronous plug-ins registered in the Post-Operation stage do not participate in the database transaction. They are queued in the
AsyncOperationtable and executed independently by the Asynchronous Service.
Depth and Loop-Guard Limits
To prevent infinite loops caused by recursive plug-in execution (e.g., a plug-in on the update of an account updates the same account, triggering itself again), Dataverse enforces a strict execution depth limit:
- Maximum Depth Limit: 8
- Time Window: 1 hour
- Every time a plug-in executes an operation that triggers another plug-in (or itself), the
Depthproperty of theIPluginExecutionContextincrements by 1. - If the
Depthreaches 9 within the 1-hour window, the platform immediately terminates execution and throws aMaxDepthExceededexception, rolling back the transaction.
Developers must implement defensive checks in their code to prevent infinite loops:
if (context.Depth > 1)
{
tracingService.Trace($"Current depth is {context.Depth}. Exiting to prevent infinite loop.");
return;
}
8. Error Handling & Retry Patterns
Common Failure Modes and Resolutions
| Error Code / Exception Type | Root Cause | Diagnostic Steps | Correct Remediation |
|---|---|---|---|
SandboxWorkerNotAvailable (0x8004418D) | The Sandbox Worker process crashed during execution. | Check Application Insights for a PluginWorkerCrashException. Look for memory leaks or unhandled thread exceptions. | Ensure the plug-in is completely stateless. Avoid using static collections that grow over time. Wrap all async tasks in try-catch blocks. |
SandboxHostPluginTimeout (0x80044172) | The plug-in execution exceeded the hard 2-minute timeout limit. | Check the execution duration in the Plug-in Trace Log. | Optimize database queries. Implement paging. Offload long-running operations to asynchronous plug-ins. |
SandboxMessageSizeExceeded (0x8004029B) | The message payload sent to the Sandbox service exceeded 116.85 MB. | Review the size of the target record, especially if it contains large binary attachments or base64 data. | Exclude large binary columns from the plug-in step registration. Retrieve binary data in smaller chunks using the Web API. |
IsvUnExpected (0x80040265) | An unhandled exception bubbled up from the plug-in code. | Review the Plug-in Trace Log to locate the unhandled exception stack trace. | Wrap the entire Execute method in a try-catch block and throw an InvalidPluginExecutionException with a user-friendly message. |
Robust Error Handling and HTTP Retry Pattern
When making outbound HTTP calls from a sandboxed plug-in, developers must handle transient network failures. The following code demonstrates how to implement an exponential back-off retry pattern using a custom HTTP client:
public class HttpIntegrationService
{
private readonly ITracingService tracingService;
public HttpIntegrationService(ITracingService tracing)
{
this.tracingService = tracing;
}
public string CallExternalApiWithRetry(string url, string payload, string apiKey)
{
int maxRetries = 3;
int delayMilliseconds = 1000; // Initial delay
using (var client = new System.Net.Http.HttpClient())
{
client.Timeout = TimeSpan.FromSeconds(15); // Fail fast, do not use default 100s timeout
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
for (int retry = 0; retry <= maxRetries; retry++)
{
try
{
var content = new System.Net.Http.StringContent(payload, Encoding.UTF8, "application/json");
var response = client.PostAsync(url, content).GetAwaiter().GetResult();
if (response.IsSuccessStatusCode)
{
return response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
}
this.tracingService.Trace($"HTTP Call failed with status code: {response.StatusCode}. Retry attempt: {retry}");
}
catch (Exception ex)
{
this.tracingService.Trace($"HTTP Exception on attempt {retry}: {ex.Message}");
if (retry == maxRetries)
{
throw new InvalidPluginExecutionException(
OperationStatus.Failed,
200100,
"Failed to connect to the external integration service after multiple attempts.",
ex);
}
}
// Exponential back-off delay
if (retry < maxRetries)
{
System.Threading.Thread.Sleep(delayMilliseconds);
delayMilliseconds *= 2; // Double the delay for the next attempt
}
}
}
throw new InvalidPluginExecutionException("Failed to execute external API call.");
}
}
9. Performance Optimisation & Limits
To maintain platform stability, Microsoft Dataverse enforces strict resource limits on plug-in execution. Developers must design their code to operate efficiently within these boundaries.
Platform Limits
- Execution Timeout: Plug-ins have a hard limit of 2 minutes (120 seconds). If a plug-in does not complete within this window, the Sandbox service terminates the worker process, rolling back the transaction.
- Memory Limit: The Sandbox worker process has a memory limit of 150 MB per execution. Exceeding this limit causes the process to crash.
- Outbound Call Timeout: Outbound network requests are limited to 15 seconds per call.
- Database Lock Escalation: Synchronous plug-ins running inside a transaction hold database locks on the records being modified. If a transaction takes too long, SQL Server escalates these locks, blocking other users and causing
Sql error: Execution Timeout Expirederrors.
Optimization Strategies
- Specify Filtering Attributes: Never register an
Updatestep with "All Attributes". This causes the plug-in to execute on every single update operation, severely degrading system performance. Only select the specific columns that are required to trigger the business logic. - Avoid Retrieve and RetrieveMultiple Plug-ins: Registering synchronous plug-ins on retrieve messages adds overhead to read operations, which constitute the majority of database traffic. Use calculated columns, rollups, or asynchronous processes instead.
- Minimize Database Queries: Use Pre-Images and Post-Images to obtain the state of a record instead of executing explicit
Retrieverequests within the plug-in. - Use No-Lock Queries: When querying data using
QueryExpressionorFetchExpression, set theNoLockproperty totrueto prevent read locks on database tables:var query = new QueryExpression("account"){ColumnSet = new ColumnSet("name"),NoLock = true}; - Keep Plug-ins Stateless: Do not store service instances, organization services, or execution contexts in class-level fields. These instances are cached by the platform and shared across multiple threads, leading to thread-safety issues and memory leaks.
10. ALM & Deployment Checklist
Step-by-Step ALM Process
- Development:
- Develop plug-ins in a dedicated local Visual Studio project.
- Register the assembly and steps in the Development Environment using the Plugin Registration Tool.
- Create an unmanaged solution (e.g.,
EnterpriseCustomizations) in the development environment. - Add the
PluginAssemblyandSdkMessageProcessingStepcomponents to the solution.
- Source Control Integration:
- Export the unmanaged solution from the development environment.
- Unpack the solution zip file using the Power Platform CLI Solution Packager:
pac solution unpack --zipfile EnterpriseCustomizations.zip --folder ./src/solutions/EnterpriseCustomizations
- Commit the unpacked XML files, C# source code, and project files to your Git repository.
- Build and Release Pipeline:
- Configure a CI/CD pipeline (Azure DevOps or GitHub Actions) to build the C# project, run unit tests, pack the solution, and import it into target environments.
Azure DevOps Pipeline YAML Snippet
The following YAML snippet demonstrates how to automate the build, packaging, and deployment of a Dataverse solution containing plug-ins using the Microsoft Power Platform Build Tools:
trigger:
branches:
include:
- main
pool:
vmImage: 'windows-latest'
variables:
solutionName: 'EnterpriseCustomizations'
buildConfiguration: 'Release'
steps:
- task: NuGetToolInstaller@1
displayName: 'Install NuGet'
- task: NuGetCommand@2
displayName: 'Restore NuGet Packages'
inputs:
restoreSolution: '**/*.sln'
- task: VSBuild@1
displayName: 'Build Plug-in Assembly'
inputs:
solution: '**/*.sln'
configuration: '$(buildConfiguration)'
- task: microsoft-IsvExpTools.PowerPlatform-BuildTools.tool-installer.PowerPlatformToolInstaller@2
displayName: 'Power Platform Tool Installer'
- task: microsoft-IsvExpTools.PowerPlatform-BuildTools.pack-solution.PowerPlatformPackSolution@2
displayName: 'Pack Solution'
inputs:
SolutionSourceFolder: 'src/solutions/$(solutionName)'
SolutionOutputFile: '$(Build.ArtifactStagingDirectory)/$(solutionName)_managed.zip'
SolutionType: 'Managed'
- task: microsoft-IsvExpTools.PowerPlatform-BuildTools.import-solution.PowerPlatformImportSolution@2
displayName: 'Import Solution to Target Environment'
inputs:
authenticationType: 'PowerPlatformSPN'
PowerPlatformSPN: 'Dataverse-Service-Connection'
SolutionInputFile: '$(Build.ArtifactStagingDirectory)/$(solutionName)_managed.zip'
PublishCustomizations: true
OverwriteUnmanagedCustomizations: true
MaxAsyncWaitTime: '120'
11. Common Pitfalls & Troubleshooting Guide
1. Non-Stateless Plug-in Implementations
- Symptom: Intermittent data corruption, users seeing other users' data, or random null reference exceptions.
- Root Cause: The developer declared class-level fields (e.g.,
private IOrganizationService _service;) to store the organization service or execution context. Because Dataverse caches and reuses plug-in class instances across multiple threads, concurrent executions overwrite these fields. - Diagnostic Steps: Review the plug-in class definition. Look for any non-static, non-constant member variables.
- Fix: Remove all class-level fields. Declare and instantiate all services, contexts, and variables strictly within the scope of the
Executemethod.
2. Missing Filtering Attributes on Update Steps
- Symptom: Severe performance degradation, high database CPU utilization, and plug-ins executing unexpectedly.
- Root Cause: An
Updatestep was registered with "All Attributes" selected. The plug-in executes every time any column on the entity is updated, even if the business logic only depends on a single field. - Diagnostic Steps: Open the step registration in the Plugin Registration Tool and check the "Filtering Attributes" field.
- Fix: Edit the step and select only the specific attributes required to trigger the logic.
3. Swallowing Exceptions inside Database Transactions
- Symptom: Dataverse operations fail with the error
ISV code reduced the open transaction countorThere is no active transaction. - Root Cause: The developer wrapped an
IOrganizationServicecall in a try-catch block and caught a genericExceptionwithout rethrowing it. When a database operation fails inside a transaction, SQL Server immediately rolls back the transaction. If the plug-in swallows the error and attempts to continue executing, the platform detects that the transaction count has been reduced. - Diagnostic Steps: Search the plug-in code for empty catch blocks or catch blocks that do not throw an
InvalidPluginExecutionException. - Fix: Always allow exceptions from the
IOrganizationServiceto bubble up, or catch them and immediately throw anInvalidPluginExecutionException.
4. Thread Pool Starvation via Asynchronous Blocking
- Symptom: The Sandbox worker process crashes with a
SandboxWorkerNotAvailableerror, or outbound calls time out. - Root Cause: The developer used asynchronous .NET patterns (e.g.,
HttpClient.GetAsync()) and blocked the thread using.Resultor.Wait(). This causes thread pool starvation and deadlocks within the synchronous execution context of the Sandbox. - Diagnostic Steps: Inspect the code for
.Result,.Wait(), orTask.WaitAll(). - Fix: Use synchronous methods where available, or use
.GetAwaiter().GetResult()to execute the task synchronously and propagate exceptions correctly.
5. Assembly Not Signed
- Symptom: The Plugin Registration Tool throws an error during assembly registration:
The assembly must be signed with a strong name key. - Root Cause: Dataverse requires all plug-in assemblies to be strongly named to ensure uniqueness and security.
- Diagnostic Steps: Check the project properties in Visual Studio under the "Signing" tab.
- Fix: Generate a new strong name key file (.snk) and sign the assembly before compiling.
6. Primary Key Included in Filtering Attributes
- Symptom: The plug-in triggers on every update operation, despite having filtering attributes configured.
- Root Cause: The developer included the primary key column (e.g.,
accountid) in the filtering attributes list. Dataverse always includes the primary key in the update request payload, which negates the filtering logic. - Diagnostic Steps: Check the filtering attributes list for the primary key of the entity.
- Fix: Remove the primary key column from the filtering attributes list.
7. Secure Configuration Missing in Target Environment
- Symptom: The plug-in executes but fails with a null reference exception when attempting to read an API key or credential.
- Root Cause: The plug-in was deployed to a downstream environment via a managed solution, but the secure configuration was not manually re-entered or deployed via an API script. Secure configurations are not transported in solutions.
- Diagnostic Steps: Query the
sdkmessageprocessingstepsecureconfigtable in the target environment to verify if the secure configuration record exists and contains data. - Fix: Use the Plugin Registration Tool to manually enter the secure configuration in the target environment, or automate the creation of the secure configuration record using a deployment script.
8. Infinite Loop via Self-Update
- Symptom: Operations fail with the error
Microsoft.Xrm.Sdk.InvalidPluginExecutionException: Maximum depth limit of 8 has been exceeded. - Root Cause: A plug-in registered on the
Updateof an entity executes anUpdatecall on that same entity, triggering itself recursively. - Diagnostic Steps: Check the Plug-in Trace Log. Look for multiple consecutive executions of the same plug-in with incrementing depth values.
- Fix: Add a depth check at the beginning of the
Executemethod, or modify the logic to update attributes in thePreOperationstage without issuing an explicitUpdatecall.
9. Using Web API instead of Organization Service Natively
- Symptom: Outbound calls fail or do not participate in the active database transaction.
- Root Cause: The developer made HTTP calls to the Dataverse Web API endpoint from within a plug-in instead of using the native
IOrganizationServiceprovided by the service provider. - Diagnostic Steps: Search the code for references to
HttpClientpointing to the local Dataverse organization URL. - Fix: Replace all local Web API calls with calls to the
IOrganizationServiceretrieved from the service provider.
10. Exceeding the 15-Second Outbound Call Limit
- Symptom: Outbound integration calls fail with a
TaskCanceledExceptionor timeout error. - Root Cause: The external service took longer than 15 seconds to respond. The Dataverse Sandbox service enforces a hard 15-second limit on outbound network calls.
- Diagnostic Steps: Measure the response time of the external API.
- Fix: Set the
Timeoutproperty of theHttpClientto a maximum of 15 seconds to fail fast, and implement a retry pattern or offload the call to an asynchronous plug-in.
12. Exam Focus: Key Facts & Edge Cases
The following list highlights critical, highly specific technical details that are frequently tested on the PL-400 Microsoft Power Platform Developer exam:
- Target Framework: Plug-ins must target .NET Framework 4.6.2. Targeting any other version (such as .NET Core or .NET Standard) will prevent registration.
- Pipeline Stage Numbers: You must memorize the exact integer values for the execution pipeline stages:
PreValidation= 10PreOperation= 20PostOperation= 40
- Execution Modes:
- Synchronous steps can be registered on stages 10, 20, and 40.
- Asynchronous steps can only be registered on stage 40 (Post-Operation).
- Database Transactions:
- Stage 10 (Pre-Validation) runs outside the database transaction (unless triggered within an existing transaction).
- Stage 20 (Pre-Operation) and Stage 40 (Post-Operation) run inside the database transaction.
- Pre/Post Images Availability:
- Pre-Images are not available on
Createoperations (the record does not exist yet). - Post-Images are not available on
Deleteoperations (the record no longer exists). - Images are not available on asynchronous steps registered on the
Createmessage.
- Pre-Images are not available on
- Secure vs. Unsecure Configuration Transport:
- Unsecure configurations are stored in the
configurationattribute of the step and are included in exported solutions. - Secure configurations are stored in the
secureconfigattribute of the secure configuration entity and are NOT included in exported solutions.
- Unsecure configurations are stored in the
- Sandbox Network Restrictions:
- Outbound calls are restricted to ports 80 and 443.
- You cannot use IP addresses; you must use DNS names.
- The maximum timeout for any outbound call is 15 seconds.
- Depth Limit: The maximum execution depth is 8. If a plug-in triggers an operation that causes the depth to reach 9, the platform terminates execution and rolls back the transaction.
- Exception Type:
InvalidPluginExecutionExceptionis the only exception type that will display a custom, friendly error message to the end-user in a model-driven app. All other exceptions bubble up as a generic "An unexpected error occurred from ISV code" error. - Impersonation:
- By default, plug-ins run in the context of the Calling User.
- You can configure a step to run in the context of a specific user (impersonation) by setting the
ImpersonatingUserIdattribute of the step.
- Bypassing Custom Business Logic:
- You can bypass custom plug-ins and workflows by passing the
BypassCustomPluginExecutionoptional parameter in your organization service requests, provided the calling user has theprvBypassPluginsprivilege.
- You can bypass custom plug-ins and workflows by passing the