Skip to main content

Transfer between keys with a transaction

In this tutorial, you move a balance from one account to another so that both writes succeed together or neither does. You use a transaction: staged writes stay invisible until you commit, the commit applies them atomically, and if another writer changed the same data underneath you the commit reports a conflict instead of corrupting the balances. This is the classic transfer-between-two-accounts problem, and it is exactly what transactions exist to solve. You need the .NET 8 SDK or later and a text editor.

A transaction is created from IZarisClient with BeginTransactionAsync and gives you an IZarisTransaction. You read and write through the transaction, then call CommitAsync. Until you commit, no other reader sees your changes.

1. Create a project and add the SDK

Create a console project and add the Zaris SDK.

dotnet new console -o zaris-transactions
cd zaris-transactions
dotnet add package Clustron.Zaris.SDK

2. Get a client

In Program.cs, register an in-process store and resolve the client. Transactions are on the base client, so you resolve it as usual.

using Clustron.Zaris.Abstractions;
using Clustron.Zaris.Abstractions.Transactions;
using Clustron.Zaris.Client;
using Clustron.Zaris.Client.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection()
.AddClustronZaris("demo", "zaris://inproc/demo")
.BuildServiceProvider();

var client = (IZaris)await services
.GetRequiredService<IZarisClientProvider>()
.GetAsync("demo");

3. Seed two accounts

Give two accounts a starting balance with ordinary puts. The invariant you want to preserve is that the two balances always add up to 300, no matter how many transfers run.

await client.PutAsync("acct:alice", 200);
await client.PutAsync("acct:bob", 100);

4. Begin a transaction and stage the transfer

Open a transaction with BeginTransactionAsync, read both balances through the transaction, and stage the debit and credit with the transaction's PutAsync. The await using ensures the transaction is disposed — and, if you never commit, automatically rolled back.

await using var tx = await client.BeginTransactionAsync();

var from = await tx.GetAsync<int>("acct:alice");
var to = await tx.GetAsync<int>("acct:bob");

var amount = 50;

if (!from.IsSuccess || from.Value < amount)
{
Console.WriteLine("Insufficient funds; nothing staged.");
await tx.RollbackAsync();
return;
}

await tx.PutAsync("acct:alice", from.Value - amount);
await tx.PutAsync("acct:bob", to.Value + amount);

At this point the new balances exist only inside the transaction. A separate read on the client still sees 200 and 100, because staged writes are invisible until commit.

5. Commit and check the outcome

Call CommitAsync to apply both writes at once. It returns a TransactionResult: IsSuccess tells you whether it committed, and Status gives the detail — TransactionStatus.Committed on success, or TransactionStatus.Conflict when another writer changed one of your keys after you read it.

var result = await tx.CommitAsync();

if (result.Status == TransactionStatus.Committed)
{
Console.WriteLine("Transfer committed.");
}
else if (result.Status == TransactionStatus.Conflict)
{
Console.WriteLine("Conflict: another writer changed an account. Retry the transfer.");
}
else
{
Console.WriteLine($"Transfer failed: {result.Status} - {result.ErrorMessage}");
}

6. Confirm the balances moved together

Read both accounts on the client after the commit. Both writes are now visible, and the total is unchanged.

var alice = await client.GetAsync<int>("acct:alice");
var bob = await client.GetAsync<int>("acct:bob");

Console.WriteLine($"Alice: {alice.Value}, Bob: {bob.Value}, Total: {alice.Value + bob.Value}");

After a successful transfer this prints Alice: 150, Bob: 150, Total: 300. The total is always 300 because the debit and credit either both applied or both did not.

7. Handle a conflict by retrying

A conflict is not a failure of your logic — it means the data moved under you, so the safe response is to read again and retry. Wrap the transfer in a small retry loop that re-reads the current balances on each attempt.

async Task<bool> TransferAsync(string fromKey, string toKey, int amount, int maxAttempts = 3)
{
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
await using var tx = await client.BeginTransactionAsync();

var from = await tx.GetAsync<int>(fromKey);
var to = await tx.GetAsync<int>(toKey);

if (!from.IsSuccess || from.Value < amount)
return false;

await tx.PutAsync(fromKey, from.Value - amount);
await tx.PutAsync(toKey, to.Value + amount);

var result = await tx.CommitAsync();

if (result.Status == TransactionStatus.Committed)
return true;
if (result.Status != TransactionStatus.Conflict)
return false; // a non-conflict failure will not be fixed by retrying
}

return false; // gave up after repeated conflicts
}

Because each attempt re-reads inside a fresh transaction, a retry always works from the latest balances rather than stale ones, so concurrent transfers converge without ever violating the invariant.

What you built

You built an atomic transfer between two keys. BeginTransactionAsync gave you an IZarisTransaction whose staged PutAsync calls stayed invisible until CommitAsync, at which point both writes applied together. You checked TransactionResult.Status to distinguish TransactionStatus.Committed from TransactionStatus.Conflict, treated a conflict as a signal to re-read and retry, and let await using roll back automatically on any path that never commits. The same pattern extends to any set of keys that must change as a unit.

Next steps