Writing Dataverse Plugins in C#: What the SDK Docs Leave Out
When Power Automate isn't fast enough and business rules aren't powerful enough, you write a plugin. Here's how to build, debug, and deploy server-side logic in Dataverse — with real code and real opinions.
There comes a point in every Dataverse project where Power Automate is too slow, business rules are too limited, and you need something that runs instantly, on the server, inside the transaction. That’s when you write a plugin.
Plugins are C# classes that run inside the Dataverse execution pipeline. They fire synchronously (or asynchronously) in response to data operations — create, update, delete, retrieve. They’re the most powerful extension point in the platform, and they’re also the easiest place to shoot yourself in the foot.
This is how I build them.
When to Use a Plugin
Before writing any code, make sure a plugin is actually the right choice.
| Scenario | Use |
|---|---|
| Set a field value based on other fields during save | Business rule (simplest) or plugin |
| Validate data and block save if invalid | Plugin (business rules can’t call external data) |
| Auto-number a record on creation | Plugin (pre-operation) |
| Send a notification after a record is updated | Power Automate (async is fine, no code needed) |
| Complex calculations involving multiple related records | Plugin |
| Call an external API during save and use the response | Plugin (sync) or Power Automate (if async is OK) |
| Cascade updates to child records | Plugin |
My rule: if the logic needs to run inside the database transaction (before the user sees the result), it’s a plugin. If it can happen a few seconds later in the background, Power Automate is usually simpler to maintain.
The Execution Pipeline
Every data operation in Dataverse goes through a pipeline with specific stages:
Pre-Validation runs before the database transaction starts. Use it for cheap checks — like rejecting a request early if a required field is missing. If you throw an error here, no transaction was ever opened, so it’s the cheapest failure path.
Pre-Operation runs inside the transaction, before the data is written. This is where you modify the record before it’s saved. Auto-numbering, calculated fields, injecting default values — all go here.
Post-Operation runs inside the transaction, after the data is written. The record now exists in the database (but the transaction hasn’t committed yet). Use this for creating related records, audit logging, or triggering side effects that need to happen atomically with the main operation.
If your post-operation plugin throws an error, the entire transaction rolls back — including the original create/update. This is powerful but dangerous.
Your First Plugin
Here’s a real example. When an Account is created, we auto-generate a number like ACC-00042 using a custom counter table.
The code
using Microsoft.Xrm.Sdk;
using System;
public class AccountAutoNumber : IPlugin
{
public void Execute(IServiceProvider serviceProvider)
{
var context = (IPluginExecutionContext)serviceProvider
.GetService(typeof(IPluginExecutionContext));
var factory = (IOrganizationServiceFactory)serviceProvider
.GetService(typeof(IOrganizationServiceFactory));
var tracingService = (ITracingService)serviceProvider
.GetService(typeof(ITracingService));
var service = factory.CreateOrganizationService(context.UserId);
// Only run on Create
if (context.MessageName != "Create")
return;
// Get the record being created
if (context.InputParameters["Target"] is not Entity target)
return;
tracingService.Trace("AccountAutoNumber: starting for {0}", target.Id);
try
{
// Get next number from counter table
int nextNumber = GetNextNumber(service, "account");
string accountNumber = $"ACC-{nextNumber:D5}";
// Set it on the record (pre-operation: this modifies the record before save)
target["accountnumber"] = accountNumber;
tracingService.Trace("AccountAutoNumber: assigned {0}", accountNumber);
}
catch (Exception ex)
{
tracingService.Trace("AccountAutoNumber failed: {0}", ex.Message);
throw new InvalidPluginExecutionException(
"Failed to generate account number. Please try again.", ex);
}
}
private int GetNextNumber(IOrganizationService service, string entityName)
{
// Query the counter table for this entity
var query = new Microsoft.Xrm.Sdk.Query.QueryExpression("abc_autonumbercounter")
{
ColumnSet = new Microsoft.Xrm.Sdk.Query.ColumnSet("abc_currentvalue"),
Criteria = new Microsoft.Xrm.Sdk.Query.FilterExpression()
};
query.Criteria.AddCondition("abc_entityname",
Microsoft.Xrm.Sdk.Query.ConditionOperator.Equal, entityName);
var results = service.RetrieveMultiple(query);
if (results.Entities.Count == 0)
{
// First time: create the counter record
var counter = new Entity("abc_autonumbercounter")
{
["abc_entityname"] = entityName,
["abc_currentvalue"] = 1
};
service.Create(counter);
return 1;
}
var counterRecord = results.Entities[0];
int current = counterRecord.GetAttributeValue<int>("abc_currentvalue");
int next = current + 1;
// Update the counter
counterRecord["abc_currentvalue"] = next;
service.Update(counterRecord);
return next;
}
}
Register this on the Create message for the Account table, Pre-Operation stage, Synchronous. The target["accountnumber"] assignment modifies the record before Dataverse writes it to the database — no second update needed.
The Three Services You Always Need
Every plugin starts by pulling three things from the service provider:
IPluginExecutionContext — tells you what’s happening. Which message (Create, Update, Delete), which entity, which stage, what data was passed in. The InputParameters["Target"] gives you the record being operated on.
IOrganizationService — your API to Dataverse. Create, update, delete, retrieve records. Always get it from the factory, never instantiate it yourself. Pass context.UserId to run as the calling user, or null to run as the system.
ITracingService — your only debugging tool in production. Everything you pass to Trace() shows up in the Plugin Trace Log. Use it generously. When something breaks at 2am, these traces are all you’ll have.
Pre and Post Entity Images
Images are snapshots of the record at specific points in the pipeline. You configure them during registration.
Pre-image: the record’s state before the current operation. Available on Update and Delete. Useful when you need to know what changed — compare the pre-image to the target to see which fields were modified.
Post-image: the record’s state after the operation. Available on Create (post-operation only) and Update. Useful when you need the complete record including fields you didn’t change.
// Get the pre-image (configured during registration as "PreImage")
Entity preImage = context.PreEntityImages["PreImage"];
string oldName = preImage.GetAttributeValue<string>("name");
// Get the current value from the target
string newName = target.GetAttributeValue<string>("name");
if (oldName != newName)
{
tracingService.Trace("Name changed from {0} to {1}", oldName, newName);
// Do something with the name change
}
Important: the Target entity on an Update only contains the fields that were actually changed. If a user edits the phone number but not the name, the Target won’t have the name field. That’s why you need pre-images — to get the full picture.
Error Handling
The only way to show an error to the user from a plugin is InvalidPluginExecutionException. Any other exception type will show a generic “an error has occurred” message that helps nobody.
throw new InvalidPluginExecutionException(
"Cannot deactivate this account — it has open opportunities.");
This message appears directly in the UI as a business error. Make it clear and actionable. Don’t show stack traces or technical details to end users.
For unexpected errors (null references, API failures), catch them and wrap:
catch (Exception ex)
{
tracingService.Trace("Unexpected error: {0}\n{1}", ex.Message, ex.StackTrace);
throw new InvalidPluginExecutionException(
"An error occurred while processing this record. Contact your admin.", ex);
}
The trace gives you the technical details. The exception gives the user a readable message.
Debugging
Plugin Trace Log
Turn it on: Settings → Administration → System Settings → Customization tab → Enable logging to plug-in trace log → All.
Every tracingService.Trace() call in your plugin writes here. Go to Settings → Plugin Trace Log to read them. Each entry shows the plugin name, message, stage, execution time, and your trace messages.
This is your primary debugging tool. I add traces at every decision point: “entering plugin,” “found X records,” “setting field to Y,” “exiting plugin.” It feels like overkill until you’re debugging a production issue.
Plugin Profiler
For deeper debugging, the Plugin Registration Tool includes a profiler:
- Open Plugin Registration Tool, find your plugin step
- Click Profile → Start Profiling
- Trigger the plugin (create/update a record in the app)
- The profiler captures the full execution context
- Download the profile, then Debug → replay it in Visual Studio with breakpoints
This lets you step through your code with real production data. It’s the closest thing to a proper debugger for plugins.
Common Mistakes
Infinite loops. Your plugin updates a record. That update triggers the plugin again. Which updates the record again. Dataverse will stop you after a depth of 8, but your logic is already broken. Always check execution depth:
if (context.Depth > 1)
return; // We were triggered by our own update, bail out
Or better — check if the specific field you care about is actually in the Target before doing anything.
Transaction timeouts. Synchronous plugins run inside the database transaction. The default timeout is 2 minutes. If your plugin calls a slow external API, the entire save operation hangs until it times out. For anything that takes more than a few seconds, use an async plugin or move the logic to Power Automate.
Not filtering on attributes. When you register a plugin on the Update message, you can specify which fields should trigger it. If you leave this blank, your plugin fires on every single update to the record — including system updates, workflow updates, and other plugin updates. Always set the filtering attributes to only the fields your plugin actually cares about.
Hardcoding GUIDs. Don’t put environment-specific IDs in your plugin code. Use configuration strings (passed during registration), custom tables, or environment variables. Your plugin needs to work in dev, test, and production without code changes.
Using the wrong service identity. factory.CreateOrganizationService(context.UserId) runs as the triggering user — subject to their security roles. factory.CreateOrganizationService(null) runs as the system — bypasses security. Use the user context by default. Only use system context when the plugin needs to do something the user can’t (like updating a locked system table).
When to Go Async
Register your plugin step as asynchronous when:
- The operation doesn’t need to complete before the user sees the result
- It calls external APIs or services
- It processes large amounts of data
- The user doesn’t need to see an error if it fails (async failures show up in System Jobs, not in the UI)
Async plugins run outside the database transaction. They’re processed by the Async Service and can be monitored under Settings → System Jobs. They’ll retry automatically on transient failures.
The tradeoff: you lose the ability to block the save or modify the record before the user sees it. If you need that, stay synchronous.
Two tools worth mentioning: the Plugin Registration Tool (ships with the Power Platform CLI — pac tool prt) handles registration and profiling. And if you’re debugging from trace logs, the Plugin Trace Viewer in XrmToolBox formats them much better than the built-in Dataverse view. For generating early-bound classes, I use the Early Bound Generator — also XrmToolBox. You could write plugins without these, but I wouldn’t want to.
Related articles
D365 Data Migration: What the Docs Skip
After 50+ implementations, here's what actually works for migrating data into Dataverse — the tools, the phases, the gotchas with lookups and deduplication, and the mistakes I still see teams make.
Dynamics 365 Solution Design: Stop Putting Everything in One Solution
How you structure your Dynamics 365 solutions determines how painful your deployments will be. Here's a layered approach that actually scales.
5 Plugin Errors You'll Hit in Dataverse (and What's Actually Wrong)
Exact error messages, root causes, and fixes for the five Dataverse plugin errors you will hit sooner or later. Save yourself the 2am debugging session.