Pre-validation and Pre-operation Pipeline Stage Development
1. Conceptual Foundation
The Microsoft Dataverse event execution pipeline is a highly optimized, structured framework that processes data operations and executes custom business logic. To build robust, enterprise-grade extensions, developers must understand the lifecycle of a message request as it flows through the pipeline.
[Client Request]
│
▼
┌────────────────────────────────────────────────────────┐
│ Stage 10: Pre-Validation (Outside Transaction) │
│ - Input parameter validation │
│ - Structural & business rule checks │
│ - Runs before database locks are acquired │
└────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ Security & Privilege Checks (Platform Core) │
└────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ Stage 20: Pre-Operation (Inside Transaction) │
│ - Modify Target entity attributes directly │
│ - Read/write operations participate in transaction │
│ - Avoid cancellation here due to rollback cost │
└────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ Stage 30: Main Operation (Platform Core) │
│ - Database write (INSERT, UPDATE, DELETE) │
└────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ Stage 40: Post-Operation (Inside Transaction) │
│ - Create child records, integrate with external systems│
│ - Asynchronous steps queued here │
└────────────────────────────────────────────────────────┘
│
▼
[Transaction Commit / Response Sent]
The Event Execution Pipeline Stages
The pipeline is divided into four distinct stages, each executed in a specific sequence relative to the core database operation:
- Pre-Validation (Stage 10): This stage executes prior to the main system operation and before security validation checks are performed to verify that the calling user has the necessary privileges. Crucially, Stage 10 runs outside the main database transaction for the initial operation. This makes it the ideal location for business validation rules. If a validation check fails here, the operation can be cancelled by throwing an exception without incurring the performance penalty of rolling back an active database transaction.
- Pre-Operation (Stage 20): This stage executes after security checks have passed but before the core database operation (Stage 30) occurs. Stage 20 runs inside the database transaction. It is designed specifically for modifying the attributes of the target record before they are written to the database. Because it runs within the transaction, any database reads or writes performed here participate in the same transaction.
- Main Operation (Stage 30): This is an internal platform stage that executes the core database operation (such as SQL
INSERT,UPDATE, orDELETE). Custom plug-ins cannot be registered in this stage. - Post-Operation (Stage 40): This stage executes after the database write has occurred but before the transaction is committed. It runs inside the database transaction. It is used to perform actions that depend on the core operation succeeding, such as creating related child records, updating rollup fields, or modifying output parameters. Asynchronous plug-in steps also hook into this stage but execute outside the transaction boundary via the asynchronous queue.
Transaction Boundaries and Rollback Mechanics
A database transaction is a sequence of operations performed as a single logical unit of work. In Dataverse, a SQL Server transaction is initiated at the start of Stage 20 (Pre-Operation) and encompasses Stage 20, Stage 30, and Stage 40 (Post-Operation).
If any synchronous plug-in registered in Stage 20 or Stage 40 throws an exception, or if the core database operation fails, the platform automatically issues a SQL ROLLBACK command. This rolls back all data modifications made within that transaction, including those made by the core operation and any custom operations executed by plug-ins within the transaction.
The performance cost of a transaction rollback is high. When a rollback occurs, SQL Server must undo every write operation by reading the transaction log and reversing the changes. This consumes significant CPU, memory, and I/O resources, and extends the duration of database locks, which can lead to blocking and SQL timeouts for concurrent users. Therefore, validation logic should be placed in Stage 10 (Pre-Validation) to prevent the transaction from starting if the data is invalid.
Modifying the Target Entity Before the Database Write
In Stage 20 (Pre-Operation), the execution context contains the Target entity within the InputParameters collection. The Target represents the record being created or updated.
Because Stage 20 executes before the database write, any modifications made to the attributes of the Target entity in this stage are automatically included in the SQL statement executed in Stage 30. This is a critical optimization:
- No Extra Database Calls: Modifying the
Targetin Stage 20 does not require callingIOrganizationService.Update(). The platform simply reads the modifiedTargetfrom the execution context when performing the write. - Prevention of Infinite Loops: Calling
IOrganizationService.Update()on the same record within a plug-in triggers the update message again, which can cause an infinite loop. Modifying theTargetdirectly in Stage 20 avoids this entirely.
Throwing InvalidPluginExecutionException
To cancel an operation and display a meaningful message to the user, a plug-in must throw a Microsoft.Xrm.Sdk.InvalidPluginExecutionException.
When this exception is thrown:
- The platform halts execution of the pipeline.
- If a transaction is active (Stages 20 or 40), it is rolled back.
- The error message passed to the exception constructor is propagated back to the caller.
- In model-driven apps, this message is displayed in a clean, user-facing dialog box. In canvas apps or custom integrations, it is returned in the API response payload.
If any other exception type (such as NullReferenceException or SqlException) is allowed to bubble up from a synchronous plug-in, the platform wraps it and displays a generic error message: "An unexpected error occurred from ISV code." This prevents users and administrators from diagnosing the issue. Developers must catch all unexpected exceptions and wrap them in an InvalidPluginExecutionException.
2. Architecture & Decision Matrix
When designing business logic in Dataverse, developers must choose the most appropriate tool. The table below compares the primary implementation options:
| Criteria | Synchronous Plug-in (Pre-Validation) | Synchronous Plug-in (Pre-Operation) | Power Automate Cloud Flow (Automated) | Low-Code Plug-in (Power Fx) | Client-Side JavaScript (OnSave) |
|---|---|---|---|---|---|
| Complexity | Medium to High (C# development required) | Medium to High (C# development required) | Low to Medium (Visual designer) | Low (Power Fx formulas) | Medium (JavaScript/TypeScript) |
| Scalability | High (Compiled code, low latency) | High (Compiled code, low latency) | Medium (Asynchronous, API limits apply) | High (Managed by platform) | Low (Runs in user's browser) |
| Execution Context | Server-side, runs before transaction and security checks | Server-side, runs inside transaction, after security checks | Server-side, asynchronous, runs after transaction commits | Server-side, synchronous or asynchronous | Client-side, runs in browser before data is sent to server |
| Offline Support | No | No | No | No | Yes (via Mobile Offline profiles) |
| Licensing | Included in standard Power Apps/Dataverse licenses | Included in standard Power Apps/Dataverse licenses | May require Power Automate per-user/per-flow licenses | Included in standard Power Apps/Dataverse licenses | Included in standard Power Apps/Dataverse licenses |
| PL-400 Exam Relevance | High (Core focus of pipeline validation) | High (Core focus of data manipulation) | Medium (Focus on integration scenarios) | Medium (Modern alternative, in preview/early adoption) | High (Focus on client-side validation) |
Architectural Decision Rules
- Use Client-Side JavaScript (OnSave) when you need to provide immediate feedback to the user in the UI, or when you want to prevent a form submission without making a round-trip to the server. Do not rely on JavaScript for security or data integrity, as it can be bypassed by API calls.
- Use Synchronous Plug-in (Pre-Validation - Stage 10) when you must enforce business rules and data validation on the server side, regardless of the client application (Model-Driven App, Canvas App, Web API, or Power Pages). This stage is ideal because it runs before database locks are acquired, minimizing transaction overhead.
- Use Synchronous Plug-in (Pre-Operation - Stage 20) when you need to manipulate or enrich the data of the incoming record (the
Target) before it is written to the database. This ensures the modifications are saved in a single database write operation without triggering additional update events. - Use Power Automate Cloud Flows for asynchronous, long-running processes, or when you need to integrate with external systems that do not require real-time transactional consistency.
3. Step-by-Step Implementation Guide
This guide walks through setting up, writing, deploying, and configuring a synchronous Dataverse plug-in that implements validation in Stage 10 and data manipulation in Stage 20.
Step 1: Project Setup in Visual Studio
- Open Visual Studio and select Create a new project.
- Choose Class Library (.NET Framework) and click Next.
- Configure the project:
- Project name:
Contoso.Dataverse.Plugins - Framework: Select .NET Framework 4.6.2 (required for Dataverse plug-ins).
- Click Create.
- Project name:
- Open the NuGet Package Manager Console and install the core assemblies:
Install-Package Microsoft.CrmSdk.CoreAssemblies -Version 9.0.2.56
- Right-click the project, select Properties, and navigate to the Signing tab.
- Check Sign the assembly.
- Under Choose a strong name key file, select New.
- Enter a key file name (e.g.,
ContosoPlugins.snk) and uncheck password protection for simplicity in build pipelines.
Step 2: Writing the Plug-in Code
Create a new class file named PreOperationAccountValidation.cs. This plug-in will:
- Validate that the
Credit Limit(creditlimit) is not negative during creation or update (Stage 10 validation). - Automatically populate the
Description(description) field with a standardized audit note if the credit limit is modified (Stage 20 manipulation).
The complete, compilable code is provided in Section 4.
Step 3: Registering the Assembly and Steps
To deploy the plug-in, use the Plug-in Registration Tool (PRT):
- Download the PRT using PowerShell if you do not have it:
Alternatively, download the NuGet package# Run in PowerShell to download the latest tooling[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12$sourceNugetURL = "https://api.nuget.org/v3/index.json"Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -ForceInstall-Module -Name Microsoft.PowerApps.Administration.PowerShell -Force
Microsoft.CrmSdk.XrmTooling.PluginRegistrationTooland extract it. - Launch
PluginRegistration.exeand click Create New Connection. - Authenticate using your Dataverse environment credentials.
- Click Register > Register New Assembly.
- Browse to the location of your compiled DLL (e.g.,
bin/Debug/Contoso.Dataverse.Plugins.dll) and click Register Selected Plugins.
Registering the Pre-Validation Step (Stage 10)
- Right-click the registered plug-in type
Contoso.Dataverse.Plugins.PreOperationAccountValidationand select Register New Step. - Configure the step:
- Message:
Create - Primary Entity:
account - Event Pipeline Stage of Execution:
PreValidation - Execution Mode:
Synchronous - Deployment:
Server - Click Register New Step.
- Message:
- Register a second step for the
Updatemessage:- Message:
Update - Primary Entity:
account - Filtering Attributes: Select
creditlimit. This ensures the plug-in only runs when the credit limit is modified. - Event Pipeline Stage of Execution:
PreValidation - Execution Mode:
Synchronous - Click Register New Step.
- Message:
Registering the Pre-Operation Step (Stage 20)
- Right-click the plug-in type again and select Register New Step.
- Configure the step:
- Message:
Create - Primary Entity:
account - Event Pipeline Stage of Execution:
PreOperation - Execution Mode:
Synchronous - Click Register New Step.
- Message:
- Register a second step for the
Updatemessage:- Message:
Update - Primary Entity:
account - Filtering Attributes: Select
creditlimit. - Event Pipeline Stage of Execution:
PreOperation - Execution Mode:
Synchronous - Click Register New Step.
- Message:
Step 4: Configuring Entity Images
For the Update steps, the plug-in needs to compare the new credit limit with the previous value. To avoid making an expensive retrieve call to the database, register a Pre-Image:
- In the PRT, right-click the
Updatestep registered in thePreOperationstage and select Register New Image. - Configure the Image:
- Image Type:
Pre Image - Name:
PreImage - Entity Alias:
PreImage - Parameters: Select
creditlimitanddescription. - Click Register Image.
- Image Type:
4. Complete Code Reference
The following is a complete, production-grade, compilable C# class file implementing the business logic described above. It includes robust error handling, depth checking, and tracing.
//-----------------------------------------------------------------------
// <copyright file="PreOperationAccountValidation.cs" company="Contoso">
// Copyright (c) Contoso. All rights reserved.
// </copyright>
// <summary>
// Implements business validation in Pre-Validation (Stage 10) and
// data manipulation in Pre-Operation (Stage 20) for the Account entity.
// </summary>
//-----------------------------------------------------------------------
namespace Contoso.Dataverse.Plugins
{
using System;
using System.Globalization;
using Microsoft.Xrm.Sdk;
/// <summary>
/// Plug-in registered on the Create and Update messages of the Account entity.
/// Performs validation in Stage 10 and modifies the Target in Stage 20.
/// </summary>
public sealed class PreOperationAccountValidation : IPlugin
{
/// <summary>
/// The maximum allowed execution depth to prevent infinite loops.
/// </summary>
private const int MaxDepthLimit = 2;
/// <summary>
/// Executes the plug-in logic.
/// </summary>
/// <param name="serviceProvider">The service provider containing the execution context.</param>
public void Execute(IServiceProvider serviceProvider)
{
if (serviceProvider == null)
{
throw new ArgumentNullException(nameof(serviceProvider));
}
// Obtain the execution context, tracing service, and organization service factory
IPluginExecutionContext context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
ITracingService tracingService = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
IOrganizationServiceFactory serviceFactory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
if (context == null || tracingService == null || serviceFactory == null)
{
return;
}
tracingService.Trace("PreOperationAccountValidation: Execution started. Stage: {0}, Message: {1}", context.Stage, context.MessageName);
// Guard against infinite loops by checking execution depth
if (context.Depth > MaxDepthLimit)
{
tracingService.Trace("PreOperationAccountValidation: Execution depth {0} exceeds limit of {1}. Aborting execution.", context.Depth, MaxDepthLimit);
return;
}
// Verify that the input parameters contain the Target entity
if (!context.InputParameters.Contains("Target") || !(context.InputParameters["Target"] is Entity))
{
tracingService.Trace("PreOperationAccountValidation: Target entity is missing or invalid. Aborting execution.");
return;
}
Entity targetEntity = (Entity)context.InputParameters["Target"];
// Verify that the target entity is an account
if (targetEntity.LogicalName != "account")
{
tracingService.Trace("PreOperationAccountValidation: Target entity logical name '{0}' is not 'account'. Aborting.", targetEntity.LogicalName);
return;
}
try
{
// Route execution based on the pipeline stage
switch (context.Stage)
{
case 10: // Pre-Validation
this.HandlePreValidation(targetEntity, context, tracingService);
break;
case 20: // Pre-Operation
this.HandlePreOperation(targetEntity, context, tracingService);
break;
default:
tracingService.Trace("PreOperationAccountValidation: Plug-in registered on invalid stage: {0}. No action taken.", context.Stage);
break;
}
}
catch (InvalidPluginExecutionException)
{
// Re-throw expected platform exceptions to propagate the user-facing message
throw;
}
catch (Exception ex)
{
// Catch unexpected exceptions, log them, and wrap them in an InvalidPluginExecutionException
tracingService.Trace("PreOperationAccountValidation: Unexpected exception occurred: {0}", ex.ToString());
throw new InvalidPluginExecutionException(
OperationStatus.Failed,
"An unexpected error occurred during account processing. Please contact your system administrator.",
ex);
}
tracingService.Trace("PreOperationAccountValidation: Execution completed successfully.");
}
/// <summary>
/// Handles validation logic in the Pre-Validation stage (Stage 10).
/// Runs outside the database transaction.
/// </summary>
private void HandlePreValidation(Entity target, IPluginExecutionContext context, ITracingService tracingService)
{
tracingService.Trace("PreOperationAccountValidation: Processing Pre-Validation rules.");
// Rule 1: Credit Limit must not be negative
if (target.Contains("creditlimit"))
{
Money creditLimit = target.GetAttributeValue<Money>("creditlimit");
if (creditLimit != null && creditLimit.Value < 0)
{
tracingService.Trace("Pre-Validation Failure: Credit Limit is negative ({0}).", creditLimit.Value);
// Throwing InvalidPluginExecutionException cancels the operation and displays the message to the user
throw new InvalidPluginExecutionException(
OperationStatus.Failed,
"The Credit Limit cannot be negative. Please enter a value of 0 or greater.");
}
}
}
/// <summary>
/// Handles data manipulation logic in the Pre-Operation stage (Stage 20).
/// Runs inside the database transaction. Modifies the Target entity directly.
/// </summary>
private void HandlePreOperation(Entity target, IPluginExecutionContext context, ITracingService tracingService)
{
tracingService.Trace("PreOperationAccountValidation: Processing Pre-Operation rules.");
// Rule 2: If Credit Limit is modified, update the description field with an audit note
if (target.Contains("creditlimit"))
{
Money newLimit = target.GetAttributeValue<Money>("creditlimit");
decimal newValue = newLimit != null ? newLimit.Value : 0;
decimal oldValue = 0;
if (context.MessageName.Equals("Update", StringComparison.OrdinalIgnoreCase))
{
// Retrieve the previous value from the Pre-Image to compare
if (context.PreEntityImages.Contains("PreImage") && context.PreEntityImages["PreImage"] is Entity)
{
Entity preImage = context.PreEntityImages["PreImage"];
Money oldLimit = preImage.GetAttributeValue<Money>("creditlimit");
oldValue = oldLimit != null ? oldLimit.Value : 0;
}
else
{
tracingService.Trace("Warning: PreImage is missing from the execution context. Cannot compare old value.");
}
}
// Only update the description if the value has actually changed (or if it's a Create operation)
if (context.MessageName.Equals("Create", StringComparison.OrdinalIgnoreCase) || newValue != oldValue)
{
string auditNote = string.Format(
CultureInfo.InvariantCulture,
"Credit limit updated from {0:C} to {1:C} on {2:yyyy-MM-dd HH:mm:ss} UTC.",
oldValue,
newValue,
DateTime.UtcNow);
tracingService.Trace("Pre-Operation: Modifying Target description attribute.");
// Modify the Target entity directly. This change is written to the database in Stage 30.
target["description"] = auditNote;
}
}
}
}
}
5. Configuration & Environment Setup
Solution Publisher Prefix Rules
When deploying custom tables or columns to support your plug-in logic, you must adhere to solution publisher prefix rules.
- Prefix Enforcement: All custom components must be prefixed with the unique prefix defined by your Solution Publisher (e.g.,
contoso_). - Logical Names: In your C# code, always reference tables and columns by their exact logical names (all lowercase, including the prefix, such as
contoso_project).
Environment Variables Schema Definitions
To avoid hardcoding configuration values (such as external API endpoints or threshold values) in your plug-in code, use Environment Variables within your Dataverse solution.
An Environment Variable consists of two tables:
- Environment Variable Definition (
environmentvariabledefinition): Defines the schema, data type (String, Number, Boolean, JSON, Secret), and default value. - Environment Variable Value (
environmentvariablevalue): Stores the environment-specific value (e.g., different values for Dev, Test, and Prod).
Retrieving Environment Variables in C#
To retrieve an environment variable value within a plug-in, query the environmentvariabledefinition and environmentvariablevalue tables using a QueryExpression:
public string GetEnvironmentVariable(IOrganizationService service, string schemaName)
{
QueryExpression query = new QueryExpression("environmentvariabledefinition")
{
ColumnSet = new ColumnSet("defaultvalue"),
Criteria = new FilterExpression(LogicalOperator.And)
};
query.Criteria.AddCondition("schemaname", ConditionOperator.Equal, schemaName);
LinkEntity linkValue = query.AddLink("environmentvariablevalue", "environmentvariabledefinitionid", "environmentvariabledefinitionid", JoinOperator.LeftOuter);
linkValue.Columns = new ColumnSet("value");
linkValue.EntityAlias = "val";
EntityCollection results = service.RetrieveMultiple(query);
if (results.Entities.Count > 0)
{
Entity def = results.Entities[0];
AliasedValue aliasedVal = def.GetAttributeValue<AliasedValue>("val.value");
if (aliasedVal != null)
{
return aliasedVal.Value.ToString();
}
return def.GetAttributeValue<string>("defaultvalue");
}
return null;
}
Secure vs. Unsecure Configuration
When registering a plug-in step in the Plug-in Registration Tool, you can provide Unsecure Configuration and Secure Configuration strings (usually formatted as XML or JSON).
┌─────────────────────────────────────────────────────────────────────────┐
│ Plug-In Step Registration │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Unsecure Configuration (Included in Solution Export) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ <config><threshold>5000</threshold></config> │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ Secure Configuration (Excluded from Solution Export - Encrypted) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ <config><apiKey>EncryptedSecretValue123</apiKey></config> │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
- Unsecure Configuration: This data is stored in the
SdkMessageProcessingSteptable. It is included when you export your solution. Use this for non-sensitive settings like thresholds, logging levels, or feature flags. - Secure Configuration: This data is stored in the
SdkMessageProcessingStepSecureConfigtable. It is not included in solution exports to prevent sensitive data from being exposed in source control or target environments. Use this for API keys, connection strings, or credentials.
Implementing a Plug-in Constructor to Accept Configuration
To read these configurations, implement a constructor in your plug-in class that accepts two string parameters:
public class ConfigurablePlugin : IPlugin
{
private readonly string unsecureConfig;
private readonly string secureConfig;
public ConfigurablePlugin(string unsecure, string secure)
{
this.unsecureConfig = unsecure;
this.secureConfig = secure;
}
public void Execute(IServiceProvider serviceProvider)
{
// Parse and use the configuration strings here
}
}
6. Security & Permission Matrix
The following matrix details the security roles, privileges, and permissions required for registering, deploying, and executing plug-ins in Microsoft Dataverse.
| Principal | Permission / Privilege | Scope | Reason |
|---|---|---|---|
| System Administrator | Full access to all tables and settings | Global | Required to register assemblies, steps, and images using the Plug-in Registration Tool. |
| System Customizer | Read/Write on customization tables | Global | Can import solutions containing plug-in assemblies and steps, but cannot register new assemblies directly unless granted explicit privileges. |
| Deployment Administrator | prvBypassCustomPlugins | Global | Allows the user to bypass custom synchronous plug-in execution during bulk data operations. |
| Application User (S2S) | Read/Write on target tables | Business Unit / Parent | The identity under which integration plug-ins execute when triggered by external API calls. |
| Calling User | Read/Write on target tables | User / Team / BU | By default, plug-ins execute in the security context of the calling user. The user must have privileges to perform the core operation (e.g., Create Account). |
| Impersonated User | Read/Write on target tables | Global / BU | If the plug-in step is configured to "Run in User's Context" as a specific user, that user must have the necessary privileges to execute the operations performed by the plug-in. |
Security Implications of Impersonation
When registering a plug-in step, you can configure the Run in User's Context property:
- Calling User (Default): The plug-in runs with the security privileges of the user who initiated the operation. If the user does not have permission to read or write a related table that the plug-in accesses, the plug-in execution fails with an "Access Denied" error.
- SYSTEM / Administrator: You can configure the step to run under the context of a highly privileged integration user or system administrator. This allows the plug-in to perform elevated operations (such as writing to an audit log table that standard users cannot access) while the calling user only needs permissions for the primary operation.
Field-Level Security (FLS) Behavior
If Field-Level Security is enabled on an attribute:
- Pre-Validation/Pre-Operation: The platform enforces FLS before the plug-in executes. If the calling user does not have Create or Update privileges for an FLS-protected field, and that field is included in the
Targetentity, the operation is blocked before the plug-in runs. - Bypassing FLS in Code: If the plug-in code needs to modify an FLS-protected field, it should execute using an
IOrganizationServiceinstance created with a system user context (impersonation) that has the necessary Field Security Profile assigned.
7. Pipeline Execution Internals
Execution Pipeline Stages Reference
The table below maps the logical pipeline stages to their internal numeric values and execution characteristics:
| Stage Name | Stage Value | Transaction Context | Execution Mode | Bypassed by Custom Logic Bypass? |
|---|---|---|---|---|
| Pre-Validation | 10 | Outside (Initial Operation) | Synchronous | Yes (if CustomSync is specified) |
| Pre-Operation | 20 | Inside | Synchronous | Yes (if CustomSync is specified) |
| Main Operation | 30 | Inside | Platform Core | No |
| Post-Operation | 40 | Inside | Synchronous / Asynchronous | Yes (Synchronous bypassed by CustomSync; Asynchronous bypassed by CustomAsync) |
Transaction Boundaries and Lock Escalation
When an operation enters Stage 20 (Pre-Operation), Dataverse opens a SQL Server transaction.
[Stage 10: Pre-Validation]
│ (No Transaction Active)
▼
[Stage 20: Pre-Operation] ──► SQL BEGIN TRANSACTION
│ (Exclusive Locks Acquired on Target Rows)
▼
[Stage 30: Main Operation]
│ (Locks Held, Indexes Updated)
▼
[Stage 40: Post-Operation]
│ (Locks Held, Child Records Processed)
▼
[Transaction Commit] ───────► SQL COMMIT TRANSACTION (Locks Released)
- Lock Acquisition: During Stage 30 (Main Operation), SQL Server acquires exclusive locks (
Xlocks) on the rows being modified. These locks prevent other concurrent transactions from reading or modifying these rows. - Lock Duration: Because the transaction remains open through Stage 40 (Post-Operation), any custom logic executed in Stage 40 extends the duration of these locks. If a Stage 40 plug-in makes an external web service call that takes 5 seconds, those SQL locks are held for the entire 5 seconds, severely impacting system throughput and causing concurrency bottlenecks.
- Lock Escalation: If a transaction modifies a large number of rows (typically >5,000), SQL Server may escalate row-level locks to a full table lock, blocking all other operations on that table.
Sandbox Isolation Mode (Isolation Mode = 2)
All custom plug-ins in Dataverse environments execute within an isolated, sandboxed worker process (SandboxAppDomain). This environment enforces strict security and resource constraints:
- No Local File System Access: Plug-ins cannot read or write files to the local server disk.
- No Registry Access: Access to the Windows Registry is blocked.
- Network Restrictions: Outbound network calls are restricted to standard web protocols (HTTP/HTTPS on ports 80 and 443). Direct TCP/UDP socket connections are blocked.
- IP Resolution: Outbound calls must resolve to public IP addresses. Access to local intranet addresses (RFC 1918) is blocked.
- Execution Timeout: A hard 2-minute (120 seconds) timeout is enforced for the entire pipeline execution. If a plug-in exceeds this limit, the sandbox worker process is terminated, and a
TimeoutExceptionis thrown, rolling back the transaction. - Payload Size Limit: The maximum payload size for a single message transaction is 116.85 MB. Exceeding this throws error code
-2147220970("Message size exceeded when sending context to Sandbox").
Depth and Loop-Guard Limits
To prevent infinite recursion (e.g., an update plug-in on Account that updates the same Account, triggering itself indefinitely), Dataverse employs a loop-guard mechanism:
- Depth Counter: The
IExecutionContext.Depthproperty tracks the current nesting level of the execution chain. Every time a plug-in or workflow executes an operation that triggers another plug-in, the depth increments by 1. - Platform Limit: If the depth counter reaches 8 within a 1-hour window, the platform aborts execution and throws an exception.
- Defensive Coding: Developers should implement custom depth checks (typically limiting depth to 1 or 2) to fail fast and log a clear trace message before the platform limit is hit.
8. Error Handling & Retry Patterns
Common Failure Modes and Remediation
The table below lists common failure modes encountered during plug-in execution, along with their root causes and correct remediation strategies:
| Error Code | Exception Type / Message | Root Cause | Remediation |
|---|---|---|---|
-2147220891 | InvalidPluginExecutionException | Custom business rule validation failure. | Display a clear, localized message to the user explaining how to correct the input data. |
-2147204783 | Sql error: Execution Timeout Expired | SQL blocking caused by long-running transactions or lock escalation. | Optimize plug-in code to reduce transaction duration. Move non-essential logic to asynchronous Post-Operation steps. |
-2146893812 | ISV code reduced the open transaction count | The plug-in caught an exception from an IOrganizationService call inside a transaction and swallowed it, preventing the platform from rolling back correctly. | Never swallow exceptions from service calls inside a synchronous plug-in. Allow the exception to bubble up or throw an InvalidPluginExecutionException. |
-2147220911 | There is no active transaction | A plug-in attempted to perform a database operation after the transaction was already aborted by a previous failure. | Ensure proper exception handling and do not attempt subsequent database writes if a prior write failed. |
Implementing Custom HTTP Status Codes
When building custom Web APIs or integrations, you can map your plug-in exceptions to specific HTTP status codes using the PluginHttpStatusCode enumeration:
throw new InvalidPluginExecutionException(
OperationStatus.Failed,
(int)System.Net.HttpStatusCode.BadRequest, // Custom Error Code
"The requested operation is invalid due to business rule violations.",
PluginHttpStatusCode.BadRequest, // Maps to HTTP 400 in Web API
null);
Transient Error Retry Pattern with Exponential Back-off
When making outbound web service calls from a plug-in, transient network failures can occur. Implement a robust retry pattern with exponential back-off to handle these gracefully:
using System;
using System.Net.Http;
using System.Threading;
using Microsoft.Xrm.Sdk;
public static class HttpRetryHelper
{
public static HttpResponseMessage ExecuteWithRetry(Func<HttpResponseMessage> action, ITracingService tracing, int maxRetries = 3)
{
int delay = 1000; // Start with a 1-second delay
for (int i = 0; i < maxRetries; i++)
{
try
{
HttpResponseMessage response = action();
if (response.IsSuccessStatusCode)
{
return response;
}
tracing.Trace("HTTP Call failed with status code: {0}. Retrying...", response.StatusCode);
}
catch (Exception ex)
{
tracing.Trace("Transient error encountered: {0}", ex.Message);
if (i == maxRetries - 1)
{
throw;
}
}
Thread.Sleep(delay);
delay *= 2; // Double the delay for exponential back-off
}
throw new InvalidPluginExecutionException("External service call failed after maximum retries.");
}
}
9. Performance Optimisation & Limits
Critical Performance Thresholds
- 2-Second Execution Target: While the platform enforces a hard 2-minute timeout, synchronous plug-ins should execute in under 2 seconds (ideally under 100 milliseconds) to prevent degrading the user experience.
- Memory Limit: The sandbox worker process has a memory limit of approximately 1 GB per node. Memory leaks (e.g., storing large datasets in static variables) will cause the process to crash, affecting all plug-ins running on that node.
Optimization Strategies
1. Register Filtering Attributes
For Update messages, never leave the filtering attributes blank. If blank, the plug-in executes on every update to any column on the table. Specifying filtering attributes ensures the plug-in only runs when the columns it cares about are modified.
2. Avoid Retrieve Calls (Use Entity Images)
If your plug-in needs to know the value of a column before the update occurred, do not call IOrganizationService.Retrieve(). This adds an unnecessary database round-trip. Instead, register a Pre-Image during step registration and read the value from context.PreEntityImages.
3. Write Stateless Plug-ins
The platform caches plug-in class instances to improve performance. This means the class constructor is only called once, and the Execute method is called concurrently across multiple threads for subsequent requests.
- No Instance Fields: Never store request-specific data in class-level instance fields or static variables. Always use local variables within the
Executemethod to ensure thread safety.
public class StatefulPluginBad : IPlugin
{
// BAD: This field is shared across concurrent threads!
private string accountName;
public void Execute(IServiceProvider serviceProvider)
{
// Thread A writes to this field, then Thread B overwrites it before Thread A reads it.
this.accountName = "ThreadA_Account";
}
}
10. ALM & Deployment Checklist
Solution Packaging Representation
In a Dataverse solution, plug-in assemblies and steps are represented as XML elements within the customizations.xml file.
Plug-in Assembly XML Schema Example
<PluginAssembly AssemblyId="g9f8e7d6-c5b4-a3f2-e1d0-c9b8a7f6e5d4" Name="Contoso.Dataverse.Plugins">
<SourceType>0</SourceType> <!-- 0 = Database -->
<Path>Contoso.Dataverse.Plugins.dll</Path>
<Version>1.0.0.0</Version>
<PublicKeyToken>1234567890abcdef</PublicKeyToken>
<Culture>neutral</Culture>
<IsolationMode>2</IsolationMode> <!-- 2 = Sandbox -->
<PluginTypes>
<PluginType TypeName="Contoso.Dataverse.Plugins.PreOperationAccountValidation" FriendlyName="PreOperationAccountValidation">
<Description>Validates and manipulates account data.</Description>
</PluginType>
</PluginTypes>
</PluginAssembly>
Plug-in Step XML Schema Example
<SdkMessageProcessingStep SdkMessageProcessingStepId="a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6" Name="Contoso.AccountValidation.PreOperation">
<PluginTypeId>g9f8e7d6-c5b4-a3f2-e1d0-c9b8a7f6e5d4</PluginTypeId>
<Stage>20</Stage> <!-- 20 = Pre-Operation -->
<SupportedDeployment>0</SupportedDeployment> <!-- 0 = Server Only -->
<InvocationSource>0</InvocationSource>
<Mode>0</Mode> <!-- 0 = Synchronous -->
<Rank>1</Rank> <!-- Execution Order -->
<SdkMessageId>Create</SdkMessageId>
<PrimaryEntityName>account</PrimaryEntityName>
</SdkMessageProcessingStep>
Power Platform Build Tools Pipeline YAML
The following YAML snippet demonstrates how to automate the build, testing, and deployment of your plug-in assembly using Azure DevOps pipelines:
trigger:
- main
pool:
vmImage: 'windows-latest'
variables:
solutionName: 'ContosoPluginsSolution'
buildConfiguration: 'Release'
steps:
- task: NuGetToolInstaller@1
- task: NuGetCommand@2
inputs:
restoreSolution: '**/*.sln'
- task: VSBuild@1
inputs:
solution: '**/*.sln'
configuration: '$(buildConfiguration)'
- task: VSTest@2
inputs:
platform: '$(buildPlatform)'
configuration: '$(buildConfiguration)'
# Pack the solution containing the plug-in assembly
- task: PowerPlatformPackSolution@2
inputs:
RawConnectionString: '$(ServiceConnection)'
SolutionSourceFolder: 'src/solutions/$(solutionName)'
SolutionOutputFile: '$(Build.ArtifactStagingDirectory)/$(solutionName).zip'
# Import the solution to the target environment
- task: PowerPlatformImportSolution@2
inputs:
RawConnectionString: '$(TargetEnvironmentConnection)'
SolutionInputFile: '$(Build.ArtifactStagingDirectory)/$(solutionName).zip'
OverwriteUnmanagedCustomizations: true
PublishWorkflowsAndPlugins: true
11. Common Pitfalls & Troubleshooting Guide
1. Swallowing Exceptions in Synchronous Plug-ins
- Symptom: The plug-in fails silently, or subsequent steps fail with error
-2146893812("ISV code reduced the open transaction count"). - Root Cause: The developer wrapped an
IOrganizationServicecall in atry-catchblock and swallowed the exception. This prevents the platform from rolling back the transaction immediately, leaving the transaction in an inconsistent state. - Diagnostic Steps: Check the Plugin Trace Log. Look for database errors followed by transaction count mismatch errors.
- Fix: Never swallow database exceptions. If you catch an exception, re-throw it or wrap it in an
InvalidPluginExecutionException.
2. Modifying the Target Entity in Post-Operation (Stage 40)
- Symptom: Changes made to the
Targetentity attributes in Stage 40 are not saved to the database. - Root Cause: Stage 40 executes after the database write has occurred. Modifying the
Targetin memory here has no effect on the database. - Diagnostic Steps: Verify the stage of execution. If registered in Stage 40, the modifications will be lost.
- Fix: Move the data manipulation logic to Stage 20 (Pre-Operation), or explicitly call
IOrganizationService.Update()in Stage 40 (which will trigger a new update message and incur performance overhead).
3. Infinite Loops Caused by Self-Updates
- Symptom: Plug-in execution fails with depth limit exceeded error (Depth = 8).
- Root Cause: An update plug-in calls
IOrganizationService.Update()on the same record that triggered it, without a termination condition. - Diagnostic Steps: Check the
Depthproperty in the trace log. If it increments rapidly to 8, an infinite loop is occurring. - Fix:
- If in Stage 20, modify the
Targetentity attributes directly instead of callingUpdate(). - Add a depth check at the beginning of the plug-in:
if (context.Depth > 1) return;. - Register filtering attributes so the plug-in only runs when specific columns change.
- If in Stage 20, modify the
4. Stateful Plug-in Implementations
- Symptom: Intermittent data corruption or unexpected values when multiple users execute the plug-in concurrently.
- Root Cause: Storing request-specific data in class-level instance fields. Because plug-in instances are cached and shared across threads, concurrent executions overwrite each other's data.
- Diagnostic Steps: Review the plug-in class definition. Look for any private or public instance fields that are modified within the
Executemethod. - Fix: Remove all instance fields. Pass data between methods using parameters, or use local variables within the
Executemethod.
5. Missing Filtering Attributes on Update Steps
- Symptom: High database CPU utilization and slow performance across the entire environment.
- Root Cause: Plug-ins registered on the
Updatemessage of a heavily used table (like Account or Contact) do not have filtering attributes configured. The plug-in executes on every single update, even if the relevant fields did not change. - Diagnostic Steps: Query the
SdkMessageProcessingSteptable to find steps registered onUpdatewith no filtering attributes. - Fix: Edit the step registration in the Plug-in Registration Tool and select only the specific columns required for the business logic.
6. Performing Long-Running Operations Inside Transactions
- Symptom: Users encounter frequent SQL timeout errors (
Execution Timeout Expired). - Root Cause: A synchronous plug-in in Stage 20 or Stage 40 is performing a long-running operation, such as calling an external API or processing a large batch of records. This holds SQL locks open, blocking other users.
- Diagnostic Steps: Measure the execution time of the plug-in using Application Insights. If it exceeds 2 seconds, it is a bottleneck.
- Fix: Move the long-running or external integration logic to an Asynchronous Post-Operation step, or offload it to a Power Automate flow or Azure Function.
7. Accessing SharedVariables Across Incompatible Stages
- Symptom:
context.SharedVariablesdoes not contain the expected key in a Post-Operation step. - Root Cause: The key was added in a Pre-Validation step, but the developer did not access the
ParentContextto retrieve it. - Diagnostic Steps: Check if the step where the variable was set and the step where it is read are in different execution contexts.
- Fix: To access shared variables set in Stage 10 (Pre-Validation) from Stage 20 or 40, use
context.ParentContext.SharedVariables.
8. Swallowing Sandbox Worker Process Crashes
- Symptom: The plug-in execution halts abruptly, and no trace logs are written.
- Root Cause: A
StackOverflowExceptionorOutOfMemoryExceptionoccurred in the plug-in code. These exceptions terminate the sandbox worker process immediately, preventing the platform from writing trace logs or returning a friendly error. - Diagnostic Steps: Check the environment's Application Insights logs for
PluginWorkerCrashException. - Fix: Ensure all recursive methods have clear termination conditions. Avoid loading large datasets into memory at once; use paging when querying records.
9. Hardcoded Schema Names and Guids
- Symptom: The plug-in works perfectly in the Dev environment but fails with "Record Not Found" or "Invalid Attribute" errors in Test or Prod.
- Root Cause: Hardcoding GUIDs of records (such as Teams, Queues, or Price Lists) or using incorrect schema names that differ between environments.
- Diagnostic Steps: Compare the schema names and referenced record GUIDs between the source and target environments.
- Fix: Use Environment Variables to store environment-specific GUIDs or configuration values. Always reference tables and columns by their exact logical names.
10. Using Web API (OData) Requests Inside Plug-ins
- Symptom: Plug-in execution is slow, and operations do not participate in the active database transaction.
- Root Cause: The plug-in code uses
HttpClientto make Web API calls back to the same Dataverse organization, rather than using the providedIOrganizationService. - Diagnostic Steps: Review the code for outbound HTTP calls targeting the organization's own URL.
- Fix: Always use the
IOrganizationServiceinstance obtained from the service provider. This ensures the operations run within the same process, use the same transaction context, and do not consume external API limits.
12. Exam Focus: Key Facts & Edge Cases
To prepare for the PL-400 exam, memorize the following high-yield facts and edge cases:
- Stage 10 (Pre-Validation) runs outside the database transaction for the initial operation. This is the best place for validation because throwing an exception here does not trigger a database rollback.
- Stage 20 (Pre-Operation) runs inside the database transaction. This is the only stage where you can modify the attributes of the
Targetentity directly in the execution context to have those changes saved in the core database write (Stage 30) without callingIOrganizationService.Update(). - The database transaction starts at Stage 20 and ends after Stage 40. Any synchronous plug-in registered in Stage 20 or Stage 40 participates in this transaction.
- Asynchronous plug-ins can only be registered in Stage 40 (Post-Operation). They run outside the database transaction and are processed by the asynchronous service queue.
InvalidPluginExecutionExceptionis the only exception type that allows you to display a custom, friendly message to the user in model-driven and canvas apps. All other exceptions are displayed as generic unexpected errors.- The
IExecutionContext.Depthproperty is used by the platform to prevent infinite loops. The default limit is 8 executions within 1 hour. - The Sandbox execution timeout is a hard 2-minute (120 seconds) limit. This cannot be increased.
- The maximum payload size for a plug-in execution context sent to the sandbox is 116.85 MB.
- To pass data between plug-ins executing in different stages of the same pipeline, use the
SharedVariablescollection. If passing data from Stage 10 to Stage 20/40, the receiving plug-in must accesscontext.ParentContext.SharedVariables. - To bypass custom plug-in execution during bulk data operations, the calling user must have the
prvBypassCustomPluginsprivilege, and the request must include theBypassBusinessLogicExecutionparameter with the valueCustomSync.