The two properties that decide it

Before anyone argues about code against low code, answer two questions about the logic itself. When does it run relative to the data operation, and can it take part in the same transaction as that operation. Team skills, licensing and preference all matter less than those two answers, and most of the arguments I sit through in design reviews are really about them without anyone saying so.

A plugin registered to run synchronously executes inside the platform's own operation on the record. It can read the values about to be written, change them, or throw an error that stops the write. A cloud flow triggered on create or update runs afterwards and separately, once the platform has committed the row. By the time it starts, the record exists and a user can already see it on a form.

That difference settles more cases than anything in the licensing guide. Logic that has to prevent bad data belongs in a plugin, because a flow cannot refuse a save that already completed. Logic that has to succeed or fail together with the write belongs there too, since a plugin failure inside the transaction takes the write down with it. This is the narrow in-platform version of where business logic should live.

What running inside the transaction buys

Dataverse runs registered extensions in a pipeline around each data operation. A plugin step can sit before the main operation, where it can modify the incoming values, or after it, where the row is written but the operation has not finished. Register that step to run synchronously and it joins the transaction the platform opened. Throw from there and the platform rolls everything back, including writes your code made to other tables.

That rollback is the property you cannot get any other way. An approval that writes a ledger row and stamps the source record either does both or does neither. A plugin gives you that guarantee because the platform is already holding the transaction open. A flow doing the same two writes can complete the first, fail the second, and leave a record marked approved with nothing behind it.

Synchronous execution comes with a budget. The platform caps how long your code can hold a user's request open, and those caps change over time, so read the current figures in the Dataverse documentation rather than trusting a number someone quoted in a workshop three years ago. Assume the budget is small. Anything that has to wait on something else has no business inside it.

The counterweight is who maintains it

Plugins need a developer, and a maker who writes good formulas is not the same skill set. Someone has to set up a project, register an assembly, write tests and explain the behaviour to whoever inherits it. Someone has to keep source control, a build and a deployment path that carries the assembly through environments. Plenty of teams running Dataverse have none of that, and proceeding anyway produces a plugin nobody left can change.

Code that outlived the person who wrote it costs real money, and the bill arrives at the worst possible hour. A flow that a second administrator can open and read is worth something on the night a process breaks. Our guidance on code other people can maintain applies here with extra force, because a plugin failing synchronously stops users from saving records instead of quietly writing a line in a log.

Flows earn their place on visibility. They appear in the maker experience where an administrator can find them, open the run history and see which step failed. They reach services outside the platform without anyone writing authenticated calls by hand. A support analyst with no development background can change a condition on a Tuesday afternoon. That is a real operational advantage, and teams who dismiss it end up with correct code and a long change backlog.

Long work and outside calls stay out

Long-running work does not belong in a synchronous plugin. The user's save waits on your code, so every second you spend is a second somebody watches a spinner, and passing the platform's execution budget fails the operation so the record never saves. Asynchronous plugin steps exist for work that has to be reliable without being immediate, and a flow suits the same work when nothing needs rolling back.

Calling an external service usually belongs in a flow. A remote endpoint can be slow, rate limited or down for an afternoon, and none of that should hold a transaction open or fail a user's save. Flows give you retries, connection management and a run history someone can read, which our Power Automate error handling guide covers. If the work belongs outside Dataverse altogether, we have compared Power Automate against Logic Apps separately.

The shape to avoid is a synchronous plugin waiting on a system you do not control. It ties a user's ability to save a record to somebody else's uptime, and the failure surfaces in the form of a save error nobody in support can interpret.

Validation and volume point the other way

Validation almost always belongs in a plugin. A flow cannot stop a save. It can correct the record afterwards, flag it or notify somebody, and each of those leaves a window in which the bad value existed and something else may have read it. If an order line cannot exist without a price, a synchronous plugin is the only option that enforces that against every caller, including a spreadsheet import and an overnight integration.

Column constraints and business rules handle the simple cases and should be tried first. A plugin is the answer for rules that have to look at related records, compare values against a configuration table or reject one particular combination. Write the rejection message for the person who will read it, because your exception text becomes the error dialog on somebody's screen.

Volume is the other case people get wrong. Flow runs carry a cost per execution in licensing and capacity terms, so logic that fires on every row of a nightly load multiplies that cost at once. Plugin code inside the same operation carries no per-run meter of that kind. Confirm the current request and capacity terms before a bulk load, because those numbers move.

Write down the default and the reason to deviate

A mixed estate is normal. Most mature Dataverse implementations have plugins enforcing the rules that must hold and flows doing the work that reaches the rest of the business. The expensive failure is nobody being able to say where the logic for a given behaviour lives, which is how an admin ends up disabling a flow to stop a symptom a plugin was causing.

Write the default down in a single paragraph. Rules that have to be enforced go in plugins, work that reaches outside Dataverse goes in flows, and everything else goes in a flow unless a named person signs off on a reason. Then insist on that reason in writing, because two years later it is what tells the next architect whether the constraint still holds.

Debugging one behaviour split across both is the scenario to avoid on purpose. A plugin sets a field, a flow reads it and sets another, a second flow reacts to that, and the trace you need spans plugin logs and three run histories with nothing tying them together. That pattern grows out of flow ownership drifting rather than design. If a behaviour has to span both, document the handoff and name the owner of each half.

So take the next piece of logic on your backlog and put one question to it before opening any designer. If this logic fails, should the record still be saved? Get the answer from the person who owns the process rather than the person who is comfortable with the tooling, and write it next to the requirement before anyone builds a thing.