-
-
Notifications
You must be signed in to change notification settings - Fork 0
05 Push Plugins and Custom APIs
Flowline uses source-only NuGet package Flowline.Attributes to register Dataverse plugin steps and Custom APIs.
Add it to your Plugins project:
<PackageReference Include="Flowline.Attributes" Version="1.0.0" PrivateAssets="all" />PrivateAssets="all" keeps this as a development-time dependency. The attributes compile directly into your assembly, so no extra DLL is shipped.
Run flowline push and Flowline inspects your compiled assembly, then creates or updates all registrations in Dataverse. No Plugin Registration Tool needed.
Flowline registers exactly one plugin step per IPlugin class.
The message, stage, and processing mode come from the class name; the table and options come from attributes.
Any class that implements IPlugin, directly or through a base class, is eligible. Using your own PluginBase : IPlugin works fine; Flowline walks the full inheritance chain.
| Stage keyword | When it runs | In transaction? | Use for |
|---|---|---|---|
Validation |
Before the transaction opens | No | Throwing to reject the operation cleanly |
Pre |
Before the record is saved | Yes | Enriching or correcting incoming data |
Post (sync) |
After the record is saved | Yes | Follow-up writes that must be atomic |
Post + Async
|
After the transaction closes | No | Notifications, external API calls, long-running work |
{DescriptiveName}{Stage keyword}{Message}[Async][Plugin]
| Class name | Message | Stage | Mode |
|---|---|---|---|
SetNamePostCreatePlugin |
Create | PostOperation | Synchronous |
RecalculateTotalsPreUpdatePlugin |
Update | PreOperation | Synchronous |
OwnershipValidationDeletePlugin |
Delete | PreValidation | Synchronous |
NotifyPostUpdateAsyncPlugin |
Update | PostOperation | Asynchronous |
The {DescriptiveName} describes what the plugin does, it can have the table name but it doesn't need to. The table is declared on [Step].
For {Stage keyword} we use the shortened versions Pre, Post and Validation to match the Dataverse pipeline terminology (PreOperation, PostOperation, PreValidation) because it makes the class names more concise and readable, like SetNamePostCreatePlugin. To make it async you add the Async suffix, like the normal C# convention.
The {Message} is the Dataverse message, like Create, Update, Delete, Associate, Disassociate, AddToQueue, etc. The message is case-sensitive and must match the Dataverse message exactly. See also Available events.
Classes without [Step] are skipped for step registration. Classes with [Step] must follow the naming convention. Flowline fails fast when it cannot parse the stage and message, or when invalid combinations are used.
Brownfield class names. If you can't rename an existing class, use [Handles]. See below.
Specifies the table logical name. Without it, Flowline ignores the class.
[Step("account")]
public class SetNamePostCreatePlugin : IPlugin { ... }The logical name is lowercase, found in the maker portal under Table → Properties → Name.
Use the early-bound constant instead of a string literal when the table has a generated type. flowline generate emits EntityLogicalName as a const string, so it is legal in an attribute. Typos become compile errors, and Find All References tells you which plugins touch a table:
[Step(SalesOrderDetail.EntityLogicalName)]
public class OrderLineDeleteGuardPreDeletePlugin : IPlugin { ... }See Generate Early-Bound Types.
Registering on all tables: pass "none" explicitly:
[Step("none")]
public class GlobalPreCreatePlugin : IPlugin { ... }Optional named properties:
| Property | Type | Default | Description |
|---|---|---|---|
Order |
int |
1 |
Execution order when multiple steps fire on the same event. Lower runs first. |
RunAs |
string? |
null |
GUID of the systemuser to impersonate. null runs as the calling user. |
Config |
string? |
null |
Passed to the plugin constructor as unsecureConfig. |
Description |
string? |
null |
Description shown in the Plugin Registration Tool. |
DeleteJobOnSuccess |
bool |
true |
Delete the AsyncOperation job record on success. Async steps only. |
SecondaryTable |
string? |
null |
Secondary table for Associate / Disassociate steps. Pass "none" to match any secondary table. |
Limits the step to fire only when at least one of the listed columns is included in the operation. Without [Filter], an Update step fires on every update to the table. Flowline warns if you omit it on an Update step.
[Step("account")]
[Filter("name", "creditlimit")]
public class RecalculateTotalsPreUpdatePlugin : IPlugin { ... }Use nameof with early-bound classes for compile-time safety:
[Filter(nameof(Account.name), nameof(Account.creditlimit))]
[Filter]only applies to Update steps. Using it on any other message is an error.
Register snapshots of the record before and after the operation.
Availability by message and stage:
| PreImage | PostImage | |
|---|---|---|
Create |
Not available | PostOperation only |
CreateMultiple |
Not available | PostOperation only |
Update |
Any stage | PostOperation only |
UpdateMultiple |
Any stage | PostOperation only |
Delete |
Any stage | Not available |
Assign, DeliverIncoming, DeliverPromote, Merge, Route, Send, and SetState also support
images. Any other message is an error.
Violations are errors, and Flowline throws during flowline push.
Specify only the columns your plugin needs. Omitting columns fetches all of them, which negatively impacts performance.
[Step("account")]
[Filter("name", "creditlimit")]
[PreImage("name", "creditlimit")]
[PostImage("name", "creditlimit")]
public class RecalculateTotalsPostUpdatePlugin : IPlugin
{
public void Execute(IServiceProvider sp)
{
var ctx = (IPluginExecutionContext)sp.GetService(typeof(IPluginExecutionContext));
var preImage = ctx.PreEntityImages["preimage"];
var postImage = ctx.PostEntityImages["postimage"];
}
}Default aliases are "preimage" and "postimage". Override Alias when migrating from a step with a different alias:
[PreImage(Alias = "legacy_pre")] // retrieve with: ctx.PreEntityImages["legacy_pre"]One image per type. A class takes at most one [PreImage] and one [PostImage], so declare all
the columns you need on that single image. Renaming Alias or changing the column list updates the
same image in place.
When a class name doesn't follow the naming convention, use [Handles] to declare message and stage explicitly:
[Step("account")]
[Handles(Message.Update, Stage.PreOperation)]
public class AccountPlugin : IPlugin { ... }
// Async
[Step("account")]
[Handles(Message.Create, Stage.PostOperationAsync)]
public class AccountPlugin : IPlugin { ... }
// Custom API message
[Step("account")]
[Handles("mynamespace_MyAction", Stage.PostOperation)]
public class AccountPlugin : IPlugin { ... }[Handles] requires [Step] to be present.
Stack [Handles] when you're migrating from a tool where one class handled several steps and
splitting it right now isn't feasible. Each [Handles] produces one step, and push warns about the
multi-step pattern. Split into named subclasses as soon as the migration window allows.
Caution
All stacked handles share the same [Step], which sets the primary table. Steps firing on
different primary tables need separate classes.
// Valid — both steps fire on the same table ("account")
[Step("account")]
[Handles(Message.Create, Stage.PostOperation)]
[Handles(Message.Update, Stage.PostOperation)]
public class AccountPlugin : IPlugin { ... }[Filter], [PreImage], and [PostImage] are applied per-step based on message compatibility.
Splitting changes step names. The next push deletes the old steps and creates new ones, so plan for a maintenance window.
Use SecondaryTable on [Step] to scope the step to a specific secondary table:
// Fires when a contact is associated with any record type
[Step("contact", SecondaryTable = "none")]
public class ContactPreAssociatePlugin : IPlugin { ... }
// Fires only when a contact is associated with an account
[Step("contact", SecondaryTable = "account")]
public class ContactAccountPreAssociatePlugin : IPlugin { ... }On Associate/Disassociate steps, pass "none" to match any secondary table.
On every flowline push:
-
Plugin types are created for every public
IPluginorCodeActivityclass, and deleted when the class is removed. -
Steps are created or updated for every class with
[Step], and deleted when[Step]is removed or the class is deleted.
Steps created by Flowline are stamped with [flowline] in their description, visible in Plugin Registration Tool.
Step identity. Renaming a class updates the existing step in place. Changing the message, table, stage, or mode is a different registration, so the step is deleted and recreated.
Use --no-delete to suppress all deletions for a run. Use --dry-run to preview changes without applying them.
A Custom API is a custom endpoint invoked explicitly by name, from Power Automate, a canvas app, a web resource, or another plugin.
Add [CustomApi] to an IPlugin class to register it as a Custom API.
Flowline strips the Api, CustomApi, or Plugin suffix and prefixes the result with the solution's publisher prefix:
| Class name | Unique name |
|---|---|
GetAccountRiskApi |
av_GetAccountRisk |
SendNotificationCustomApi |
av_SendNotification |
ApproveOrderPlugin |
av_ApproveOrder |
Without arguments, registers a global API that isn't tied to any table:
[CustomApi]
public class SendNotificationApi : IPlugin { ... }Pass a table logical name for entity binding:
[CustomApi("salesorder")]
public class ApproveOrderApi : IPlugin
{
public void Execute(IServiceProvider sp)
{
var ctx = (IPluginExecutionContext)sp.GetService(typeof(IPluginExecutionContext));
var orderId = ((EntityReference)ctx.InputParameters["Target"]).Id;
}
}Pass TableCollection for entity collection binding, where the API operates on a set of records rather than one:
[CustomApi(TableCollection = "invoice")]
public class BulkApproveApi : IPlugin { ... }Table and TableCollection are mutually exclusive: an API binds to a single record or to a collection, never both. Setting both fails the push.
Both take any const string, so the early-bound constant works here as well:
[CustomApi(SalesOrder.EntityLogicalName)]
public class ApproveOrderApi : IPlugin { ... }
[CustomApi(TableCollection = Invoice.EntityLogicalName)]
public class BulkApproveApi : IPlugin { ... }Optional named properties:
| Property | Type | Default | Description |
|---|---|---|---|
IsFunction |
bool |
false |
true = Function (HTTP GET). false = Action (HTTP POST). |
IsPrivate |
bool |
false |
Hides the API from the OData catalog. |
AllowedStepType |
AllowedStepType |
None |
Whether third parties can register plugin steps on this API. |
DisplayName |
string? |
class name split | Shown in solution explorer. |
Description |
string? |
null |
Shown in solution explorer. |
ExecutePrivilege |
string? |
null |
Privilege required to call this API. |
UniqueName |
string? |
class name derived | Overrides the unique name. Complete name, publisher prefix included (e.g. "dev1_MyCustomApi"). See Class naming. |
Declare Custom API parameters on the class:
[CustomApi]
[Input("accountId", FieldType.EntityReference, Table = "account")]
[Input("includeHistory", FieldType.Boolean, IsOptional = true)]
[Output("riskScore", FieldType.Integer)]
[Output("riskLabel", FieldType.String)]
public class GetAccountRiskApi : IPlugin
{
public void Execute(IServiceProvider sp)
{
var ctx = (IPluginExecutionContext)sp.GetService(typeof(IPluginExecutionContext));
var accountId = (EntityReference)ctx.InputParameters["accountId"];
var withHistory = ctx.InputParameters.Contains("includeHistory")
&& (bool)ctx.InputParameters["includeHistory"];
ctx.OutputParameters["riskScore"] = ComputeScore(accountId, withHistory);
ctx.OutputParameters["riskLabel"] = "High";
}
}Always check Contains before reading an optional input.
[Input] properties:
| Property | Type | Default | Notes |
|---|---|---|---|
Name |
string |
none | Key in context.InputParameters. Convention: camelCase. |
Type |
FieldType |
none | See type table below. |
IsOptional |
bool |
false |
Check Contains("name") before reading when true. |
Table |
string? |
null |
Required when type is EntityReference or Entity. |
DisplayName |
string? |
name split | Shown in solution explorer. |
Description |
string? |
null |
[Output] has the same properties except IsOptional.
C# type in Execute
|
FieldType |
|---|---|
bool |
Boolean |
DateTime |
DateTime |
decimal |
Decimal |
Entity |
Entity |
EntityCollection |
EntityCollection |
EntityReference |
EntityReference |
float / double
|
Float |
int |
Integer |
Money |
Money |
OptionSetValue |
Picklist |
string |
String |
string[] |
StringArray |
Guid |
Guid |
Flowline treats the DLL as the source of truth for Custom APIs, the same as for steps.
-
Created when a class with
[CustomApi]has no matching unique name in Dataverse. -
Updated when mutable fields change (
DisplayName,Description,IsPrivate,ExecutePrivilege). -
Deleted and recreated when an immutable field changes (binding type,
IsFunction,AllowedStepType, or a parameter's type or optionality). Flowline warns before doing this. -
Deleted when the class or
[CustomApi]is removed, provided the live record's implementation is a plugin type this push owns. A Custom API that points at a plugin type from another project, or at nothing at all, is left alone. See What push deletes, with several projects.
The --no-delete flag suppresses deletions the same way it does for steps.
One step per class keeps [Filter] and the image attributes unambiguous, keeps Execute free of
branching on which message fired, and makes Dataverse's error logs name the exact plugin that threw:
SetNamePostCreatePlugin tells you what happened, AccountPlugin doesn't.
It doesn't mean duplicating code. Put shared logic in a base class and declare one leaf class per step:
public abstract class AccountSavePlugin : IPlugin
{
public void Execute(IServiceProvider sp) { /* shared logic */ }
}
[Step("account")]
public class AccountPreCreatePlugin : AccountSavePlugin { }
[Step("account")]
public class AccountPreUpdatePlugin : AccountSavePlugin { }
// Same pattern for multiple entities:
[Step("contact")]
public class ContactPreCreatePlugin : AccountSavePlugin { }Flowline supports IPlugin classes, CodeActivity workflow activities, and [CustomApi] classes all in the same assembly. One Plugins project, one build output (a classic .dll or a NuGet .nupkg package, see PluginPackageMode), one flowline push. That's the default and the right shape for most solutions, but it is a default rather than a limit; see More than one plugin project.
CodeActivityclasses need the classic.dllpath, because Dataverse's Dependent Assemblies feature only auto-creates plugin records forIPluginclasses, not workflow activities. If a.nupkgbuild contains aCodeActivityclass,pushrejects it before uploading and names the offending type; move workflow activities to a separate project built as a.dll(or forcePluginPackageMode: Dllfor that project).
This also means:
- No separate early-bound types project. Generated classes live directly in
Plugins/. - No ILMerge. External references are resolved normally by the build.
Microsoft's best practices recommend consolidating plug-ins and custom workflow activities into a single assembly.
A solution can hold several plugin projects, and one flowline push registers all of them. Each gets its own assembly, plugin types, steps, and Custom APIs under the same Dataverse solution.
You don't configure this. push reads the solution file, takes every .csproj it references, and reflects each one's build output for IPlugin or CodeActivity types. Whatever has them is a plugin project. Whatever doesn't, like a shared library or your web resources project, is skipped. Run with --verbose to see what was skipped and why.
That also means the folder and project names are yours. Plugins/<SolutionName>.Plugins.csproj is what clone scaffolds, not what push requires. A custom <AssemblyName>, a build output with no publish/ subfolder, a project called Contoso.Sales.Plugins in a folder called Sales: all fine, as long as the solution file references it.
Renaming an existing project changes the assembly's identity as far as Dataverse is concerned. It registers as a new assembly, and the old one and its steps stay behind as orphans. push reports them; --force delete-orphans clears them. Steps and Custom APIs re-derive from your source attributes, so nothing is lost.
When to split. Two reasons come up in practice: separating business areas that change on different cadences, and the CodeActivity case above, where workflow activities need the classic .dll path, so a solution using .nupkg packaging can put them in their own project rather than giving up packaging everywhere.
push removes registrations that no longer have source behind them. Assemblies and steps and Custom APIs follow different rules, because the underlying records aren't shaped the same way.
Assemblies and steps. Anything in the solution that no discovered project produces is an orphan. It's reported on every push, and deleted only with --force delete-orphans. A sibling project's assembly is never mistaken for an orphan.
Adding or removing a plugin project in a .nupkg package. Both work. push handles the registration either way, names the assembly it acted on, and --dry-run previews it first.
Adding a project to an existing package needs the same fix in every environment. Solution import doesn't register the added assembly either, so it doesn't travel with the next
deploy.pushwarns when it fires, anddeployreports the missing registration in any target it finds one in (exit21, see 08-Deploy#package-assembly-check).
Orphans left behind by a .nupkg project. An assembly belonging to a plugin package can't be deleted on its own. When every assembly a package owns is an orphan, --force delete-orphans deletes the package and the assemblies go with it. When the package still owns something live, push leaves it alone and names it. Remove that package yourself once you're sure.
Custom APIs are different. A Custom API points at a plugin type rather than belonging to one, and can exist with no implementation at all. So push deletes a Custom API only when it can prove the API is its own:
| The API's implementation | What push does |
|---|---|
| A plugin type this push owns, and source no longer declares the API | Deleted |
| A plugin type this push doesn't own | Never deleted, with or without --force. --verbose says why it was left |
| Nothing, no plugin type set | Left alone; deleted only with --force delete-orphans
|
A Custom API is a public API surface that other people's code calls, so the bar to delete one is higher than for a step.
Push the whole solution, not a subset. All the projects a solution contains are discovered and pushed together, which is what keeps the orphan set correct.
One failure stops the run. If a project fails to push, the error names it, lists which assemblies already reached the org, and lists which were not attempted. Nothing is skipped quietly. Fix the named project and push again. The ones that already landed are idempotent.