DataSet Workflows
Workflows group multiple DataSet mutations into an atomic server-side transaction. Rather than each SetAsync call sending a PATCH immediately and independently, operations are buffered under a shared Workflow-Id header and committed as a unit. This is useful when a user action requires several entity changes to succeed or fail together — for example, populating multiple fields on a sales order and seeing all server-side computed values (prices, totals, availability) reflect every change before anything is persisted.
How It Works
A workflow has three phases:
Begin —
BeginWorkflowAsync()posts toapi/workflow/and receives a server-allocatedWorkflowId(Guid). TheIODataClientstores this ID and attaches it as aWorkflow-Idheader (plus an auto-incrementingWorkflow-Index) on every subsequent request.Buffer — While the workflow is active, each
SetAsyncon any DataSet sharing that client instance sends a PATCH to the server. The server applies the change to the in-memory workflow state and returns the updated entity, including computed values and validation messages. Nothing is persisted to the database. A400 Bad Requestfrom the server during this phase is not an error; it means the operation was accepted into the workflow state with validation messages attached.Commit —
CommitWorkflowAsync()posts toapi/workflow/commit. The server validates the full transaction and either persists every buffered operation atomically or rejects the commit. The workflow remains open after a successful commit; you can continue making changes and commit again. The workflow is closed (and any uncommitted changes discarded) when theIODataClientis disposed viaDisposeAsync.
sequenceDiagram
participant Page
participant Client as IODataClient
participant Server
Page->>Client: BeginWorkflowAsync()
Client->>Server: POST api/workflow/
Server-->>Client: { workflowId: "..." }
Page->>Client: SetAsync("Quantity", 5)
Client->>Server: PATCH (Workflow-Id + Workflow-Index headers)
Server-->>Client: Updated entity + @workflow.messages (buffered, not persisted)
Page->>Client: SetAsync("ProductGuid", guid)
Client->>Server: PATCH (Workflow-Id + Workflow-Index headers)
Server-->>Client: Updated entity + @workflow.messages
Page->>Client: CommitWorkflowAsync()
Client->>Server: POST api/workflow/commit
Server-->>Client: Success or validation errors
Virtual entities take part in workflows too — staged in memory and committed atomically. See Virtual Entities › Workflows.
IODataClient Workflow Members
BeginWorkflowAsync()
Task<CoreODataResponse> BeginWorkflowAsync();
Starts a new server-side workflow. On success, WorkflowId is set to the GUID returned by the server and Workflow-Index is reset to zero. Returns CoreODataResponse.Error if a workflow is already active on this client instance — you cannot nest workflows.
CommitWorkflowAsync()
Task<CoreODataResponse> CommitWorkflowAsync();
Commits all buffered operations. The server persists the transaction atomically.
- Returns
CoreODataResponse.Successif the commit succeeded. If the response body includes validation messages they are accessible viaGetWorkflowUserPrompts(), but noError-level prompts were present. - Returns
CoreODataResponse.Errorcarrying a populatedUserPromptsdictionary if the server rejected the commit due to validation errors. The workflow remains open; you can correct values and callCommitWorkflowAsync()again. - Returns
CoreODataResponse.Errorwithout prompts if the server returned a non-400 failure status.
The Workflow-Index is incremented after every commit attempt.
WorkflowId
Guid? WorkflowId { get; }
Non-null while a workflow is active. Becomes non-null after BeginWorkflowAsync() succeeds. The workflow remains active (and WorkflowId remains set) across multiple commits until DisposeAsync is called.
GetWorkflowUserPrompts()
IReadOnlyDictionary<string, UserPrompt[]> GetWorkflowUserPrompts();
Returns the most recent set of validation messages returned by the server, keyed by "{EntityGuid}.{PropertyName}". Updated after every PATCH, POST, DELETE, and commit response while a workflow is active. The dictionary is empty when no messages were returned.
UserPrompts During Workflows
The server can attach validation messages to workflow responses at any stage — not only on commit. During a workflow, a 400 Bad Request from a PATCH or POST is treated as a successful buffered operation that carries prompts, not as a transport error.
UserPrompt Fields
| Field | Type | Description |
|---|---|---|
Level |
int |
Severity: 0 = Error, 1 = Warning, 2 = Info |
Text |
string |
The human-readable validation message |
Type |
int |
Prompt type code (application-defined) |
Details |
string |
Additional detail text (empty string when absent) |
EntityGuid |
Guid? |
The entity the message applies to, or null for workflow-level messages |
SourceName |
string |
The property or source that produced the message (empty string when absent) |
Prompts are surfaced on each DataSet through GetUserPrompts(path?):
// All prompts on this DataSet (any property)
UserPrompt[] all = _orderDataSet.GetUserPrompts();
// Prompts for a specific property path
UserPrompt[] qtyPrompts = _orderDataSet.GetUserPrompts("Quantity");
PropertyComponent renders these automatically. Access them directly only when you need custom logic — for example, blocking a submit button when error-level prompts exist:
bool hasErrors = _orderDataSet.GetUserPrompts()
.Any(p => p.Level == 0);
Using Workflows from a Page
The IODataClient is injected into the page. All DataSets on the page share that single client instance, so beginning a workflow affects every DataSet simultaneously.
Basic Pattern
@inject IODataClient OData
private DataSet _orderDataSet = null!;
private DataSet _lineDataSet = null!;
protected override void OnInitialized()
{
_orderDataSet = new DataSet(OData, OrderGraph);
_lineDataSet = new DataSet(OData, OrderLineGraph);
}
private async Task SaveChangesAsync()
{
var begun = await OData.BeginWorkflowAsync();
if (!begun.IsSuccess(out _, out var beginError))
{
// Show beginError
return;
}
try
{
await _orderDataSet.SetAsync("CustomerGuid", _selectedCustomerGuid);
await _orderDataSet.SetAsync("OrderDate", _orderDate);
await _lineDataSet.SetAsync("Quantity", _quantity);
await _lineDataSet.SetAsync("ProductGuid", _selectedProductGuid);
var commit = await OData.CommitWorkflowAsync();
if (commit.IsSuccess(out _, out var commitError))
{
// All changes persisted — navigate or show confirmation
}
else
{
// Validation errors are now visible via GetUserPrompts()
// The workflow is still open; user can correct and retry
}
}
finally
{
// DisposeAsync is called when the page disposes OData,
// but if you need explicit cleanup before navigation:
await OData.DisposeAsync();
}
}
SetAsync Behavior Inside a Workflow
SetAsync behaves identically from the DataSet's perspective whether or not a workflow is active. The call still performs an optimistic local update and fires the Changed event. The difference is on the server side: instead of persisting immediately, the server records the change in the workflow buffer and returns the updated entity (with computed values and prompts). This means the UI stays live and reactive throughout data entry — computed fields update with each keystroke or field exit, exactly as they do outside a workflow.
Error Handling
Distinguishing Validation Rejections from Hard Errors
CommitWorkflowAsync() returns CoreODataResponse.Error in two distinct cases:
| Case | error.UserPrompts |
Meaning |
|---|---|---|
| Server validation rejection (HTTP 400) | Populated | Business rules blocked the commit. The workflow remains open. Inspect prompts, correct values, and call CommitWorkflowAsync() again. |
| Transport or server fault (HTTP 5xx, network failure, etc.) | Empty | A hard error occurred. The workflow state is unknown. |
var commit = await OData.CommitWorkflowAsync();
if (!commit.IsSuccess(out _, out var commitError))
{
var prompts = OData.GetWorkflowUserPrompts();
if (prompts.Count > 0)
{
// Validation rejection — prompts are surfaced on each DataSet automatically.
// Let the user correct the inputs; do not dispose the workflow yet.
}
else
{
// Hard error — log commitError.Exception, notify the user, and clean up.
await OData.DisposeAsync();
}
}
DataSet State After a Hard Commit Failure
If a hard error occurs during CommitWorkflowAsync(), the local DataSet state may be out of sync with the server. The server may have partially applied or rolled back the transaction. Call RefreshAsync() on all affected DataSets before allowing further edits:
catch (Exception)
{
await _orderDataSet.RefreshAsync();
await _lineDataSet.RefreshAsync();
throw;
}
try/finally Cleanup
If the user navigates away mid-workflow, the IODataClient is disposed with the page (because it is scoped to the page's DI lifetime). DisposeAsync sends a best-effort close request to api/workflow/close so the server can release the buffered state. You do not need to call DisposeAsync manually in normal navigation scenarios.
For imperative control flows where you want to abandon a workflow early:
private async Task CancelAsync()
{
await OData.DisposeAsync();
// WorkflowId is now null; a new BeginWorkflowAsync() can be called if needed.
NavigationManager.NavigateTo("/orders");
}
Relationship to DataSet
A DataSet uses the IODataClient passed at construction time. It has no direct knowledge of workflows — it simply calls PatchAsync, PostAsync, and DeleteAsync on the client. When a workflow is active on that client, all those calls carry the workflow headers automatically.
Scoping Implications
IODataClient should be registered as a scoped service (one instance per page or per DI scope). All DataSets on a page share the same scoped client, which is the correct behavior: beginning a workflow on the client causes every DataSet on that page to participate.
Do not share a single IODataClient instance across multiple pages or components that have independent lifecycles. Doing so would cause their mutations to be bundled into the same workflow unintentionally.
If you need isolated transactions within the same page, inject separate IODataClient instances for each logical group and begin workflows on each independently.
Code Examples
1. Basic Workflow: Begin, Mutate, Commit
private async Task SubmitOrderAsync()
{
var begun = await OData.BeginWorkflowAsync();
if (!begun.IsSuccess(out _, out var err))
{
_errorMessage = err.Exception.Message;
return;
}
await _orderDataSet.SetAsync("ShipToName", _shipToName);
await _orderDataSet.SetAsync("ShipToAddress", _shipToAddress);
await _orderDataSet.SetAsync("RequestedDate", _requestedDate);
var commit = await OData.CommitWorkflowAsync();
if (commit.IsSuccess(out _, out _))
{
NavigationManager.NavigateTo($"/orders/{_orderDataSet.Guid}");
}
else
{
// Prompts are now visible on the DataSet controls via PropertyComponent.
// No navigation — keep the page open for corrections.
}
}
2. Reading UserPrompts After a Mid-Workflow 400 Response
During a workflow, a 400 response from SetAsync is not surfaced as an error to the DataSet — it is treated as a buffered operation with messages. The prompts are available immediately via GetUserPrompts:
await _orderDataSet.SetAsync("CustomerGuid", _selectedCustomerGuid);
// After SetAsync returns, check if the server attached any messages.
var prompts = _orderDataSet.GetUserPrompts("CustomerGuid");
foreach (var prompt in prompts)
{
if (prompt.Level == 0) // Error
Console.WriteLine($"Error on CustomerGuid: {prompt.Text}");
else if (prompt.Level == 1) // Warning
Console.WriteLine($"Warning: {prompt.Text}");
}
// Error-level prompts do not block further buffering. You can continue
// setting other fields; errors will re-evaluate on commit.
await _orderDataSet.SetAsync("OrderDate", _orderDate);
3. try/finally Pattern Ensuring Cleanup on Exception or Navigation
private bool _workflowActive;
private async Task SaveAsync()
{
var begun = await OData.BeginWorkflowAsync();
if (!begun.IsSuccess(out _, out var err))
{
_errorMessage = err.Exception.Message;
return;
}
_workflowActive = true;
try
{
await _dataSet.SetAsync("Title", _title);
await _dataSet.SetAsync("Description", _description);
await _dataSet.SetAsync("AssigneeGuid", _assigneeGuid);
var commit = await OData.CommitWorkflowAsync();
if (commit.IsSuccess(out _, out _))
{
_workflowActive = false;
NavigationManager.NavigateTo("/done");
}
// On validation rejection, leave _workflowActive = true so the user
// can correct and resubmit without beginning a new workflow.
}
catch
{
_workflowActive = false;
await OData.DisposeAsync();
await _dataSet.RefreshAsync();
throw;
}
}
4. Two-Step Pattern: Gather Inputs, Then Apply All
Collect all user inputs first, then open the workflow and apply them. This keeps the workflow window short and ensures the server sees the final intended state in as few round-trips as possible:
// Step 1: user fills the form — no workflow active, no PATCH calls.
// _form fields are bound to local variables, not to DataSet.SetAsync.
// Step 2: user clicks Save.
private async Task ApplyFormAsync()
{
var begun = await OData.BeginWorkflowAsync();
if (!begun.IsSuccess(out _, out var err))
{
_errorMessage = err.Exception.Message;
return;
}
try
{
// Apply all gathered inputs sequentially.
// Each call buffers the change and returns computed server state.
await _projectDataSet.SetAsync("Name", _form.Name);
await _projectDataSet.SetAsync("StartDate", _form.StartDate);
await _projectDataSet.SetAsync("OwnerGuid", _form.OwnerGuid);
await _projectDataSet.SetAsync("BudgetCode", _form.BudgetCode);
// Check for error-level prompts before committing.
bool hasErrors = _projectDataSet.GetUserPrompts().Any(p => p.Level == 0);
if (hasErrors)
{
// Don't commit yet — surface prompts to the user for correction.
return;
}
var commit = await OData.CommitWorkflowAsync();
if (commit.IsSuccess(out _, out _))
{
// Show confirmation, navigate, etc.
_saved = true;
}
else
{
// Validation errors surfaced via DataSet prompts.
}
}
catch
{
await OData.DisposeAsync();
await _projectDataSet.RefreshAsync();
throw;
}
}