Flowline.Attributes
0.4.0
dotnet add package Flowline.Attributes --version 0.4.0
NuGet\Install-Package Flowline.Attributes -Version 0.4.0
<PackageReference Include="Flowline.Attributes" Version="0.4.0"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
<PackageVersion Include="Flowline.Attributes" Version="0.4.0" />
<PackageReference Include="Flowline.Attributes"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
paket add Flowline.Attributes --version 0.4.0
#r "nuget: Flowline.Attributes, 0.4.0"
#:package Flowline.Attributes@0.4.0
#addin nuget:?package=Flowline.Attributes&version=0.4.0
#tool nuget:?package=Flowline.Attributes&version=0.4.0
Flowline.Attributes
Source-only NuGet package that provides attributes for registering Dataverse plugin steps and Custom APIs with the Flowline CLI.
Run flowline push and Flowline inspects your plugin assembly and automatically creates or updates all registrations in Dataverse — no Plugin Registration Tool needed.
Installation
<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 plugin assembly — no extra DLL is shipped, which keeps the Dataverse sandbox happy.
Plugin steps
Each IPlugin class registers exactly one plugin step. The message, stage, and processing mode come from the class name; the table and options come from attributes. This keeps each Execute body focused on one thing and makes log entries self-describing.
The pipeline
| Stage keyword | When it runs | In transaction? | Use for |
|---|---|---|---|
Validation |
Before the transaction opens | No | Throwing to reject the operation cleanly — no rollback needed |
Pre |
Before the record is saved | Yes | Enriching or correcting incoming data — changes to Target are saved automatically |
Post (sync) |
After the record is saved | Yes | Follow-up writes that must be atomic with the triggering operation |
Post + Async |
After the transaction closes | No | Notifications, external API calls, long-running work |
Class naming
{DescriptiveName}{Stage}{Message}[Async][Plugin]
The Plugin suffix is optional but recommended. Flowline strips it before parsing.
| Class name | Message | Stage | Mode |
|---|---|---|---|
AccountPostCreatePlugin |
Create | PostOperation | Synchronous |
InvoicePreUpdatePlugin |
Update | PreOperation | Synchronous |
ContactValidationDeletePlugin |
Delete | PreValidation | Synchronous |
OrderPostUpdateAsyncPlugin |
Update | PostOperation | Asynchronous |
Common messages: Create, Update, Delete, Retrieve, RetrieveMultiple, Associate, Disassociate, Assign, SetState. Names are case-sensitive.
Classes without [Step] are skipped. Classes with [Step] must follow the naming convention;
Flowline fails fast when it cannot parse the stage and message, because [Step] is explicit
intent to register a Dataverse plugin step.
[Step] — required
Specifies the table logical name. Without it, Flowline ignores the class.
[Step("account")]
public class AccountPostCreatePlugin : IPlugin { ... }
The logical name is always lowercase and found in the maker portal under Table → Properties → Name.
Registering on all tables: omit the entity argument to fire on every table. Flowline warns so you don't do this accidentally. Pass "none" to suppress the warning and make the intent explicit:
// Warns: "[Step] has no entity — this step will fire for all tables."
[Step]
public class GlobalPreCreatePlugin : IPlugin { ... }
// No warning — intentional global registration
[Step("none")]
public class GlobalPreCreatePlugin : IPlugin { ... }
Passing an empty string ([Step("")]) is an error.
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 Dataverse systemuser to impersonate (impersonatinguserid). null runs as the calling user. |
Configuration |
string? |
null |
Passed to the plugin constructor as unsecureConfig. |
DeleteJobOnSuccess |
bool |
true |
Automatically delete the AsyncOperation job record when the step succeeds. Async post-operation steps only. Set to false to retain the record for auditing. |
Use RunAs to run the plugin as a specific service account. Pass the string form of the user's GUID:
[Step("account", RunAs = "3b36b50c-03e5-4b5f-8882-123456789abc")]
public class AccountPostCreatePlugin : IPlugin { ... }
Use environment-stable GUIDs. The value is stored in source control and the solution XML. Avoid personal accounts or accounts whose GUID differs between environments.
Use Configuration to pass endpoint URLs, feature flags, or JSON settings. Receive the value in a constructor overload that accepts string unsecureConfig:
[Step("account", Configuration = "{\"endpoint\":\"https://api.example.com\"}")]
public class AccountPostCreatePlugin : IPlugin
{
private readonly string _endpoint;
public AccountPostCreatePlugin(string unsecureConfig)
{
_endpoint = JsonSerializer.Deserialize<Config>(unsecureConfig)!.Endpoint;
}
}
Do not store secrets in
Configuration. The value is visible in source control and the solution XML. Use environment variables or Azure Key Vault for anything sensitive.
DeleteJobOnSuccess defaults to true — every async step execution creates an AsyncOperation record and Flowline deletes it automatically on success, keeping the job queue clean. Set it to false explicitly if you need to retain the record for auditing or debugging:
[Step("cr07982_invoice", DeleteJobOnSuccess = false)]
[Filter("cr07982_status")]
public class InvoicePostUpdateAsyncPlugin : IPlugin { ... }
DeleteJobOnSuccessonly applies to asynchronous (post-operation) steps. Flowline warns if you explicitly set it totrueon a synchronous step.
[Filter] — optional, strongly recommended on Update steps
Limits the step to fire only when at least one of the listed columns is included in the operation. Dataverse evaluates the filter before invoking your plugin — a filtered step that doesn't match costs almost nothing.
Without [Filter], an Update step fires on every update to the table, regardless of which columns changed.
[Step("account")]
[Filter("name", "creditlimit")]
public class AccountPreUpdatePlugin : 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 Create, Delete, or any other message is an error — Flowline will throw duringflowline push.
[SecondaryEntity] — required for Associate / Disassociate
Scopes the step to a specific secondary table. Use "none" to fire on any table.
// Fires when a contact is associated with any record type
[Step("contact")]
[SecondaryEntity("none")]
public class ContactPreAssociatePlugin : IPlugin { ... }
// Fires only when a contact is associated with an account
[Step("contact")]
[SecondaryEntity("account")]
public class ContactAccountPreAssociatePlugin : IPlugin
{
public void Execute(IServiceProvider sp)
{
var ctx = (IPluginExecutionContext)sp.GetService(typeof(IPluginExecutionContext));
var target = (EntityReference)ctx.InputParameters["Target"];
var relatedEntities = (EntityReferenceCollection)ctx.InputParameters["RelatedEntities"];
var relationship = (Relationship)ctx.InputParameters["Relationship"];
}
}
Omitting [SecondaryEntity] on an Associate or Disassociate step produces a warning during flowline push. Using [SecondaryEntity] with no argument also warns — pass "none" explicitly to suppress it. Passing an empty string is an error. Using [SecondaryEntity] on any other message (Create, Update, Delete, ...) is an error.
[PreImage] and [PostImage] — optional
Register snapshots of the record before and after the operation. Retrieve them from context.PreEntityImages and context.PostEntityImages inside Execute.
Availability by message and stage:
| PreImage | PostImage | |
|---|---|---|
| Create | Not available — record didn't exist yet | PostOperation only |
| Update | Any stage | PostOperation only |
| Delete | Any stage | Not available — record no longer exists |
Violations are errors — Flowline throws during flowline push.
Specify only the columns your plugin needs. Omitting columns fetches all of them, which negatively impacts performance. Flowline warns when no columns are specified.
[Step("account")]
[Filter("name", "creditlimit")]
[PreImage("name", "creditlimit")]
[PostImage("name", "creditlimit")]
public class AccountPostUpdatePlugin : IPlugin
{
public void Execute(IServiceProvider sp)
{
var ctx = (IPluginExecutionContext)sp.GetService(typeof(IPluginExecutionContext));
var preImage = ctx.PreEntityImages["preimage"];
var postImage = ctx.PostEntityImages["postimage"];
if (preImage.GetAttributeValue<string>("name") != postImage.GetAttributeValue<string>("name"))
{
// name changed — react here
}
}
}
Default aliases are "preimage" and "postimage". Override Alias when migrating from a manually registered step with a different alias:
[PreImage(Alias = "legacy_pre")] // retrieve with: ctx.PreEntityImages["legacy_pre"]
Examples
Minimal — reject an operation before anything is written:
[Step("account")]
[Filter("creditlimit")]
public class AccountValidationUpdatePlugin : IPlugin
{
public void Execute(IServiceProvider sp)
{
var ctx = (IPluginExecutionContext)sp.GetService(typeof(IPluginExecutionContext));
var target = (Entity)ctx.InputParameters["Target"];
if (target.GetAttributeValue<Money>("creditlimit")?.Value > 100_000)
throw new InvalidPluginExecutionException("Credit limit cannot exceed 100,000.");
}
}
Enrich a record before it is saved:
[Step("account")]
public class AccountPreCreatePlugin : IPlugin
{
public void Execute(IServiceProvider sp)
{
var ctx = (IPluginExecutionContext)sp.GetService(typeof(IPluginExecutionContext));
var target = (Entity)ctx.InputParameters["Target"];
target["cr123_source"] = "web"; // included in the save automatically
}
}
Call an external system after the save, in the background:
[Step("cr07982_invoice")]
[Filter("cr07982_status")]
public class InvoicePostUpdateAsyncPlugin : IPlugin
{
public void Execute(IServiceProvider sp)
{
// Runs after the transaction commits — safe to call external APIs here.
// A failure does not roll back the record.
// DeleteJobOnSuccess defaults to true — the AsyncOperation record is cleaned up automatically.
}
}
Lifecycle
Flowline treats the DLL as the source of truth. On every flowline push:
- Plugin types — created for every public
IPluginorCodeActivityclass; deleted when the class is removed. - Steps — created or updated for every class with
[Step]; 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.
Disabling a step without deleting it: remove [Step] — Flowline deletes the step but keeps the plugin type registered.
--save flag: suppresses all deletions for that run and prints each skipped item — useful as a dry run:
flowline push MySolution --save
Custom APIs
A Custom API is a custom endpoint you define in Dataverse, invoked explicitly by name — from Power Automate, a canvas app, a web resource, or another plugin. Unlike a plugin step, it does not fire automatically on record changes.
Add [CustomApi] to an IPlugin class to register it as a Custom API. You write Execute exactly as normal — Flowline handles the Dataverse registration.
Class naming
Flowline strips the Api, CustomApi, or Plugin suffix and prefixes the result with the solution's publisher prefix:
| Class name | Unique name |
|---|---|
GetAccountRiskApi |
cr123_GetAccountRisk |
SendNotificationCustomApi |
cr123_SendNotification |
ApproveOrderPlugin |
cr123_ApproveOrder |
The publisher prefix is read from the solution automatically.
[CustomApi] — required
Without arguments, registers a global API — not tied to any table:
[CustomApi]
public class SendNotificationApi : IPlugin { ... }
Pass a table logical name for entity binding. Dataverse automatically provides a Target EntityReference — you do not declare it yourself:
[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;
}
}
Use EntityCollection for entity collection binding:
[CustomApi(EntityCollection = "invoice")]
public class BulkApproveApi : IPlugin { ... }
Optional named properties:
| Property | Type | Default | Description |
|---|---|---|---|
IsFunction |
bool |
false |
false = Action (HTTP POST). true = Function (HTTP GET). |
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. Omit to allow any authenticated user. |
[Input] and [Output]
Declare parameters on the class. These are registration declarations only — Flowline registers them in Dataverse; you read and write the values yourself in Execute.
[CustomApi]
[Input("accountId", FieldType.EntityReference, Entity = "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 — Dataverse throws if the caller omitted it.
[Input] properties:
| Property | Type | Default | Notes |
|---|---|---|---|
Name |
string |
— | Key in context.InputParameters. Convention: camelCase. |
Type |
FieldType |
— | See type table below. |
IsOptional |
bool |
false |
Check Contains("name") before reading when true. |
Entity |
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.
Supported types
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 |
Lifecycle
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.
The --save flag suppresses deletions the same way it does for steps.
Why one class per step
Each plugin class registers exactly one step. This constraint pays dividends:
Focused Execute bodies. Without the rule, Execute needs branching logic to handle different messages. With it, every Execute does one thing — Dataverse guarantees which step fired because only one is registered.
Self-describing logs. When a plugin throws, Dataverse logs the class name. AccountPostCreatePlugin tells you exactly what happened; AccountPlugin does not.
Unambiguous attributes. [Filter], [PreImage], and [PostImage] always belong to exactly one step — the one the class registers. No need to associate them with a particular step in a multi-step registration.
Shared logic via base classes. The rule does not 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 { }
One assembly for everything
Flowline supports IPlugin classes, CodeActivity workflow activities, and [CustomApi] classes all in the same assembly. One Extensions project, one Extensions.dll, one flowline push.
Why other tools can't do this
Other tools run a separate sync pass per type — first workflows, then plugins (or vice versa). Each pass treats the assembly as its own domain and deletes any registrations it doesn't recognise. The result: syncing plugins after workflow activities wipes the workflow registrations, and syncing workflow activities after plugins wipes the plugin registrations. You end up maintaining separate projects and separate DLLs just to keep the two sync passes from destroying each other.
Flowline reads all types from the assembly in a single pass and registers everything together. There is no ordering problem.
Why one assembly is better
A DLL is a deployment unit. Plugins and workflow activities are always deployed to the same environment at the same time — splitting them into separate DLLs reflects a historical assumption about project structure, not a technical requirement.
When you consolidate into one assembly you get four concrete benefits:
No separate early-bound types project. Early-bound generated classes are shared across plugin steps and workflow activities. With one assembly project, they live there directly. With separate projects you need a third project just for the shared types.
ILMerge is not needed. When everything lives in one project, external references are resolved normally by the build. Separate projects often require ILMerge to bundle shared dependencies into each DLL — an extra build step with its own failure modes.
Maintainability. At minimum three projects (plugins, workflow activities, early-bound types) collapse into one. Fewer projects means less cognitive overhead for developers, fewer things to go wrong during deployment, and a simpler ALM/DevOps pipeline.
Performance. Microsoft's best practices recommend consolidating plug-ins and custom workflow activities into a single assembly. Multiple assemblies cause additional loading and caching work on the server, which can increase overall execution time.
Learn more about Target Frameworks and .NET Standard.
-
.NETStandard 2.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.