A database transaction is straightforward when all changes happen inside one database. A microservice workflow is different. Creating an order may involve an order record, inventory reservation, payment and notifications, each owned by a different component or external system.
If the third step fails after the first two have already succeeded, there is no single database transaction that can roll everything back. The Saga Pattern addresses this by splitting the workflow into local transactions and defining compensating actions for steps that have already completed.
The problem: one order, several independent operations
Consider a simple checkout flow:
- Create the order.
- Reserve inventory.
- Charge the customer.
- Confirm the order.
If payment fails, the inventory reservation must be released and the order must move to a cancelled state. A normal rollback cannot reverse an API call to a payment provider or undo a reservation already committed by another component.
What the Saga Pattern changes
A Saga treats the complete workflow as a sequence of smaller transactions. Each step commits independently. When a later step fails, the workflow executes one or more compensating actions instead of relying on a global rollback.
For the order example, the forward path is:
CreateOrder → ReserveInventory → ChargePayment → ConfirmOrder
If ChargePayment fails after inventory was reserved, the compensating path can be:
ReleaseInventory → CancelOrder
This is an important distinction: compensation is domain logic. Releasing stock, refunding a payment or cancelling a shipment are explicit business operations, not database rollback commands.
Orchestration or choreography?
Two common Saga styles are orchestration and choreography. In choreography, services react to events and decide what to do next. In orchestration, one component tracks the workflow and decides which step or compensation runs next.
For this example we will use orchestration because the order flow is easy to understand when one .NET process owns the sequence and failure handling. The same principles can later be distributed further if the system requires it.
Implementing the example with .NET and WBert
WBert is not presented here as a dedicated Saga engine. Instead, we use existing backend capabilities to implement the pattern: authenticated API calls, PostgreSQL functions, configured C# processes, queued execution and realtime database changes.
If you are new to the platform, see Introducing WBert: One Backend for Modern Applications for the broader architecture.
1. Call the WBert gateway from .NET
A .NET application can call the WBert gateway with the application API key and the current user's bearer token. The helper below keeps the gateway format in one place.
View C# gateway helper
public async Task<JsonDocument> CallWbertAsync(
string endpoint,
object parameters,
string accessToken,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(
HttpMethod.Post,
$"{_wbertBaseUrl}/api/App/RefMethod");
request.Headers.Add("X-Api-Key", _apiKey);
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
request.Content = JsonContent.Create(new
{
endpoint,
parameters
});
using var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
return JsonDocument.Parse(
await response.Content.ReadAsStringAsync(cancellationToken));
}
2. Create the order with a PostgreSQL function
The order can be created through db.ExecuteFunction. A normal authenticated user call is authorized by WBert before the PostgreSQL function is executed.
View C# db.ExecuteFunction call
var result = await CallWbertAsync(
"db.ExecuteFunction",
new
{
connector = "main",
functionName = "main.fn_create_order",
values = new
{
p_total = 149.90m
}
},
accessToken,
cancellationToken);
The PostgreSQL function can read the authenticated WBert user from transaction-local context instead of trusting a user id supplied by the browser or desktop client.
View PostgreSQL function
create or replace function main.fn_create_order(
p_total numeric
)
returns jsonb
language plpgsql
security invoker
as $$
declare
v_user_id uuid;
v_order_id uuid;
begin
v_user_id := nullif(
current_setting('wbert.user_id', true),
''
)::uuid;
if v_user_id is null then
raise exception 'Authenticated user required';
end if;
insert into main.orders (
id, user_id, status, total
)
values (
gen_random_uuid(),
v_user_id,
'pending',
p_total
)
returning id into v_order_id;
return jsonb_build_object(
'orderId', v_order_id,
'status', 'pending'
);
end;
$$;
The result gives the application an order id in a durable pending state. At this point the distributed workflow can begin.
3. Start the Saga as a configured process
WBert exposes process.Execute for configured C# processes. An order process can be configured for queued execution so the HTTP request does not need to remain open while inventory and payment operations complete.
View process.Execute call
await CallWbertAsync(
"process.Execute",
new
{
processName = "processes.OrderSaga",
parameters = new
{
orderId
}
},
accessToken,
cancellationToken);
The deployment can keep that background work on WBert Internal Queue or use RabbitMQ. The application-facing workflow does not need to change when the queue implementation changes.
4. Execute forward steps and compensation
The orchestrator records which steps have completed. If payment fails after inventory has been reserved, it releases the inventory and marks the order as cancelled.
View simplified C# orchestration logic
public async Task RunOrderSagaAsync(Guid orderId, CancellationToken ct)
{
var inventoryReserved = false;
try
{
await ExecuteFunctionAsync(
"main.fn_reserve_inventory",
new { p_order_id = orderId },
ct);
inventoryReserved = true;
await _paymentGateway.ChargeAsync(orderId, ct);
await ExecuteFunctionAsync(
"main.fn_confirm_order",
new { p_order_id = orderId },
ct);
}
catch
{
if (inventoryReserved)
{
await ExecuteFunctionAsync(
"main.fn_release_inventory",
new { p_order_id = orderId },
ct);
}
await ExecuteFunctionAsync(
"main.fn_cancel_order",
new { p_order_id = orderId },
ct);
throw;
}
}
This sample is intentionally small. In production, each step also needs a durable execution state so a process restart does not lose knowledge of what has already happened.
Retries are not enough: make every step idempotent
Background work can be retried after a timeout, worker restart or transient network failure. A retry must not reserve the same inventory twice or charge the same payment twice.
A practical Saga therefore needs idempotency. Common approaches include an operation id or Saga id stored with each completed step, unique constraints around external operation keys, and payment-provider idempotency keys where the provider supports them.
For example, fn_reserve_inventory can first check whether the reservation for the current order already exists. If it does, the function returns the existing result instead of applying the reservation again.
Persist the Saga state
Do not keep the complete workflow state only in process memory. Store enough information to answer questions such as:
- Which Saga instance belongs to this order?
- Which step is currently running?
- Which steps have completed?
- How many attempts were made?
- Did compensation start or complete?
- What was the last error?
This makes retries and operational recovery predictable. It also provides the data needed for support tooling and observability.
Realtime order status without constant polling
While the Saga runs in the background, the user interface can show the current order state. If the orders table is enabled in WBert Realtime, a client can subscribe to its database changes.
View realtime subscription
client.on("db_changes", {
event: "*",
connector: "main",
schema: "main",
table: "orders"
}, event => {
refreshOrder(event);
});
The UI might move from pending to processing and finally to confirmed or cancelled. The client owns how that state is displayed; WBert provides the backend event channel.
Where WBert fits in this architecture
In this example WBert provides the common backend surface around the workflow:
- Authentication and authorization for application calls.
- db.ExecuteFunction for controlled PostgreSQL operations.
- process.Execute for the configured .NET orchestration process.
- Internal Queue or RabbitMQ for background process execution.
- Realtime db_changes for order status notifications.
The Saga itself still belongs to the application domain. You decide the steps, compensation rules, retry policy, idempotency strategy and the point at which a failure should require manual intervention.
When not to use a Saga
If the entire operation can be completed safely inside one PostgreSQL transaction, use the local transaction. A Saga adds workflow state, retry behavior and compensation logic, so it should solve a real distributed consistency problem rather than replace a simpler transaction.
It becomes useful when one business operation crosses independent transactional boundaries: multiple services, external providers, message-driven workers or other resources that cannot participate in one atomic commit.
Key takeaway
The Saga Pattern does not make distributed work atomic. It makes failure explicit and manageable. Each local step commits independently, and the application defines how to compensate when the workflow cannot continue.
With WBert, that pattern can be implemented using existing backend building blocks rather than exposing database, queue or realtime infrastructure directly to the client.