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:

  1. BeginBeginWorkflowAsync() posts to api/workflow/ and receives a server-allocated WorkflowId (Guid). The IODataClient stores this ID and attaches it as a Workflow-Id header (plus an auto-incrementing Workflow-Index) on every subsequent request.

  2. Buffer — While the workflow is active, each SetAsync on 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. A 400 Bad Request from the server during this phase is not an error; it means the operation was accepted into the workflow state with validation messages attached.

  3. CommitCommitWorkflowAsync() posts to api/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 the IODataClient is disposed via DisposeAsync.

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.Success if the commit succeeded. If the response body includes validation messages they are accessible via GetWorkflowUserPrompts(), but no Error-level prompts were present.
  • Returns CoreODataResponse.Error carrying a populated UserPrompts dictionary if the server rejected the commit due to validation errors. The workflow remains open; you can correct values and call CommitWorkflowAsync() again.
  • Returns CoreODataResponse.Error without 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;
    }
}