Plugin vs Power Automate: When to Use What
Same client, same month, both tools#
On a credit-management platform running 35,000+ active cases, I had two problems on my desk at the same time.
Problem one: case handlers were deleting tasks generated from key process templates. The tasks were "locked" in the UI, but a UI lock means nothing to a grid bulk-delete or a Web API call, and each deleted task quietly broke the workflow integrity the templates existed to guarantee.
Problem two: the Outlook tracking add-in was duplicating emails. Its retry mechanism would create two records with the same MessageID, so every affected case timeline showed conversations twice.
One became a C# plugin. The other became a Power Automate flow. Walking through why is the whole decision framework, so that's what this article does.
Why the delete-blocker had to be a plugin#
A flow triggers after the delete. By the time it runs, the row is gone, and the best you can do is recreate it: a reconstruction, with a new GUID, after a window where dependent automation may have already misfired. That's not enforcement, it's cleanup.
A plugin registered on the PreValidation stage runs before the database transaction even opens. Throw an exception there and the delete never happens, the user gets a readable message, and (this is the part UI-level locks can't give you) it doesn't matter where the delete came from. Form, grid, bulk job, Web API, another integration: the platform funnels them all through the same pipeline.
public void Execute(IServiceProvider serviceProvider)
{
var context = (IPluginExecutionContext)serviceProvider
.GetService(typeof(IPluginExecutionContext));
if (context.MessageName != "Delete") return;
var reference = (EntityReference)context.InputParameters["Target"];
var task = RetrieveTask(serviceProvider, reference.Id);
// Tasks generated from a key template carry a marker set at creation.
if (task.GetAttributeValue<bool>("new_iskeytemplatetask"))
{
throw new InvalidPluginExecutionException(
"This task was generated from a key process template and cannot be deleted. " +
"Close it with an appropriate status instead.");
}
}Synchronous, transactional, no per-execution licensing, and testable with FakeXrmEasy in a normal unit test project. For server-side enforcement of a business invariant, nothing else on the platform is equivalent.
One implementation note: I built, signed, and deployed this entirely from macOS with the dotnet CLI and pac. No Visual Studio, no Plugin Registration Tool. That's been possible for a while now, but the documentation trail is thin, so if your team still thinks plugin development requires a Windows VM, it doesn't.
Why the email dedup had to be a flow#
The duplicate-email problem looked plugin-shaped at first glance: intercept the incoming record, check for an existing MessageID, cancel the duplicate. Then the details arrived.
The add-in's retry behavior meant the "duplicate" could arrive up to a minute after the original, in either order. Handling that required a deliberate 60-second delay before deciding which record to keep. In a synchronous plugin, that's a held transaction and a guaranteed collision with the two-minute execution timeout. In a flow, a Delay action costs nothing.
There were three distinct scenarios to orchestrate (incoming arriving after an outgoing already existed, same-direction duplicates, and an outgoing superseding an incoming), each with different keep/cancel/relink logic. That's orchestration, and flows are simply better at expressing it in a way the client's own team can read, audit in run history, and modify after I'm gone. That last part matters more than developers like to admit: the maintainer of this logic is a product team, not a C# shop.
The flow also surfaced two platform quirks worth knowing about:
The Dataverse connector cannot clear a polymorphic lookup like regardingobjectid. The action just doesn't support it. The workaround is a raw HTTP DELETE against the navigation property with the connector's auth. It isn't documented in any official place I could find, and it's exactly the kind of thing that decides whether your flow works or silently doesn't.
And because the flow's trigger email could legitimately disappear during the delay window, the Get email action would 404 in a way that wasn't an error at all. The clean pattern is a sibling Terminate action with runAfter set to Failed/TimedOut: swallow the expected failure explicitly instead of letting every legitimate 404 page the support inbox.
The framework, after the stories#
Strip those two cases down and you get the questions I actually use:
Must it block the operation? Plugin, PreValidation or PreOperation. A flow can only ever react.
Does it need delays, retries, or other systems? Flow. Plugins live inside a transaction with a two-minute ceiling; flows are built for waiting and for connectors.
Who maintains it in two years? Be honest about this one. A flow is inspectable by a product owner; a plugin is owned by whoever still has the build pipeline. I've chosen flows over technically superior plugin designs purely because of who was inheriting the system.
For the real gray zone, async logic that could live in either, I lean on volume and testability. Thousands of executions per hour runs into flow throttling and licensing questions that plugins don't have, and complex branching logic is dramatically easier to regression-test in C# than by re-running flows against a dev environment.
And the hybrid pattern that keeps showing up in my architectures: core logic in a plugin-based Custom API, orchestration in flows that call it. The flow stays readable, the logic stays testable, and neither tool gets stretched past what it's good at.
Mistakes I keep seeing#
Validation in flows is the one that hurts users most: the save succeeds, the user moves on, and a correction email arrives later. If it should have been blocked, it needed to be a plugin.
The inverse failure is the plugin that sends a Teams notification: five lines of connector configuration rebuilt as a C# project with a deployment pipeline, because the team only knew one tool.
And with either tool: think about loops before production does. A plugin updating the record that triggered it, or two flows updating each other's trigger entities, will find the platform's depth limits at the worst possible time.
Pick per problem, not per team preference. The platform gives you two different tools for a reason; the architecture failure is pretending one of them is always the answer.