Post-operation and Asynchronous Plugin Development
1. Conceptual Foundation
The Microsoft Dataverse Event Execution Pipeline is a highly sophisticated, transactional event framework that processes data operations in a structured sequence of stages. Extending this pipeline using custom code—specifically through plugins—requires a deep understanding of how transactions, execution contexts, and asynchronous processing interact.
+---------------------------------------------------------------------------------+
| Dataverse Event Pipeline |
+---------------------------------------------------------------------------------+
| Stage 10: Pre-Validation (Outside Transaction*) |
| - Validates business rules before database locks are acquired. |
+---------------------------------------------------------------------------------+
|
v
+---------------------------------------------------------------------------------+
| Stage 20: Pre-Operation (Inside Transaction) |
| - Modify entity attributes before they are written to the database. |
+---------------------------------------------------------------------------------+
|
v
+---------------------------------------------------------------------------------+
| Stage 30: Main Operation (Inside Transaction - Platform Core) |
| - Core database operation (Insert, Update, Delete) is executed. |
+---------------------------------------------------------------------------------+
|
v
+---------------------------------------------------------------------------------+
| Stage 40: Post-Operation (Inside Transaction) |
| - Read/write related records, modify output parameters, trigger integrations. |
+---------------------------------------------------------------------------------+
| |
| (Synchronous Execution) | (Asynchronous Execution)
v v
+---------------------------------------+ +---------------------------------+
| Synchronous Post-Operation Plugins | | Asynchronous Service Queue |
| - Runs immediately. | | - Context serialized to DB. |
| - Failure rolls back transaction. | | - Executed by Async Service. |
| - Blocks user thread. | | - Non-blocking, auto-retry. |
+---------------------------------------+ +---------------------------------+
The Event Execution Pipeline and Stage 40 (Post-Operation)
The event execution pipeline is divided into four primary stages:
- Pre-Validation (Stage 10): Executes prior to the database transaction. It is primarily used for validation logic to reject requests before database locks are held.
- Pre-Operation (Stage 20): Executes within the database transaction. It is the ideal stage to modify the attributes of the target record before they are written to the database.
- Main Operation (Stage 30): An internal platform stage where the actual SQL operation (INSERT, UPDATE, DELETE) is executed against the database.
- Post-Operation (Stage 40): Executes within the database transaction (for synchronous steps) but after the core database operation has completed.
In Stage 40, the primary record has already been committed to the database but is not yet finalized (the transaction has not been committed). This stage is designed for:
- Creating or modifying related records (e.g., creating a follow-up task when an account is created).
- Modifying output parameters of the message before they are returned to the caller.
- Executing synchronous integrations that must succeed or fail atomically with the primary operation.
Synchronous Post-Operation Logic
When a plugin is registered as a synchronous step in Stage 40, it executes on the same thread as the platform operation. The calling application (such as a model-driven app, Power Automate flow, or external API client) blocks and waits for the plugin to complete execution.
Because synchronous Post-Operation plugins run inside the database transaction, any exception thrown by the plugin will cause the entire transaction to roll back. This includes the core operation executed in Stage 30 and any changes made by preceding plugins in Stage 20 or Stage 40.
While this transactional integrity is powerful, it introduces significant performance overhead. Database locks acquired during Stage 30 are held throughout the execution of all synchronous Stage 40 plugins. If a plugin performs slow external API calls or complex queries, it can lead to database blocking, lock escalation, and ultimately, platform timeouts (the 2-minute execution limit).
Asynchronous Post-Operation Logic
Asynchronous plugins can only be registered in the Post-Operation (Stage 40) stage. When a step is registered asynchronously, the execution flow diverges:
- The synchronous pipeline completes, and the database transaction commits.
- The platform serializes the entire execution context (
IPluginExecutionContext) into a binary or JSON payload. - A new record is inserted into the
AsyncOperation(System Job) table with anOperationTypeof1(Plugin Association). - The calling thread is immediately unblocked, returning a success response to the user.
- The Dataverse Asynchronous Service (a host process running on the application servers) polls the
AsyncOperationtable, deserializes the context, and executes the plugin logic on a background thread.
Asynchronous steps are non-blocking and highly scalable. They are the preferred choice for any operation that does not require immediate, real-time feedback to the user, such as:
- Sending integration payloads to external systems (e.g., ERP, Azure Service Bus).
- Generating complex documents or processing large batches of data.
- Sending notifications or emails.
Entity Images: Pre-Image vs. Post-Image
When a plugin executes, the InputParameters collection contains the data passed in the request. For an Update message, this collection only contains the attributes that have actually changed (the "dirty" fields). If your plugin logic needs to evaluate fields that were not modified in the current request, you must use Entity Images.
Entity Images are platform-provided snapshots of the record's attributes, captured at specific points in the pipeline. Using images is significantly more performant than executing an explicit Retrieve request within your plugin code, as the platform extracts this data during its internal database operations and passes it directly in the execution context.
- Pre-Image (PreEntityImages): A snapshot of the record's attributes before the core database operation (Stage 30) occurs.
- Availability: Available in Pre-Operation (Stage 20) and Post-Operation (Stage 40) for
Update,Delete,Assign, andSetStateoperations. It is not available forCreateoperations, as the record does not yet exist.
- Availability: Available in Pre-Operation (Stage 20) and Post-Operation (Stage 40) for
- Post-Image (PostEntityImages): A snapshot of the record's attributes after the core database operation (Stage 30) has completed.
- Availability: Available in Post-Operation (Stage 40) for
Create,Update, andAssignoperations. It is not available forDeleteoperations, as the record has been removed from the database.
- Availability: Available in Post-Operation (Stage 40) for
Service Endpoints and Azure Integration
Dataverse provides native out-of-the-box integration with Azure Service Bus, Azure Event Hubs, and Webhooks via Service Endpoints. Instead of writing custom C# code to handle HTTP requests or AMQP connections, developers can register a ServiceEndpoint configuration in Dataverse.
When a step is registered on a Service Endpoint (e.g., on the Create of an account), the Dataverse Event Framework automatically serializes the execution context into a RemoteExecutionContext object and posts it to the configured Azure resource. This integration can be configured to run synchronously or asynchronously:
- Synchronous Service Endpoint Step: The post to Azure occurs within the database transaction. If the Azure Service Bus is unavailable or rejects the message, the Dataverse transaction rolls back.
- Asynchronous Service Endpoint Step: The post is queued as a system job and executed by the Asynchronous Service. If the post fails, the system job handles retries automatically.
For advanced scenarios where the payload must be modified before transmission, developers can write a Custom Azure-Aware Plugin. This plugin uses the IServiceEndpointNotificationService to programmatically post the context to a specific Service Endpoint ID, allowing for custom pre-processing or conditional routing.
Async Retry and Failure Behavior
The Dataverse Asynchronous Service manages the execution lifecycle of system jobs using the AsyncOperation table. The state of a system job is tracked via two columns: StateCode and StatusCode.
- StateCode: Represents the high-level state of the job (e.g.,
0for Ready,1for Suspended,2for Locked,3for Completed). - StatusCode: Provides the specific status reason (e.g.,
30for Succeeded,31for Failed,32for Canceled).
When an asynchronous plugin encounters a transient error (such as a temporary network outage or rate-limiting from an external API), it should not fail silently. Instead, it must throw an InvalidPluginExecutionException using a specific constructor that passes OperationStatus.Retry.
When this exception is caught by the Asynchronous Service:
- The current execution is halted.
- The system job's
StateCodeis set to1(Suspended) andStatusCodeto10(Waiting). - The
PostponeUntilcolumn is updated with a future timestamp, scheduling the job for a retry. - The service will attempt to execute the job up to 4 times before marking it as permanently
Failed(StatusCode=31).
2. Architecture & Decision Matrix
When designing enterprise extensions in Microsoft Dataverse, selecting the correct execution vehicle is critical for system stability, performance, and maintainability. The table below compares the primary implementation approaches for post-operation logic.
Implementation Comparison Matrix
| Feature / Dimension | Synchronous Post-Operation Plugin | Asynchronous Post-Operation Plugin | Power Automate Cloud Flow (Automated) | Azure Functions (via Service Bus/Webhook) |
|---|---|---|---|---|
| Complexity | High (C# development, strong-name signing, registration) | High (C# development, async state management) | Low (Low-code drag-and-drop designer) | Medium-High (C#/TypeScript, Azure resource management) |
| Scalability | Low (Tied to IIS/worker process threads, blocks caller) | High (Queued in database, processed by background service) | Medium-High (Subject to API request limits and concurrency caps) | Extremely High (Serverless scaling, independent of Dataverse resources) |
| Execution Context | Full IPluginExecutionContext (Input/Output parameters, SharedVariables) | Full IPluginExecutionContext (Serialized snapshot of the event) | Simplified JSON payload (Requires Dataverse connector retrieves) | Full RemoteExecutionContext (JSON/XML payload posted from Dataverse) |
| Transaction Boundary | Inside the database transaction (Can roll back the operation) | Outside the database transaction (Cannot roll back) | Outside the database transaction (Cannot roll back) | Outside the database transaction (Cannot roll back) |
| Offline Support | Yes (Supported in Mobile Offline for Power Apps) | No (Requires connection to queue system jobs) | No (Requires cloud connectivity) | No (Requires cloud connectivity) |
| Execution Timeout | Strict 2-minute limit | Strict 2-minute limit per execution run | Up to 30 days (for long-running approval flows) | Default 5 mins (up to 10 mins on Consumption, longer on Premium) |
| Licensing Impact | Included in base Power Apps/Dataverse license | Included in base Power Apps/Dataverse license | Subject to Power Automate request limits and licensing | Incurs Azure consumption costs (independent of Power Platform) |
| PL-400 Exam Relevance | Critical (Deep focus on pipeline, transaction, and rollback) | Critical (Focus on non-blocking design and system jobs) | High (Focus on trigger conditions and connector limits) | High (Focus on Service Endpoint registration and webhooks) |
Architectural Decision Tree
Is the logic required to run
immediately and block the user
until completion?
/ \
Yes No
/ \
Does it need to roll back the Is it a simple notification,
database transaction on failure? approval, or low-complexity task?
/ \ / \
Yes No Yes No
/ \ / \
[Sync Post-Operation [Use Async [Power Automate Does it require heavy
Plugin] Plugin] Cloud Flow] computation or external
libraries?
/ \
Yes No
/ \
[Azure Function [Async Post-Op
via ServiceBus] Plugin]
3. Step-by-Step Implementation Guide
This guide details the end-to-end process of creating, registering, and configuring a synchronous and asynchronous plugin, including Pre/Post Entity Images and Service Endpoint registration.
Step 1: Project Setup and Initialization
- Open a terminal and use the Power Platform CLI (PAC CLI) to initialize a new plugin project:
pac plugin init --outputDirectory DataversePlugins --author "Enterprise Architect"
- Navigate to the created directory and open the
.csprojfile. Ensure the latest official NuGet packages are referenced:<PackageReference Include="Microsoft.CrmSdk.CoreAssemblies" Version="9.0.2.56" /><PackageReference Include="Microsoft.CrmSdk.Deployment" Version="9.0.2.34" /> - Generate a strong-name key file to sign the assembly (mandatory for Dataverse plugins):
sn -k KeyPair.snk
- Reference the key file in your
.csproj:<PropertyGroup><SignAssembly>true</SignAssembly><AssemblyOriginatorKeyFile>KeyPair.snk</AssemblyOriginatorKeyFile></PropertyGroup>
Step 2: Writing the Plugin Code
Create a class file named AccountPostOperationPlugin.cs. The plugin will:
- Implement the
IPlugininterface. - Extract the
IPluginExecutionContext,ITracingService, andIOrganizationServiceFactory. - Read a configured Pre-Image to get the original account rating.
- Read a configured Post-Image to get the updated account rating.
- Compare the values and, if changed, create a follow-up task record.
- Handle synchronous and asynchronous execution paths gracefully.
(The complete, compilable code is provided in Section 4).
Step 3: Registering the Assembly
- Build the project in Release mode:
dotnet build -c Release
- Launch the Plugin Registration Tool (PRT) via PAC CLI:
pac tool prt
- Click Create New Connection, select Microsoft 365, check Display list of available organizations, and log in to your target environment.
- Click Register -> Register New Assembly.
- Browse to the compiled
DataversePlugins.dllin yourbin/Release/net462(or target framework) folder. - Ensure Sandbox is selected for the Isolation Mode and Database is selected for the Location. Click Register Selected Plugins.
Step 4: Registering the Synchronous Post-Operation Step
- In the PRT, locate your registered assembly and expand it to find the plugin type:
DataversePlugins.AccountPostOperationPlugin. - Right-click the plugin type and select Register New Step.
- Configure the step with the following parameters:
- Message:
Update - Primary Entity:
account - Filtering Attributes: Click the ellipsis and select
creditlimit,ratingcode. (Restricting execution to specific attributes is a critical performance best practice). - Event Pipeline Stage of Execution:
PostOperation(Stage 40) - Execution Mode:
Synchronous - Deployment:
Server - Name:
AccountPostOperationPlugin_Update_Sync
- Message:
- Click Register New Step.
Step 5: Registering the Asynchronous Post-Operation Step
- Right-click the same plugin type (
DataversePlugins.AccountPostOperationPlugin) and select Register New Step. - Configure the step with the following parameters:
- Message:
Update - Primary Entity:
account - Filtering Attributes: Select
creditlimit,ratingcode. - Event Pipeline Stage of Execution:
PostOperation(Stage 40) - Execution Mode:
Asynchronous - Deployment:
Server - Name:
AccountPostOperationPlugin_Update_Async
- Message:
- Check the box Delete AsyncOperation if StatusCode = Succeeded. (This prevents database bloat in the
AsyncOperationtable). - Click Register New Step.
Step 6: Configuring Pre and Post Entity Images
To allow the plugin to compare the record's state before and after the update, you must register images on the steps.
Register Pre-Image on the Synchronous Step:
- In the PRT, right-click the synchronous step you registered in Step 4 and select Register New Image.
- Configure the image:
- Image Type:
Pre Image - Name:
PreUpdateImage - Entity Alias:
PreImage(This is the key used to access the image in C# code). - Parameters: Click the ellipsis and select
creditlimit,ratingcode,name.
- Image Type:
- Click Register Image.
Register Post-Image on the Synchronous Step:
- Right-click the synchronous step again and select Register New Image.
- Configure the image:
- Image Type:
Post Image - Name:
PostUpdateImage - Entity Alias:
PostImage - Parameters: Select
creditlimit,ratingcode,name.
- Image Type:
- Click Register Image.
Repeat these exact steps for the Asynchronous step registered in Step 5, using the same aliases (PreImage and PostImage).
Step 7: Registering a Service Endpoint (Azure Service Bus Queue)
To integrate Dataverse directly with Azure Service Bus without writing custom outbound code:
- In the PRT, click Register -> Register New Service Endpoint.
- Paste your Azure Service Bus Connection String (retrieved from the Azure Portal).
- The tool will parse the connection string. Configure the following properties:
- Designation:
Queue - Message Format:
Json(Preferred for cross-platform interoperability). - Path: Enter the exact name of your Azure Service Bus Queue (e.g.,
dataverse-account-updates). - Auth Type:
SAS Key(orManaged Identityif configured).
- Designation:
- Click Save.
Step 8: Registering a Step on the Service Endpoint
Once the Service Endpoint is registered, it acts as an event handler, similar to a plugin assembly.
- Right-click the newly registered Service Endpoint in the PRT and select Register New Step.
- Configure the step:
- Message:
Update - Primary Entity:
account - Filtering Attributes:
creditlimit - Event Pipeline Stage of Execution:
PostOperation - Execution Mode:
Asynchronous
- Message:
- Click Register New Step. Dataverse will now automatically serialize and post the execution context to your Azure Service Bus queue whenever an account's credit limit is updated.
4. Complete Code Reference
Sample 1: Robust Post-Operation Plugin with Pre/Post Images and Retry Logic
This production-grade plugin is designed to run either synchronously or asynchronously on the Update message of the account entity. It reads the registered Pre and Post Entity Images, compares the creditlimit and ratingcode fields, and creates a follow-up task. It includes robust error handling and utilizes the OperationStatus.Retry exception to handle transient failures when running asynchronously.
//-----------------------------------------------------------------------
// <copyright file="AccountPostOperationPlugin.cs" company="Enterprise">
// Copyright (c) Enterprise Corporation. All rights reserved.
// </copyright>
// <summary>
// Demonstrates Stage 40 Post-Operation execution, Pre/Post Image parsing,
// SharedVariables usage, and asynchronous retry patterns.
// </summary>
//-----------------------------------------------------------------------
namespace DataversePlugins
{
using System;
using System.ServiceModel;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Query;
/// <summary>
/// Core plugin class registered on the Post-Operation stage of the Account Update message.
/// </summary>
public sealed class AccountPostOperationPlugin : IPlugin
{
private const string PreImageAlias = "PreImage";
private const string PostImageAlias = "PostImage";
/// <summary>
/// Main entry point for the plugin execution.
/// </summary>
/// <param name="serviceProvider">The platform service provider.</param>
public void Execute(IServiceProvider serviceProvider)
{
if (serviceProvider == null)
{
throw new ArgumentNullException(nameof(serviceProvider));
}
// Extract required services from the service provider
ITracingService tracingService = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
IPluginExecutionContext context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
IOrganizationServiceFactory serviceFactory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
// Create the organization service under the system/calling user context
IOrganizationService service = serviceFactory.CreateOrganizationService(context.UserId);
tracingService.Trace("AccountPostOperationPlugin: Execution started. CorrelationId: {0}, Depth: {1}, Mode: {2}",
context.CorrelationId, context.Depth, context.Mode);
// Guard Clause: Verify target entity and message
if (context.MessageName != "Update" || !(context.InputParameters.Contains("Target") && context.InputParameters["Target"] is Entity))
{
tracingService.Trace("AccountPostOperationPlugin: Invalid message or target. Execution bypassed.");
return;
}
Entity target = (Entity)context.InputParameters["Target"];
if (target.LogicalName != "account")
{
tracingService.Trace("AccountPostOperationPlugin: Target entity is not account. Execution bypassed.");
return;
}
try
{
// Retrieve Pre and Post Entity Images
Entity preImage = GetEntityImage(context, PreImageAlias, tracingService);
Entity postImage = GetEntityImage(context, PostImageAlias, tracingService);
if (preImage == null || postImage == null)
{
throw new InvalidPluginExecutionException(
"Required PreImage or PostImage is missing. Please verify step registration in the Plugin Registration Tool.");
}
// Extract credit limit values
Money preCreditLimit = preImage.Contains("creditlimit") ? preImage.GetAttributeValue<Money>("creditlimit") : null;
Money postCreditLimit = postImage.Contains("creditlimit") ? postImage.GetAttributeValue<Money>("creditlimit") : null;
decimal preValue = preCreditLimit != null ? preCreditLimit.Value : 0m;
decimal postValue = postCreditLimit != null ? postCreditLimit.Value : 0m;
tracingService.Trace("AccountPostOperationPlugin: Pre-CreditLimit: {0}, Post-CreditLimit: {1}", preValue, postValue);
// Check if the credit limit has increased significantly (e.g., by more than $10,000)
if (postValue > preValue && (postValue - preValue) >= 10000m)
{
tracingService.Trace("Significant credit limit increase detected. Initiating follow-up task creation.");
// Create a follow-up task
Entity followUpTask = new Entity("task")
{
["subject"] = "Perform High-Value Credit Limit Audit",
["description"] = $"The credit limit was increased from {preValue:C} to {postValue:C}. Verify financial credentials.",
["regardingobjectid"] = target.ToEntityReference(),
["prioritycode"] = new OptionSetValue(2), // High Priority
["scheduledend"] = DateTime.UtcNow.AddDays(2)
};
// Execute the creation
Guid taskId = service.Create(followUpTask);
tracingService.Trace("Follow-up task successfully created with ID: {0}", taskId);
// Pass data to subsequent steps using SharedVariables
if (!context.SharedVariables.ContainsKey("IsHighValueAuditTriggered"))
{
context.SharedVariables.Add("IsHighValueAuditTriggered", true);
}
}
}
catch (FaultException<OrganizationServiceFault> ex)
{
tracingService.Trace("AccountPostOperationPlugin: OrganizationServiceFault encountered: {0}", ex.ToString());
// If running asynchronously, handle transient database locks with a retry
if (context.Mode == 1) // 1 = Asynchronous Mode
{
tracingService.Trace("Asynchronous execution failed. Throwing Retry exception.");
throw new InvalidPluginExecutionException(
OperationStatus.Retry,
-2147204723, // Custom error code mapping to transient failure
"Transient database error occurred. Scheduling retry operation.",
ex);
}
else
{
// Synchronous execution must fail and roll back the transaction
throw new InvalidPluginExecutionException(
$"An error occurred in the Account Post-Operation Plugin: {ex.Message}", ex);
}
}
catch (Exception ex)
{
tracingService.Trace("AccountPostOperationPlugin: Unexpected exception: {0}", ex.ToString());
throw new InvalidPluginExecutionException("An unexpected error occurred during post-operation processing.", ex);
}
}
/// <summary>
/// Helper method to safely retrieve an entity image from the execution context.
/// </summary>
private static Entity GetEntityImage(IPluginExecutionContext context, string alias, ITracingService tracingService)
{
if (context.PreEntityImages != null && context.PreEntityImages.Contains(alias))
{
return context.PreEntityImages[alias];
}
if (context.PostEntityImages != null && context.PostEntityImages.Contains(alias))
{
return context.PostEntityImages[alias];
}
tracingService.Trace("Image with alias '{0}' not found in context.", alias);
return null;
}
}
}
Sample 2: Custom Azure-Aware Plugin using IServiceEndpointNotificationService
This plugin demonstrates how to programmatically post the execution context to an Azure Service Bus queue using the IServiceEndpointNotificationService. This approach is used when you need to perform custom validation, modify shared variables, or conditionally route messages to different Azure endpoints based on runtime data.
//-----------------------------------------------------------------------
// <copyright file="AzureAwarePostOperationPlugin.cs" company="Enterprise">
// Copyright (c) Enterprise Corporation. All rights reserved.
// </copyright>
// <summary>
// Demonstrates programmatic integration with Azure Service Bus using
// IServiceEndpointNotificationService within a Dataverse plugin.
// </summary>
//-----------------------------------------------------------------------
namespace DataversePlugins
{
using System;
using System.ServiceModel;
using Microsoft.Xrm.Sdk;
/// <summary>
/// Custom plugin that programmatically posts the execution context to an Azure Service Bus Service Endpoint.
/// </summary>
public sealed class AzureAwarePostOperationPlugin : IPlugin
{
// The hardcoded Guid of the Service Endpoint registered in Dataverse.
// In production, this should be retrieved from an Environment Variable or Secure Configuration.
private readonly string serviceEndpointIdString;
/// <summary>
/// Initializes a new instance of the <see cref="AzureAwarePostOperationPlugin"/> class.
/// </summary>
/// <param name="unsecureConfig">Unsecure configuration string containing the Service Endpoint ID.</param>
public AzureAwarePostOperationPlugin(string unsecureConfig)
{
if (string.IsNullOrWhiteSpace(unsecureConfig))
{
// Fallback or default ID if configuration is empty
this.serviceEndpointIdString = "00000000-0000-0000-0000-000000000000";
}
else
{
this.serviceEndpointIdString = unsecureConfig.Trim();
}
}
/// <summary>
/// Executes the plugin logic.
/// </summary>
public void Execute(IServiceProvider serviceProvider)
{
if (serviceProvider == null)
{
throw new ArgumentNullException(nameof(serviceProvider));
}
ITracingService tracingService = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
IPluginExecutionContext context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
IServiceEndpointNotificationService notificationService =
(IServiceEndpointNotificationService)serviceProvider.GetService(typeof(IServiceEndpointNotificationService));
tracingService.Trace("AzureAwarePostOperationPlugin: Starting execution.");
if (notificationService == null)
{
throw new InvalidPluginExecutionException("The Service Endpoint Notification Service is unavailable.");
}
if (!Guid.TryParse(this.serviceEndpointIdString, out Guid endpointId) || endpointId == Guid.Empty)
{
throw new InvalidPluginExecutionException("Invalid or missing Service Endpoint ID in step configuration.");
}
EntityReference serviceEndpointRef = new EntityReference("serviceendpoint", endpointId);
try
{
tracingService.Trace("Injecting custom metadata into SharedVariables before posting.");
// Enrich the context with custom metadata that the Azure listener can parse
context.SharedVariables["DataverseSourceEnvironment"] = "Production-US";
context.SharedVariables["MessageTriggeredTimestamp"] = DateTime.UtcNow.ToString("o");
tracingService.Trace("Posting execution context to Service Endpoint ID: {0}", endpointId);
// Post the context to Azure Service Bus
string result = notificationService.Execute(serviceEndpointRef, context);
tracingService.Trace("Post successful. Result: {0}", string.IsNullOrEmpty(result) ? "One-Way/Queue (No response expected)" : result);
}
catch (Exception ex)
{
tracingService.Trace("AzureAwarePostOperationPlugin: Post failed. Exception: {0}", ex.ToString());
// If registered synchronously, this failure will roll back the Dataverse transaction.
// If registered asynchronously, the system job will fail and retry.
throw new InvalidPluginExecutionException($"Failed to post context to Azure Service Bus: {ex.Message}", ex);
}
}
}
}
Sample 3: Complete Solution Customizations XML Definition
When deploying plugins and service endpoints via solutions, the configurations are stored in the customizations.xml file. Below is a complete, valid XML snippet representing the registration of the plugin assembly, steps, images, and the service endpoint.
<ImportExportXml xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Entities />
<Roles />
<Workflows />
<FieldSecurityProfiles />
<Templates />
<PluginAssemblies>
<PluginAssembly SchemaName="DataversePlugins" AssemblyName="DataversePlugins" LatestVersionDependency="1.0.0.0" Version="1.0.0.0" PublicKeyToken="3a4b5c6d7e8f9a0b" Culture="neutral">
<IsolationMode>2</IsolationMode> <!-- 2 = Sandbox -->
<SourceType>0</SourceType> <!-- 0 = Database -->
<Description>Enterprise Dataverse Plugin Assembly</Description>
<PluginTypes>
<PluginType TypeName="DataversePlugins.AccountPostOperationPlugin" FriendlyName="Account Post-Operation Plugin">
<Description>Processes account updates in Stage 40.</Description>
</PluginType>
<PluginType TypeName="DataversePlugins.AzureAwarePostOperationPlugin" FriendlyName="Azure-Aware Post-Operation Plugin">
<Description>Programmatically posts context to Azure Service Bus.</Description>
</PluginType>
</PluginTypes>
</PluginAssembly>
</PluginAssemblies>
<SdkMessageProcessingSteps>
<!-- Synchronous Step Registration -->
<SdkMessageProcessingStep SdkMessageProcessingStepId="{a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d}" Name="AccountPostOperationPlugin_Update_Sync">
<PluginTypeId>{00000000-0000-0000-0000-000000000000}</PluginTypeId> <!-- Maps to PluginType ID -->
<PrimaryEntity>account</PrimaryEntity>
<SdkMessageId>{20be3540-68b3-11df-a4ec-0820200c9a66}</SdkMessageId> <!-- Update Message ID -->
<Stage>40</Stage> <!-- Post-Operation -->
<SupportedDeployment>0</SupportedDeployment> <!-- Server Only -->
<InvocationSource>0</InvocationSource>
<Mode>0</Mode> <!-- 0 = Synchronous -->
<Rank>1</Rank>
<FilteringAttributes>creditlimit,ratingcode</FilteringAttributes>
<Images>
<Image EntityAlias="PreImage" ImageType="0" Id="{b1c2d3e4-f5a6-7b8c-9d0e-1f2a3b4c5d6e}">
<Attributes>creditlimit,ratingcode,name</Attributes>
</Image>
<Image EntityAlias="PostImage" ImageType="1" Id="{c1d2e3f4-a5b6-7c8d-9e0f-1a2b3c4d5e6f}">
<Attributes>creditlimit,ratingcode,name</Attributes>
</Image>
</Images>
</SdkMessageProcessingStep>
<!-- Asynchronous Step Registration -->
<SdkMessageProcessingStep SdkMessageProcessingStepId="{d1e2f3a4-b5c6-7d8e-9f0a-1b2c3d4e5f6a}" Name="AccountPostOperationPlugin_Update_Async">
<PluginTypeId>{00000000-0000-0000-0000-000000000000}</PluginTypeId>
<PrimaryEntity>account</PrimaryEntity>
<SdkMessageId>{20be3540-68b3-11df-a4ec-0820200c9a66}</SdkMessageId>
<Stage>40</Stage>
<SupportedDeployment>0</SupportedDeployment>
<InvocationSource>0</InvocationSource>
<Mode>1</Mode> <!-- 1 = Asynchronous -->
<Rank>1</Rank>
<FilteringAttributes>creditlimit,ratingcode</FilteringAttributes>
<AsyncAutoDelete>1</AsyncAutoDelete> <!-- 1 = Auto-delete successful system jobs -->
</SdkMessageProcessingStep>
</SdkMessageProcessingSteps>
<ServiceEndpoints>
<ServiceEndpoint ServiceEndpointId="{e1f2a3b4-c5d6-7e8f-9a0b-1c2d3e4f5a6b}" Name="EnterpriseServiceBusEndpoint">
<ConnectionMode>1</ConnectionMode> <!-- Normal -->
<Contract>5</Contract> <!-- Topic -->
<NamespaceAddress>sb://enterprise-integration.servicebus.windows.net/</NamespaceAddress>
<Path>dataverse-account-updates</Path>
<MessageFormat>2</MessageFormat> <!-- Json -->
<AuthType>2</AuthType> <!-- SAS Key -->
<SASKeyName>RootManageSharedAccessKey</SASKeyName>
</ServiceEndpoint>
</ServiceEndpoints>
</ImportExportXml>
5. Configuration & Environment Setup
Environment Variables in Dataverse
Hardcoding configuration values (such as Service Endpoint IDs, external API URLs, or threshold values) within plugin code is a severe anti-pattern. Instead, developers must use Environment Variables (environmentvariabledefinition and environmentvariablevalue tables).
Accessing Environment Variables in C# Plugins:
To read an environment variable within a plugin, execute a retrieve query against the environmentvariabledefinition and its related environmentvariablevalue record:
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 def = results.Entities[0];
AliasedValue customValue = def.GetAttributeValue<AliasedValue>("val.value");
if (customValue != null && customValue.Value != null)
{
return customValue.Value.ToString();
}
return def.GetAttributeValue<string>("defaultvalue");
}
return null;
}
Solution Publisher Prefix Rules
All custom components, including Plugin Assemblies, Steps, Service Endpoints, and Environment Variables, must be created within an unmanaged solution associated with a custom Solution Publisher.
- The publisher defines a unique customization prefix (e.g.,
ent_). - All schema names for environment variables and custom entities must use this prefix (e.g.,
ent_ServiceBusEndpointId). - This prevents naming collisions during managed solution imports in downstream environments (UAT, Production).
Azure Service Bus Provisioning (Bicep Template)
To support the Service Endpoint integration, the underlying Azure Service Bus infrastructure must be provisioned. Below is a complete Bicep template to deploy a Service Bus Namespace, a Topic, a Subscription, and a Shared Access Signature (SAS) authorization rule with the minimum required permissions (Send only for Dataverse).
//-----------------------------------------------------------------------
// Bicep template to provision Azure Service Bus for Dataverse Integration
//-----------------------------------------------------------------------
@description('The name of the Service Bus Namespace.')
param namespaceName string = 'sb-dataverse-integration-prod'
@description('The Azure region where the resources will be deployed.')
param location string = resourceGroup().location
@description('The SKU of the Service Bus Namespace. Standard or Premium is required for Topics.')
param serviceBusSku string = 'Standard'
// Provision the Service Bus Namespace
resource serviceBusNamespace 'Microsoft.ServiceBus/namespaces@2021-11-01' = {
name: namespaceName
location: location
sku: {
name: serviceBusSku
tier: serviceBusSku
}
properties: {
minimumTlsVersion: '1.2'
publicNetworkAccess: 'Enabled'
}
}
// Provision the Topic for Dataverse messages
resource serviceBusTopic 'Microsoft.ServiceBus/namespaces/topics@2021-11-01' = {
parent: serviceBusNamespace
name: 'dataverse-account-updates'
properties: {
supportOrdering: true
}
}
// Provision a Subscription for the listener application
resource serviceBusSubscription 'Microsoft.ServiceBus/namespaces/topics/subscriptions@2021-11-01' = {
parent: serviceBusTopic
name: 'erp-listener-sub'
properties: {
deadLetteringOnMessageExpiration: true
maxDeliveryCount: 10
}
}
// Provision a SAS Authorization Rule specifically for Dataverse (Send Only)
resource dataverseSendRule 'Microsoft.ServiceBus/namespaces/topics/authorizationRules@2021-11-01' = {
parent: serviceBusTopic
name: 'DataverseSendOnlyRule'
properties: {
rights: [
'Send'
]
}
}
output serviceBusEndpoint string = serviceBusNamespace.properties.serviceBusEndpoint
output topicName string = serviceBusTopic.name
6. Security & Permission Matrix
To register, configure, and execute plugins and service endpoints, specific security privileges must be granted across both Microsoft Dataverse and Azure.
Security Privileges Table
| Principal / Role | Permission / Privilege | Scope | Reason |
|---|---|---|---|
| System Administrator / Customizer | prvWrite on PluginAssemblyprvCreate on SdkMessageProcessingStep | Organization | Required to register, update, or delete plugin assemblies and steps using the PRT or PAC CLI. |
| System Administrator | prvCreate on ServiceEndpoint | Organization | Required to register and configure Azure Service Bus, Event Hub, or Webhook endpoints. |
| Calling User (Plugin Context) | prvCreate, prvWrite on target entities (e.g., task) | User / Parent | If the step is configured to run in the Calling User's context, that user must possess the security role privileges to perform any SDK operations executed by the plugin. |
| Impersonated User (Run in User's Context) | prvActOnBehalfOfAnotherUser | Organization | If the step is configured to run in a specific user's context (impersonation), the calling user must have the "Act on Behalf of Another User" privilege. |
| Dataverse Service Principal | Azure Service Bus Data Sender | Azure Resource (Queue/Topic) | Required when using Managed Identity authentication for the Service Endpoint. The Dataverse enterprise application must be granted this Azure RBAC role on the Service Bus namespace. |
| Asynchronous Service Account | Read/Write on AsyncOperation | Organization | The internal system account running the asynchronous service must have full access to read, lock, execute, and update system job records. |
7. Pipeline Execution Internals
Transaction Boundaries
Understanding transaction boundaries is critical to preventing data corruption and performance bottlenecks.
[Client Request]
|
v
+-------------------------------------------------------------------------+
| Pipeline Execution Start |
| |
| Stage 10: Pre-Validation (Outside Transaction) |
| - No database locks. Safe for validation. |
+-------------------------------------------------------------------------+
|
v -- START DATABASE TRANSACTION --
+-------------------------------------------------------------------------+
| Stage 20: Pre-Operation (Inside Transaction) |
| - Database locks acquired. |
| |
| Stage 30: Main Operation (Inside Transaction) |
| - SQL INSERT/UPDATE/DELETE executed. |
| |
| Stage 40: Post-Operation (Synchronous - Inside Transaction) |
| - Related records created. |
| - Any exception here ROLLS BACK the entire transaction. |
+-------------------------------------------------------------------------+
|
v -- COMMIT DATABASE TRANSACTION --
+-------------------------------------------------------------------------+
| Pipeline Execution End |
| |
| Stage 40: Post-Operation (Asynchronous - Outside Transaction) |
| - Context serialized to AsyncOperation table. |
| - Executed independently by background service. |
+-------------------------------------------------------------------------+
- Synchronous Post-Operation (Stage 40): Runs inside the database transaction. The platform initiates a SQL transaction before Stage 20 and commits it only after all synchronous Stage 40 steps have completed successfully. If any step fails, a
ROLLBACK TRANSACTIONcommand is issued to SQL Server. - Asynchronous Post-Operation (Stage 40): Runs outside the database transaction. The primary transaction has already committed. The asynchronous service executes the plugin in a completely separate database connection and transaction. A failure in an asynchronous plugin cannot roll back the original data operation.
Sandbox Isolation Mode Restrictions
All custom plugins in Dataverse (SaaS) must run in Sandbox Isolation Mode. This enforces a highly secure, restricted execution environment managed by the platform.
- AppDomain Isolation: Plugins are executed in isolated worker processes (
Microsoft.Crm.Sandbox.WorkerProcess.exe). Code cannot access the local file system, registry, or event logs of the host server. - Outbound Network Calls:
- Restricted to ports 80 and 443 (HTTP/HTTPS). Raw TCP/UDP socket connections are blocked.
- IP addresses and DNS resolution are restricted. Outbound calls must go through the public internet; access to internal virtual networks (VNets) is blocked unless Azure Private Link or Dataverse IP firewalls are explicitly configured.
- The outbound traffic originates from the
PowerPlatformPlexservice tag IP ranges.
- Execution Timeout: There is a strict 2-minute (120 seconds) timeout limit. If a plugin (synchronous or asynchronous) does not complete within this window, the sandbox worker process is terminated, and a
Sandbox Worker process crashederror is thrown. - Memory Limits: Each sandbox worker process has a strict memory limit of 100 MB. Exceeding this limit causes the process to crash immediately.
Depth and Loop-Guard Limits
To prevent runaway recursive loops (e.g., an Update plugin that updates the same record, triggering itself infinitely), Dataverse employs a strict Depth Guard.
- Every time a plugin or workflow executes an SDK call that triggers another event, the platform increments the
Depthproperty in the execution context. - The maximum allowable depth is 8.
- If the
Depthreaches 9 within a 1-hour window for the same correlation chain, the platform aborts execution, rolls back the transaction, and throws an error:This workflow or plugin execution was cancelled because it caused an infinite loop. - Defensive Coding Pattern: Always check the depth at the beginning of your plugin:
if (context.Depth > 1){tracingService.Trace("Depth limit guard triggered. Current depth: {0}. Aborting execution to prevent infinite loop.", context.Depth);return;}
8. Error Handling & Retry Patterns
Common Failure Modes and Resolutions
| Error Code | Exception / Error Message | Root Cause | Correct Remediation |
|---|---|---|---|
-2147204723 | The plug-in execution failed because the Sandbox Worker process crashed. | Unhandled exception, stack overflow, or memory limit exceeded (>100MB) in the plugin code. | Wrap all external calls in try-catch blocks. Avoid recursive methods that cause stack overflows. Ensure stateless design. |
-2146893812 | ISV code reduced the open transaction count. | The plugin caught an exception from an IOrganizationService call within a try-catch block but swallowed it, attempting to continue. | Never swallow OrganizationService exceptions within a transaction. If an SDK call fails, you must throw an InvalidPluginExecutionException to abort. |
-2147220911 | There is no active transaction. | A plugin attempted to perform a database operation after a preceding error had already marked the transaction as doomed. | Ensure proper exception propagation. Do not attempt subsequent database writes once an SDK call has faulted. |
-2147204759 | The plug-in execution failed because of a timeout. | The plugin exceeded the strict 2-minute execution limit (usually due to slow external API calls). | Implement strict timeouts on HttpClient calls (e.g., 15 seconds). Offload long-running work to asynchronous steps. |
Implementing Asynchronous Retry with Exponential Back-off
When calling external APIs from an asynchronous plugin, transient network failures are common. The code below demonstrates how to implement a robust retry pattern using OperationStatus.Retry combined with custom exponential back-off logic.
public void ExecuteExternalApiCallWithRetry(IServiceProvider serviceProvider)
{
ITracingService tracingService = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
IPluginExecutionContext context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
// This logic is only applicable for Asynchronous steps
if (context.Mode != 1)
{
throw new InvalidPluginExecutionException("This retry pattern is only supported on Asynchronous steps.");
}
int currentAttempt = 1;
// Retrieve the current attempt count from SharedVariables if it exists
if (context.SharedVariables.ContainsKey("CurrentRetryAttempt"))
{
currentAttempt = (int)context.SharedVariables["CurrentRetryAttempt"] + 1;
}
tracingService.Trace($"Executing external API call. Attempt {currentAttempt} of 4.");
bool isSuccess = CallExternalService(tracingService);
if (!isSuccess)
{
if (currentAttempt < 4)
{
// Calculate exponential back-off delay (e.g., 5s, 25s, 125s)
int delaySeconds = (int)Math.Pow(5, currentAttempt);
tracingService.Trace($"API call failed. Scheduling retry attempt {currentAttempt + 1} in {delaySeconds} seconds.");
// Store the attempt count in SharedVariables so it persists to the next run
context.SharedVariables["CurrentRetryAttempt"] = currentAttempt;
// Throw the specific exception to trigger the Asynchronous Service retry mechanism
throw new InvalidPluginExecutionException(
OperationStatus.Retry,
-2147204723, // Custom error code
$"Transient API failure. Retry scheduled in {delaySeconds} seconds.")
{
// Note: The platform handles the actual postponement of the system job
};
}
else
{
tracingService.Trace("Maximum retry attempts reached. Marking system job as Failed.");
throw new InvalidPluginExecutionException("External API integration failed permanently after 4 attempts.");
}
}
}
private bool CallExternalService(ITracingService tracingService)
{
// Simulate external HTTP call that might fail due to transient network issues
try
{
using (var client = new System.Net.Http.HttpClient())
{
client.Timeout = TimeSpan.FromSeconds(15); // Strict timeout to prevent sandbox crash
var response = client.GetAsync("https://api.enterprise.com/sync").GetAwaiter().GetResult();
return response.IsSuccessStatusCode;
}
}
catch (Exception ex)
{
tracingService.Trace($"Network exception: {ex.Message}");
return false;
}
}
9. Performance Optimisation & Limits
Service Protection Limits
Dataverse enforces service protection limits to ensure consistent availability and performance for all tenants.
- API Request Limits: Users are allocated a maximum number of API requests within a rolling 24-hour window (based on licensing). Plugins executing SDK calls count against the calling user's limit.
- Concurrent Request Limits: If an organization exceeds 52 concurrent requests lasting longer than 10 seconds each, subsequent requests will return a
429 Too Many Requestserror.
Payload Size Limits for Integrations
When integrating with Azure via Service Endpoints, strict payload limits apply:
- Azure Service Bus Limit: The maximum size of the serialized
RemoteExecutionContextposted to a Service Bus queue or topic is 192 KB. - Webhook Limit: The maximum payload size for Webhooks is 256 KB.
Truncation Behavior:
If the serialized context exceeds these limits, Dataverse will automatically attempt to truncate the payload to prevent a hard failure:
- The platform removes large, non-essential properties from the context:
ParentContext,InputParameters,PreEntityImages, andPostEntityImages. - If the payload is now under the limit, it is posted with an added property:
MessageMaxSizeExceeded = True, indicating that truncation occurred. - If the payload still exceeds the limit after truncation, the post fails entirely, and an error is logged.
Concrete Strategies for Optimization
- Strict Attribute Filtering: Never register a step without filtering attributes. If a plugin is registered on
Updateofcontactwithout filters, it will execute when any field changes (including system fields likemodifiedon), causing massive database overhead. - Minimize Image Columns: When registering Pre/Post Images, only select the absolute minimum columns required for your comparison logic. Selecting all columns increases serialization overhead and risks exceeding the 192 KB Service Bus payload limit.
- Use CreateMultiple and UpdateMultiple: When writing plugins that process bulk data, register steps on the
CreateMultipleandUpdateMultiplemessages. This allows your plugin to process a collection of records in a single execution, drastically reducing database roundtrips. - Implement Caching: For static or rarely changing data (such as configuration settings), implement thread-safe caching within your plugin using static variables. Remember that plugins are instantiated once and cached by the platform across multiple execution threads.
private static readonly object CacheLock = new object();private static Dictionary<string, string> configCache;private static DateTime cacheExpiration;private string GetCachedConfig(IOrganizationService service, string key){lock (CacheLock){if (configCache == null || DateTime.UtcNow > cacheExpiration){// Refresh cache from DataverseconfigCache = LoadConfigurationFromDatabase(service);cacheExpiration = DateTime.UtcNow.AddMinutes(15);}return configCache.ContainsKey(key) ? configCache[key] : null;}}
10. ALM & Deployment Checklist
Deploying plugins and service endpoints across environments (Development -> UAT -> Production) must be fully automated using solutions and CI/CD pipelines.
Solution Packaging Rules
- Plugin Assemblies and Sdk Message Processing Steps are solution components. Always add them to your unmanaged development solution.
- Service Endpoints must also be packaged in the solution.
- Never manually register steps or assemblies in downstream environments (UAT/Prod) using the Plugin Registration Tool. This breaks the Application Lifecycle Management (ALM) chain and causes solution upgrade failures.
Connection References and Environment Variables
- When deploying Service Endpoints, use Environment Variables to store the connection strings and namespace addresses.
- During solution import into target environments, use a deployment settings file (
deploymentsettings.json) to inject the environment-specific values without modifying the solution package.
CI/CD Pipeline YAML Snippet (Power Platform Build Tools)
Below is a production-ready Azure DevOps pipeline snippet demonstrating how to export, unpack, pack, and import a solution containing plugins and service endpoints.
trigger:
branches:
include:
- main
pool:
vmImage: 'windows-latest'
variables:
SolutionName: 'Enterprise_Plugins_Solution'
DevEnvUrl: 'https://org-dev.crm.dynamics.com'
TargetEnvUrl: 'https://org-prod.crm.dynamics.com'
steps:
- task: PowerPlatformToolInstaller@2
displayName: 'Install Power Platform Build Tools'
- task: PowerPlatformExportSolution@2
displayName: 'Export Unmanaged Solution from Dev'
inputs:
authenticationType: 'PowerPlatformSPN'
PowerPlatformSPN: 'DevServiceConnection'
Environment: '$(DevEnvUrl)'
SolutionName: '$(SolutionName)'
SolutionOutputFile: '$(Build.ArtifactStagingDirectory)/$(SolutionName)_unmanaged.zip'
Managed: false
- task: PowerPlatformUnpackSolution@2
displayName: 'Unpack Solution for Source Control'
inputs:
SolutionInputFile: '$(Build.ArtifactStagingDirectory)/$(SolutionName)_unmanaged.zip'
SolutionTargetFolder: '$(Build.SourcesDirectory)/Solutions/$(SolutionName)'
- task: PowerPlatformPackSolution@2
displayName: 'Pack Solution as Managed'
inputs:
SolutionSourceFolder: '$(Build.SourcesDirectory)/Solutions/$(SolutionName)'
SolutionOutputFile: '$(Build.ArtifactStagingDirectory)/$(SolutionName)_managed.zip'
Type: 'Managed'
- task: PowerPlatformImportSolution@2
displayName: 'Import Managed Solution to Production'
inputs:
authenticationType: 'PowerPlatformSPN'
PowerPlatformSPN: 'ProdServiceConnection'
Environment: '$(TargetEnvUrl)'
SolutionInputFile: '$(Build.ArtifactStagingDirectory)/$(SolutionName)_managed.zip'
PublishCustomizations: true
UseDeploymentSettingsFile: true
DeploymentSettingsFile: '$(Build.SourcesDirectory)/Pipelines/prod_deployment_settings.json'
11. Common Pitfalls & Troubleshooting Guide
1. Infinite Loops in Post-Operation Updates
- Symptom: Plugin execution fails with error:
This workflow or plugin execution was cancelled because it caused an infinite loop. - Root Cause: An
Updateplugin registered on Stage 40 executes anUpdatecall on the same record, which triggers the same plugin recursively until the depth limit (8) is exceeded. - Diagnostic Steps: Check the Plugin Trace Log. Observe the same plugin executing repeatedly with incrementing depth values.
- Fix:
- Implement strict attribute filtering so the plugin only triggers when specific fields change.
- In the plugin code, check if the fields you intend to modify are already set to the target values before executing the update.
- Alternatively, perform attribute modifications in Pre-Operation (Stage 20) by modifying the target entity in the
InputParametersdirectly, which does not trigger a new update event.
2. Open Transaction Count Mismatch (Swallowing Exceptions)
- Symptom: Error:
ISV code reduced the open transaction count. Custom plug-ins should not catch exceptions from OrganizationService calls and continue processing. - Root Cause: The developer wrapped an SDK call (like
service.Create) in atry-catchblock, caught a database exception (e.g., duplicate key), and swallowed it (did not rethrow). Dataverse marks the transaction as doomed on any SQL error; continuing execution violates transactional integrity. - Diagnostic Steps: Look for
try-catchblocks in the plugin code that catch genericExceptionorFaultExceptionand do not throw anInvalidPluginExecutionException. - Fix: Always allow exceptions from
IOrganizationServiceto bubble up, or catch them and immediately throw anInvalidPluginExecutionExceptionto halt execution and roll back safely.
3. Selecting All Columns in Entity Images
- Symptom: Degraded system performance during bulk updates; occasional
MessageMaxSizeExceedederrors on Service Bus integrations. - Root Cause: The step registration has Pre/Post images configured with "All Attributes" selected. This forces Dataverse to execute a heavy SQL query to retrieve every column for the record, and bloats the serialized context payload.
- Diagnostic Steps: Open the step registration in the PRT. Inspect the image properties and check the selected attributes list.
- Fix: Edit the image in the PRT and check only the specific columns required for the business logic.
4. Multi-threading and Parallel Execution in Plugins
- Symptom: Intermittent sandbox worker process crashes (
Sandbox Worker process crashed). - Root Cause: The plugin code uses
System.Threading.Thread,Task.Run, or parallel loops (Parallel.ForEach). The sandbox isolation environment strictly prohibits spawning background threads. Any unhandled exception on a background thread crashes the entire worker process, terminating all concurrent plugins running in that process. - Diagnostic Steps: Search the codebase for multi-threading keywords.
- Fix: Always write strictly synchronous, single-threaded code within plugins. Let the Dataverse Asynchronous Service handle concurrency.
5. Assuming Pre-Image is Available on Create (or Post-Image on Delete)
- Symptom: Null reference exception (
System.NullReferenceException) when executing the plugin. - Root Cause: The plugin code attempts to read
context.PreEntityImages["PreImage"]during aCreateoperation, orcontext.PostEntityImages["PostImage"]during aDeleteoperation. - Diagnostic Steps: Check the execution trace. Identify the line throwing the null reference.
- Fix: Add defensive checks to verify the message type before accessing images, or verify the image collection contains the key:
if (context.PreEntityImages.Contains("PreImage")) { ... }
6. Exceeding the 2-Minute Execution Timeout
- Symptom: Error:
The plug-in execution failed because of a timeout. - Root Cause: The plugin performs synchronous, blocking HTTP calls to a slow external API, or executes highly inefficient database queries.
- Diagnostic Steps: Check the duration in the Plugin Trace Log. If it is exactly 120,000 ms, it is a timeout.
- Fix:
- Set a strict timeout on your
HttpClient(e.g., 15 seconds). - Offload the integration logic to an Asynchronous step so it does not block the user thread.
- Optimize queries by using indexes and avoiding nested loops.
- Set a strict timeout on your
7. Exceeding the 192 KB Payload Limit for Service Bus
- Symptom: Messages fail to post to Azure Service Bus; errors indicate payload size exceeded.
- Root Cause: The execution context contains large text fields, attachments, or extensive Pre/Post images, pushing the serialized payload over 192 KB.
- Diagnostic Steps: Inspect the size of the records being updated. Check if the payload contains large memo fields or base64 attachments.
- Fix:
- Exclude large columns (like attachments or description fields) from the step's filtering attributes and entity images.
- Write a custom plugin that extracts only the necessary data, constructs a lightweight custom JSON payload, and posts it manually using an external HTTP client or a custom Service Bus integration.
8. Thread-Safety Issues with Stateful Plugins
- Symptom: Intermittent bugs where data from one user's request appears in another user's execution context.
- Root Cause: The plugin class contains class-level instance variables (fields) to store state. Because Dataverse instantiates a plugin class once and caches it to serve multiple concurrent requests, instance variables are not thread-safe and will be overwritten by concurrent threads.
- Diagnostic Steps: Inspect the plugin class definition. Look for any fields that are not
readonlyor static constants. - Fix: Develop plugins to be completely stateless. Store all state within local variables inside the
Executemethod.
9. Missing Assembly Signing (Strong Name)
- Symptom: Error during assembly registration:
The assembly must be signed with a strong name key. - Root Cause: The compiled
.dllwas not signed with a strong-name key file (.snk). - Diagnostic Steps: Check the project properties or
.csprojfile for signing configurations. - Fix: Generate a key file using
sn -k KeyPair.snkand configure the project to sign the assembly on build.
10. Accessing SharedVariables without Checking ParentContext
- Symptom: Shared variables passed from a Pre-Validation step are missing (null) in the Post-Operation step.
- Root Cause: Shared variables registered in Stage 10 (Pre-Validation) are stored in the context of that specific execution pipeline. Because Stage 10 runs outside the main transaction, when the transaction starts for Stage 20/40, a new context is created. The original variables are moved to the
ParentContext. - Diagnostic Steps: Check if
context.SharedVariablescontains the key. If not, checkcontext.ParentContext.SharedVariables. - Fix: Use a helper method to search the context hierarchy recursively:
public static T GetSharedVariable<T>(IPluginExecutionContext context, string key){IPluginExecutionContext currentContext = context;while (currentContext != null){if (currentContext.SharedVariables.ContainsKey(key)){return (T)currentContext.SharedVariables[key];}currentContext = currentContext.ParentContext;}return default(T);}
12. Exam Focus: Key Facts & Edge Cases
To excel in the PL-400 exam, memorize these critical platform limits, behaviors, and API signatures:
- Execution Stages:
- Pre-Validation (Stage 10): Runs outside the database transaction. Ideal for validation.
- Pre-Operation (Stage 20): Runs inside the database transaction. Ideal for modifying target attributes.
- Post-Operation (Stage 40): Runs inside the transaction (if sync) or outside (if async). Ideal for related records and integrations.
- Asynchronous Steps: Can only be registered in Post-Operation (Stage 40).
- Platform Limits:
- Execution Timeout: Exactly 2 minutes (120 seconds) for both sync and async steps.
- Depth Limit: Maximum of 8. Execution is aborted on the 9th call within a 1-hour window.
- Sandbox Memory Limit: Exactly 100 MB per worker process.
- Service Bus Payload Limit: Exactly 192 KB. Truncation occurs if exceeded.
- Webhook Payload Limit: Exactly 256 KB.
- Entity Images:
- Pre-Image: Not available on
Create. - Post-Image: Not available on
Delete. - Images are accessed via
context.PreEntityImagesandcontext.PostEntityImagescollections using the registered Entity Alias as the key.
- Pre-Image: Not available on
- Asynchronous Retry:
- To trigger a retry, you must throw an
InvalidPluginExecutionExceptionpassingOperationStatus.Retryin the constructor. - The Asynchronous Service will retry the job up to 4 times before marking it as failed.
- To trigger a retry, you must throw an
- SharedVariables:
- Used to pass data between plugins executing in different stages of the same message.
- If passing data from Pre-Validation (Stage 10) to Post-Operation (Stage 40), you must access the variable via the
ParentContext.SharedVariablescollection.
- Elastic Tables:
- Elastic tables do not support database transactions.
- A failure in a Post-Operation plugin registered on an elastic table will not roll back the core operation. Avoid throwing
InvalidPluginExecutionExceptionin Stage 40 for elastic tables.
- Service Endpoint Contracts:
- Supported contracts:
OneWay,TwoWay,Queue,Topic,Event Hub,Webhook,Event Grid. - Synchronous steps on Service Endpoints will block the user thread and roll back the transaction if the post fails.
- Supported contracts: