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 stages the change in durable workflow state and returns the updated entity, including computed values, validation messages, and any questions. Nothing is persisted to the main tables. A 400 Bad Request from the server during this phase is not an error; it means the operation was staged with messages attached. Because the staged state is durable, the workflow survives a server restart and any server can serve the next request.

  3. CommitCommitWorkflowAsync() posts to api/workflow/commit. The server validates the full transaction and either persists every staged operation atomically or rejects the commit. Commit is blocked while any question is unanswered. 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.userprompts (buffered, not persisted)

    Page->>Client: SetAsync("ProductGuid", guid)
    Client->>Server: PATCH (Workflow-Id + Workflow-Index headers)
    Server-->>Client: Updated entity + @workflow.userprompts

    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. A commit is a success only if it persisted; the prompts (via GetWorkflowUserPrompts()) explain the outcome, and the workflow stays open so you can correct and commit again.

  • CoreODataResponse.Success (HTTP 200) — the transaction persisted. Any prompts present are informational; no Error-level prompts were returned.
  • CoreODataResponse.Error (HTTP 422) — an unanswered question is holding the commit. Answer it (see Answering questions), then call CommitWorkflowAsync() again.
  • CoreODataResponse.Error (HTTP 400) — save-time validation rejected the commit; correct the values and commit again.
  • CoreODataResponse.Error without prompts — a non-validation failure (e.g. a transport or server error).

A committed (200) or question-held (422) commit advances Workflow-Index; a rejected (400) commit does not — the next attempt reuses the same index.

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 user prompts (messages and questions) 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 none 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
Category int Origin category code (validation kind, deletion, system, …); informational
Details string Additional detail text (empty string when absent)
EntityGuid Guid? The entity the prompt applies to, or null for workflow-level prompts
PropertyName string The property this prompt is about — where it groups and what clears it (empty string when absent)
Choices PromptChoice[] The selectable buttons; empty for a one-way message, non-empty for a question

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);

Answering Questions

Some server logic asks a question rather than just reporting a message — for example, "You don't have enough inventory. Ship anyway?" A question carries choices and must be answered before the workflow can commit. Questions only appear inside a workflow.

A question stages its change anyway and then sits: the user can keep editing and answer later. Pending questions surface on the DataSet, distinguished from one-way messages by carrying choices.

Answer a question by passing it and the chosen choice key to AnswerAsync:

var question = _orderDataSet.GetPendingQuestions().FirstOrDefault();
if (question is not null)
{
    await _orderDataSet.AnswerAsync(question, choiceKey: question.DefaultChoice);
    // The server ran the choice and returned the updated question set.
    // Refresh to pull the updated entity state.
    await _orderDataSet.RefreshAsync();
}

There is one way to answer. AnswerAsync follows the chosen choice's continuation link (the href the server publishes on each choice) and POSTs it; the server runs the choice against the staged state and returns the remaining questions. The client replaces its pending set from that response — it never merges. A separate refresh pulls updated entity values.

Commit stays blocked until no questions remain:

if (_orderDataSet.GetPendingQuestions().Length == 0)
    await OData.CommitWorkflowAsync();

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 these distinct cases:

Case error.Messages Meaning
Question pending (HTTP 422) Populated (contains a question) A question is holding the commit. The workflow remains open. Answer the question, then call CommitWorkflowAsync() again.
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;
    }
}