Implementing Idempotent Commands in .NET MAUI
#๐งท Implementing Idempotent Commands in .NET MAUI
Modern mobile applications are asynchronous by nature.
A user taps Save, Submit, Pay, Sync, or Confirm, and the application starts an operation that may involve local persistence, an API request, navigation, authentication, or several services working together.
Most of the time, everything works as expected.
But what happens when the same logical operation is executed twice?
User taps "Submit"
โ
โโโโโโโโโโโโโโโโโ
โผ โผ
Command #1 Command #2
โ โ
โผ โผ
POST /order POST /order
โ โ
โผ โผ
Order created Order created
The UI may have received two taps.
A retry mechanism may execute the request again.
The application may resume and replay an operation.
A synchronization engine may redeliver a command.
Or the first request may have reached the server while its response was lost.
The technical executions are different, but from the business perspective they may represent exactly the same operation.
This is where idempotent commands become valuable. ๐งท
Instead of only preventing concurrent execution, we give each logical command a stable identity and design the system so processing that command multiple times produces the same intended business effect as processing it once.
In this article, we'll build a practical idempotent command architecture for .NET MAUI using C#, command identifiers, duplicate detection, persistent command tracking, concurrency protection, HTTP idempotency, MVVM integration, and automated tests.
๐ Table of Contents
- What Does Idempotency Mean?
- Duplicate Execution in Mobile Apps
- Idempotency vs Concurrency Protection
- Designing an Idempotent Command
- Command IDs
- Command Results
- The Command Handler
- Tracking Processed Commands
- In-Memory Idempotency
- Persistent Idempotency
- Handling Concurrent Duplicates
- Integrating with MVVM
- HTTP Idempotency
- Client vs Server Idempotency
- Failure Windows
- Expiration and Cleanup
- Testing
- Common Mistakes
- When Idempotency Is Not Necessary
- Production Architecture
- Best Practices
- Conclusion
1. ๐ง What Does Idempotency Mean?
An operation is idempotent when performing the same logical operation multiple times has the same intended effect as performing it once. Conceptually:
Execute(Command A)
Execute(Command A)
Execute(Command A)
โ
โผ
One logical business effect
This doesn't necessarily mean the code physically executes only once.
It means duplicate executions don't produce duplicate business effects. For example:
Set NotificationEnabled = true
is naturally close to idempotent. Executing:
true โ true โ true
still leaves the same state. But:
Create Order
is usually not naturally idempotent.
Executing it three times could create:
Order #1001
Order #1002
Order #1003
when the user intended to create only one order.
Therefore, commands that create side effects often need explicit idempotency semantics.
2. ๐ฑ Where Duplicate Commands Come From
Duplicate execution can appear from many places in a .NET MAUI application.
๐ Double taps
Tap
โ
โโโ SaveCommand
โ
Tap
โ
โโโ SaveCommand
๐ Retries
Request
โ
Timeout
โ
Retry
๐ถ Connectivity recovery
Offline operation
โ
Connectivity restored
โ
Synchronization starts
โ
Same operation replayed
๐ Application lifecycle
OnStart
โ
OnResume
โ
Both trigger synchronization
๐ฌ Persistent queues
A durable queue may intentionally provide at-least-once delivery.
๐ Ambiguous HTTP failures
This is one of the most dangerous cases.
.NET MAUI App API
โ โ
โโโโโ POST /orders โโโโโโโโโบโ
โ โ
โ Order created
โ โ
โ Xโโโโโ 201 โโโโโโโโโโ
โ
Timeout
From the application's perspective:
Request failed
From the server's perspective:
Request succeeded
Retrying without idempotency can duplicate the operation.
3. โ๏ธ Idempotency vs Concurrency Protection
These concepts are related, but they solve different problems. Consider:
private readonly SemaphoreSlim _gate = new(1, 1);
A semaphore can prevent:
Operation A
Operation A
from executing simultaneously inside one process. That's concurrency control. But suppose:
Command executes
โ
โผ
Application terminates
โ
โผ
Application restarts
โ
โผ
Command executes again
The semaphore from the previous process no longer exists.
Likewise, if the command reaches a remote server twice, a client-side semaphore can't prevent the backend from processing both requests.
| Concern | Concurrency Guard | Idempotency |
|---|---|---|
| Double tap | โ | โ |
| Concurrent calls | โ | โ |
| Retry after completion | โ | โ |
| Application restart | โ | โ with persistence |
| HTTP response lost | โ | โ with server support |
| Multiple app instances | โ | โ with shared authority |
| Duplicate business effects | Partial | โ |
The two techniques complement each other.
Concurrency protection
+
Idempotency
=
Stronger duplicate protection
4. ๐งฉ Designing an Idempotent Command
Let's start with a command contract.
public interface IIdempotentCommand
{
Guid CommandId { get; }
}
Then define a command:
public sealed record CreateOrderCommand(
Guid CommandId,
Guid CustomerId,
decimal Total)
: IIdempotentCommand;
Every logical operation now has an identity.
CreateOrderCommand
CommandId = 8ad90...
CustomerId = 72bf1...
Total = 249.99
The critical part is CommandId.
5. ๐ชช Command IDs
The command ID should identify the logical business operation. For example:
var command = new CreateOrderCommand(
Guid.NewGuid(),
customer.Id,
total);
If execution needs to retry, reuse the same command:
Attempt #1 โโ
Attempt #2 โโผโโโบ CommandId = ABC
Attempt #3 โโ
Do not generate a new identifier for every attempt:
Attempt #1 โ CommandId = A
Attempt #2 โ CommandId = B
Attempt #3 โ CommandId = C
The idempotency system would correctly interpret those as three different commands.
The identity belongs to the operation, not the transport attempt.
6. ๐ฆ Returning Command Results
Sometimes ignoring a duplicate isn't enough. Imagine the first command created:
OrderId = 7342
A duplicate arrives later.
Instead of returning:
Duplicate detected
it may be much more useful to return the result of the original operation:
OrderId = 7342
Define:
public sealed record CommandExecutionResult<T>(
T Value,
bool WasPreviouslyProcessed);
The first execution might return:
new CommandExecutionResult<Guid>(
orderId,
false);
A duplicate can return:
new CommandExecutionResult<Guid>(
orderId,
true);
Now callers don't need radically different flows for original and duplicate execution.
7. โ๏ธ Designing the Command Handler
We can define:
public interface IIdempotentCommandHandler<TCommand, TResult>
where TCommand : IIdempotentCommand
{
Task<CommandExecutionResult<TResult>> HandleAsync(
TCommand command,
CancellationToken cancellationToken = default);
}
A concrete handler might look like:
public sealed class CreateOrderCommandHandler
: IIdempotentCommandHandler<
CreateOrderCommand,
Guid>
{
private readonly IOrderService _orders;
private readonly IProcessedCommandStore _processedCommands;
public CreateOrderCommandHandler(
IOrderService orders,
IProcessedCommandStore processedCommands)
{
_orders = orders;
_processedCommands = processedCommands;
}
public async Task<CommandExecutionResult<Guid>> HandleAsync(
CreateOrderCommand command,
CancellationToken cancellationToken = default)
{
var previous =
await _processedCommands.GetAsync<Guid>(
command.CommandId,
cancellationToken);
if (previous is not null)
{
return new CommandExecutionResult<Guid>(
previous.Value,
true);
}
var orderId =
await _orders.CreateAsync(
command.CustomerId,
command.Total,
cancellationToken);
await _processedCommands.StoreAsync(
command.CommandId,
orderId,
cancellationToken);
return new CommandExecutionResult<Guid>(
orderId,
false);
}
}
At first glance, this appears sufficient.
But there's an important race condition hiding inside it.
We'll address that shortly.
8. ๐๏ธ Tracking Processed Commands
We need somewhere to remember completed commands. Define:
public interface IProcessedCommandStore
{
Task<ProcessedCommand<TResult>?> GetAsync<TResult>(
Guid commandId,
CancellationToken cancellationToken = default);
Task StoreAsync<TResult>(
Guid commandId,
TResult result,
CancellationToken cancellationToken = default);
}
And:
public sealed record ProcessedCommand<T>(
Guid CommandId,
T Value,
DateTimeOffset ProcessedAt);
Conceptually:
Processed Commands
โโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ CommandId โ Result โ ProcessedAt โ
โโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโค
โ A102... โ Order 7342 โ 10:15:21 UTC โ
โ B991... โ Order 7343 โ 10:17:04 UTC โ
โ C184... โ Order 7344 โ 10:21:39 UTC โ
โโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโ
When command A102... appears again, we already know it was processed.
9. โก In-Memory Idempotency
For short-lived scenarios, an in-memory store may be sufficient.
public sealed class InMemoryProcessedCommandStore
: IProcessedCommandStore
{
private readonly ConcurrentDictionary<Guid, object>
_results = new();
public Task<ProcessedCommand<TResult>?> GetAsync<TResult>(
Guid commandId,
CancellationToken cancellationToken = default)
{
if (_results.TryGetValue(commandId, out var value) &&
value is ProcessedCommand<TResult> result)
{
return Task.FromResult<
ProcessedCommand<TResult>?>(result);
}
return Task.FromResult<
ProcessedCommand<TResult>?>(null);
}
public Task StoreAsync<TResult>(
Guid commandId,
TResult result,
CancellationToken cancellationToken = default)
{
_results[commandId] =
new ProcessedCommand<TResult>(
commandId,
result,
DateTimeOffset.UtcNow);
return Task.CompletedTask;
}
}
This can protect against duplicates during the current application session. But:
App process
โ
โผ
Memory store
โ
โผ
๐ฅ Process terminated
โ
โผ
Store disappears
After restart, the application no longer remembers previously executed commands.
10. ๐พ Persistent Idempotency
For operations that must survive application restarts, command history should be persisted.
A SQLite table might conceptually contain:
ProcessedCommand
CommandId
CommandType
Result
ProcessedAt
ExpiresAt
A model could look like:
public sealed class ProcessedCommandEntity
{
public string CommandId { get; set; } = string.Empty;
public string CommandType { get; set; } = string.Empty;
public string? SerializedResult { get; set; }
public DateTimeOffset ProcessedAt { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
}
Now:
Execute command
โ
โผ
Persist processed ID
โ
โผ
Application closes
โ
โผ
Application starts
โ
โผ
Same command arrives
โ
โผ
Processed ID still exists
โ
โผ
Duplicate rejected / previous result returned
This is a stronger guarantee.
11. ๐๏ธ The Concurrent Duplicate Problem
Return to our original handler:
if (!await store.ExistsAsync(command.CommandId))
{
await ExecuteAsync(command);
await store.MarkProcessedAsync(command.CommandId);
}
Two concurrent calls can do this:
Command A Command A
โ โ
โผ โผ
Exists? NO Exists? NO
โ โ
โผ โผ
Execute Execute
```text
Both calls check before either stores the result.
This is a classic **check-then-act race condition**.
Idempotency storage alone does not automatically solve concurrency.
* * *
## 12. ๐ Adding Local Concurrency Protection
For process-local protection, we can combine idempotency with a keyed synchronization mechanism.
Conceptually:
```text
Command ABC
โ
โผ
Acquire lock for ABC
โ
โผ
Check processed store
โ
โโโ Exists โโโบ Return previous result
โ
โผ
Execute
โ
โผ
Store result
โ
โผ
Release lock
A simple implementation could maintain semaphores per command ID:
public sealed class CommandExecutionLock
{
private readonly ConcurrentDictionary<Guid, SemaphoreSlim>
_locks = new();
public SemaphoreSlim Get(Guid commandId)
{
return _locks.GetOrAdd(
commandId,
static _ => new SemaphoreSlim(1, 1));
}
}
Then:
var gate = _executionLock.Get(command.CommandId);
await gate.WaitAsync(cancellationToken);
try
{
var previous =
await _processedCommands.GetAsync<Guid>(
command.CommandId,
cancellationToken);
if (previous is not null)
{
return new CommandExecutionResult<Guid>(
previous.Value,
true);
}
var result =
await ExecuteCoreAsync(
command,
cancellationToken);
await _processedCommands.StoreAsync(
command.CommandId,
result,
cancellationToken);
return new CommandExecutionResult<Guid>(
result,
false);
}
finally
{
gate.Release();
}
Now simultaneous invocations with the same command ID are serialized.
13. ๐งน Keyed Lock Cleanup
There is a subtle issue with:
ConcurrentDictionary<Guid, SemaphoreSlim>
If every command creates a semaphore and those entries are never removed:
1,000 commands
10,000 commands
100,000 commands
the dictionary keeps growing.
A production implementation should provide safe lock lifecycle management or use a dedicated keyed-lock abstraction.
Removing a semaphore incorrectly can introduce another race condition, so avoid simplistic cleanup such as blindly calling:
_locks.TryRemove(commandId, out _);
while other callers may still be waiting on that same semaphore.
This is a good example of why synchronization infrastructure deserves careful encapsulation.
14. ๐ฅ๏ธ MVVM Integration
Consider a ViewModel using CommunityToolkit.Mvvm:
public partial class CheckoutViewModel
: ObservableObject
{
private readonly ICheckoutService _checkout;
public CheckoutViewModel(
ICheckoutService checkout)
{
_checkout = checkout;
}
[RelayCommand]
private async Task SubmitAsync()
{
await _checkout.SubmitAsync();
}
}
The command infrastructure can already help prevent accidental concurrent execution depending on how it is configured.
But UI command concurrency and business idempotency are different guarantees.
The ViewModel can create a stable logical command:
private Guid? _pendingCommandId;
[RelayCommand]
private async Task SubmitAsync()
{
_pendingCommandId ??= Guid.NewGuid();
var command =
new SubmitOrderCommand(
_pendingCommandId.Value,
CurrentOrder.Id);
var result =
await _commandHandler.HandleAsync(command);
if (result.Value.Success)
{
_pendingCommandId = null;
}
}
The ID remains stable while the same logical operation is pending.
A brand-new submission receives a new command ID.
15. โ ๏ธ Don't Generate the ID Inside Every Retry
This is wrong:
public async Task SubmitAsync()
{
var command =
new SubmitOrderCommand(
Guid.NewGuid(),
CurrentOrder.Id);
await _handler.HandleAsync(command);
}
if SubmitAsync() itself is repeatedly invoked as a retry of the same logical operation.
Every invocation becomes unique.
Instead:
User starts submission
โ
โผ
Generate CommandId
โ
โผ
Persist / retain command
โ
โโโ Attempt 1
โโโ Attempt 2
โโโ Attempt 3
Only when a new logical operation begins should a new identifier be generated.
16. ๐ HTTP Idempotency
Client-side command tracking helps inside the application.
But if the command creates remote side effects, the backend needs to participate.
Suppose:
await _httpClient.PostAsJsonAsync(
"api/orders",
request,
cancellationToken);
We can attach an idempotency key:
using var requestMessage =
new HttpRequestMessage(
HttpMethod.Post,
"api/orders");
requestMessage.Headers.Add(
"Idempotency-Key",
command.CommandId.ToString("N"));
requestMessage.Content =
JsonContent.Create(request);
using var response =
await _httpClient.SendAsync(
requestMessage,
cancellationToken);
response.EnsureSuccessStatusCode();
Now every transport attempt for that logical command carries the same identity.
CommandId ABC
Attempt 1 โโโโโโโโโโบ Idempotency-Key: ABC
Attempt 2 โโโโโโโโโโบ Idempotency-Key: ABC
Attempt 3 โโโโโโโโโโบ Idempotency-Key: ABC
17. ๐ก๏ธ Server-Side Duplicate Detection
The API can store processed idempotency keys. Conceptually:
Request
โ
โผ
Read Idempotency-Key
โ
โผ
Already processed?
โ
โโโดโโโโโโโโโโโโโโ
โ โ
Yes No
โ โ
โผ โผ
Return Execute
previous โ
result โผ
Store result
โ
โผ
Return
A simplified API implementation might resemble:
var existing =
await idempotencyStore.GetAsync(
idempotencyKey,
cancellationToken);
if (existing is not null)
{
return Results.Ok(existing.Response);
}
var result =
await orderService.CreateAsync(
request,
cancellationToken);
await idempotencyStore.StoreAsync(
idempotencyKey,
result,
cancellationToken);
return Results.Ok(result);
Again, production implementations need to account for concurrency and transactional boundaries.
18. ๐งฑ Client-Side vs Server-Side Idempotency
This distinction is extremely important.
Client-side idempotency
Protects the application from things such as:
Double taps
Local retries
Duplicate command dispatch
Lifecycle-triggered replay
Server-side idempotency
Protects the business system from:
Duplicate HTTP delivery
Lost responses
Multiple devices
Multiple application processes
Proxy/network retries
Persistent queue redelivery
For remote business operations, server-side enforcement is generally the stronger authority.
.NET MAUI
โ
โ CommandId
โผ
REST API
โ
โผ
Idempotency Store
โ
โโโ New โโโโโโโบ Execute
โ
โโโ Existing โโบ Previous result
The client can help.
The server should protect its own invariants.
19. ๐ฅ The Critical Failure Window
Consider:
Execute business operation
โ
โผ
Operation succeeds
โ
โผ
๐ฅ Process crashes
โ
X
Store processed CommandId
The command succeeded, but the idempotency record wasn't persisted. When the command returns:
Processed? NO
it may execute again. This means:
Execute()
then
MarkProcessed()
is not sufficient when both operations require strong atomic guarantees.
20. ๐ Atomic Idempotency on the Backend
If the business state and idempotency state share a transactional database, the strongest approach is often:
BEGIN TRANSACTION
Check IdempotencyKey
Execute business mutation
Store IdempotencyKey + Result
COMMIT
Now:
Business mutation
+
Idempotency record
succeed or fail together.
Conceptually:
await transaction.ExecuteAsync(async () =>
{
var existing =
await idempotencyStore.GetAsync(commandId);
if (existing is not null)
return existing;
var result =
await CreateOrderAsync();
await idempotencyStore.StoreAsync(
commandId,
result);
return result;
});
The exact implementation depends on your backend persistence technology.
The principle is more important than the particular ORM or database.
21. ๐ฌ Idempotent Commands + Outbox
This pattern becomes especially powerful when combined with a persistent Outbox.
User Action
โ
โผ
Command
CommandId = ABC
โ
โผ
Local Transaction
โ
โโโ Business Data
โโโ Outbox Message ABC
โ
โผ
COMMIT
โ
โผ
Outbox Processor
โ
โโโ Attempt 1 โโโบ API [ABC]
โโโ Attempt 2 โโโบ API [ABC]
โโโ Attempt 3 โโโบ API [ABC]
โ
โผ
Idempotency Store
The Outbox gives us:
Durable delivery
The command ID gives us:
Stable operation identity
Server idempotency gives us:
Duplicate-effect protection
Together:
Persistent Outbox
+
Idempotent Command
+
Server Idempotency
=
Reliable retryable operation
22. โณ Expiration
Do we need to remember every command forever? Usually not. Suppose an application processes:
100,000 commands/day
Keeping every idempotency record indefinitely may become expensive. We can add:
public DateTimeOffset ExpiresAt { get; set; }
For example:
ProcessedAt = Sep 17, 2026
ExpiresAt = Sep 24, 2026
The retention period depends on the maximum realistic duplicate-delivery window and the business requirements. Financial or audit-sensitive operations may require very different policies from UI preferences or telemetry commands.
23. ๐งน Cleaning Processed Commands
A maintenance service could periodically remove expired entries:
public interface IProcessedCommandMaintenance
{
Task<int> RemoveExpiredAsync(
DateTimeOffset now,
CancellationToken cancellationToken = default);
}
Processing might happen:
App startup
Periodic maintenance
Database maintenance cycle
Backend scheduled cleanup
Avoid running expensive cleanup every time a command executes.
24. ๐งฌ Command Versioning
Persistent commands may survive application upgrades. Consider:
App v1
โ
โผ
CreateOrderCommand v1
โ
โผ
Persisted offline
โ
โผ
App updated to v2
โ
โผ
Command replayed
If the serialized contract changed incompatibly, the old command may no longer deserialize.
For persistent systems, version command contracts explicitly:
order.create.v1
order.create.v2
or provide migration logic.
This becomes especially important when commands are stored in:
SQLite
Outbox tables
Persistent queues
Background synchronization state
25. ๐ Command Identity vs Entity Identity
Don't confuse:
CommandId
with:
OrderId
They represent different things.
OrderId
โ
โผ
Which business entity?
CommandId
โ
โผ
Which logical operation?
For example:
OrderId = 123
Command A = Create Order 123
Command B = Confirm Order 123
Command C = Cancel Order 123
All three commands operate on the same entity.
They must not share the same idempotency identity.
26. ๐ฆ Idempotency Doesn't Mean "Ignore Everything Twice"
Suppose:
Command A
Amount = $100
Idempotency-Key = XYZ
was processed.
Later someone sends:
Command B
Amount = $500
Idempotency-Key = XYZ
Should the server silently return the original response?
Not necessarily.
A stronger implementation can store a fingerprint of the original request.
For example:
Idempotency Key
+
Request Hash
Then:
Same key + same payload
โ
โผ
Duplicate
Same key + different payload
โ
โผ
Conflict / invalid request
This prevents accidental or malicious reuse of an idempotency key for a different operation.
27. ๐ Request Fingerprints
A simplified fingerprint might be calculated from a canonical representation of relevant request fields. Conceptually:
var fingerprint =
ComputeHash(serializedCommand);
Persist:
CommandId
Fingerprint
Result
ProcessedAt
On duplicate:
CommandId matches?
โ
โผ
Fingerprint matches?
โ โ
Yes No
โ โ
โผ โผ
Return Reject
previous conflict
result
Be careful with serialization ordering and irrelevant metadata when calculating hashes.
Canonicalization matters.
28. ๐ Observability
Idempotency shouldn't be invisible. Useful structured logs include:
_logger.LogInformation(
"Processing command {CommandId} of type {CommandType}",
command.CommandId,
nameof(CreateOrderCommand));
For duplicates:
_logger.LogInformation(
"Duplicate command {CommandId} detected; returning previous result",
command.CommandId);
Useful metrics could include:
commands_processed
commands_duplicate
commands_failed
command_execution_duration
idempotency_store_lookup_duration
A sudden increase in duplicate commands can reveal:
UI double-submit bug
Network instability
Broken retry policy
Synchronization replay
Lifecycle issue
So duplicate detection isn't only a protection mechanism.
It can also be a diagnostic signal. ๐
29. ๐งช Testing Idempotent Commands
Start with the basic invariant. Given:
CommandId = ABC
execute:
var first =
await handler.HandleAsync(command);
var second =
await handler.HandleAsync(command);
Expected:
Business operation count = 1
while both calls return a usable result.
Example:
Assert.False(first.WasPreviouslyProcessed);
Assert.True(second.WasPreviouslyProcessed);
Assert.Equal(
first.Value,
second.Value);
Assert.Equal(
1,
orderService.ExecutionCount);
30. ๐งช Testing Concurrent Duplicates
Sequential duplicate testing isn't enough. The race condition we discussed requires concurrent execution.
var tasks =
Enumerable.Range(0, 20)
.Select(_ =>
handler.HandleAsync(command))
.ToArray();
var results =
await Task.WhenAll(tasks);
Then verify:
Assert.Equal(
1,
orderService.ExecutionCount);
The desired result is:
20 callers
โ
โผ
Same CommandId
โ
โผ
1 business execution
not:
20 callers
โ
โผ
20 business executions
31. ๐งช Testing Retry After Failure
Failure semantics deserve explicit tests. Suppose the first execution fails before any business effect occurs.
Attempt 1
โ
โผ
Timeout before execution
โ
โผ
Retry
The second attempt should usually be allowed.
Test:
Attempt #1 โ failure
Attempt #2 โ success
Attempt #3 โ duplicate โ previous success
This verifies that failed commands aren't incorrectly marked as successfully completed.
32. ๐งช Testing the Ambiguous Failure
The more interesting case is:
Business operation succeeds
โ
โผ
Response lost
The client retries.
Your integration test should verify that the server returns the same logical result without repeating the business side effect.
Expected:
HTTP requests received = 2
Orders created = 1
This is one of the strongest tests you can write for an idempotent API.
33. ๐งช Testing Different Payloads with the Same Key
If using request fingerprints:
Request 1
Key = ABC
Amount = 100
Request 2
Key = ABC
Amount = 500
Expected:
Request 1 โ Success
Request 2 โ Conflict
rather than silently interpreting the second payload as equivalent to the first.
34. โ ๏ธ Common Mistake: Using IsBusy as Idempotency
This:
if (IsBusy)
return;
IsBusy = true;
try
{
await SaveAsync();
}
finally
{
IsBusy = false;
}
is useful UI protection.
It is not durable idempotency.
After:
Process restart
IsBusy is gone. After:
HTTP retry
the server doesn't know about IsBusy.
Use UI state for UI behavior.
Use synchronization primitives for concurrency.
Use idempotency for duplicate business operations.
35. โ ๏ธ Common Mistake: Random IDs Per HTTP Attempt
This defeats the entire design:
request.Headers.Add(
"Idempotency-Key",
Guid.NewGuid().ToString());
inside your HTTP retry loop.
The backend sees:
Attempt 1 = operation A
Attempt 2 = operation B
Attempt 3 = operation C
The key must originate from the logical command.
36. โ ๏ธ Common Mistake: Storing Only a Boolean
A minimal idempotency store might persist:
ABC = processed
Sometimes that's enough.
But if callers need the original result, storing:
CommandId
Result
Status
ProcessedAt
can provide much better semantics.
Consider a duplicate CreateOrder command. Returning:
Already processed
forces the client to discover which order was created. Returning:
OrderId = 7342
can make retries transparent.
37. โ ๏ธ Common Mistake: Making Every Command Idempotent
Idempotency has costs:
Storage
Lookups
Cleanup
Serialization
Concurrency management
Architectural complexity
Not every command needs it.
For example:
Navigate to Settings
Refresh local UI
Open modal
Recalculate display value
Read cached data
may not need durable command identity.
Use idempotency when duplicate execution can produce an incorrect or expensive business effect.
38. ๐ค When Should You Use Idempotent Commands?
Good candidates include:
| Operation | Idempotency Value |
|---|---|
| Create order | High |
| Submit inspection | High |
| Send payment instruction | Critical |
| Confirm reservation | High |
| Upload offline form | High |
| Create support ticket | High |
| Synchronize local mutation | High |
| Update profile | Medium |
| Refresh dashboard | Low |
| Navigate to page | Usually low |
| Read data | Usually unnecessary |
The business semantics determine the requirement.
39. ๐๏ธ Production Architecture
A mature implementation might look like:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ .NET MAUI UI โ
โโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
AsyncRelayCommand
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Application Command โ
โ โ
โ CommandId โ
โ Command Type โ
โ Payload โ
โโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Idempotent Command Handler โ
โ โ
โ Duplicate detection โ
โ Keyed concurrency protection โ
โ Result recovery โ
โโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโดโโโโโโโโโโโ
โผ โผ
Processed Command Business
Store Operation
โ โ
โโโโโโโโโโโฌโโโโโโโโโโโ
โ
โผ
HTTP Client
โ
Idempotency-Key
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ASP.NET Core API โ
โโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
Idempotency Layer
โ
โโโโโโโโโโดโโโโโโโโโ
โผ โผ
Existing New
โ โ
โผ โผ
Previous Result Execute
โ
โผ
Persist Result
For offline-first applications, insert the Outbox between the application command and HTTP delivery:
Command
โ
โผ
Outbox
โ
โผ
Retryable Delivery
โ
โผ
Idempotent API
Each layer solves a different problem.
40. ๐ Best Practices
When implementing idempotent commands in .NET MAUI:
- ๐ชช Give each logical operation a stable command ID.
- ๐ Reuse that ID across retries.
- ๐ซ Never generate a new idempotency key per HTTP attempt.
- ๐ Combine idempotency with concurrency protection when simultaneous duplicates are possible.
- ๐พ Persist processed commands when protection must survive application restarts.
- ๐ฆ Consider storing the original result, not only a processed flag.
- ๐ Send the command ID to remote APIs when the operation has server-side effects.
- ๐ก๏ธ Enforce important business idempotency on the server.
- ๐ Make business mutation and idempotency persistence atomic when strong guarantees are required.
- ๐งฌ Version persistent command contracts.
- ๐ฏ Keep command identity separate from entity identity.
- ๐ Consider request fingerprints to detect key reuse with different payloads.
- โณ Define an explicit retention policy.
- ๐งน Clean expired idempotency records safely.
- ๐ Monitor duplicate rates as an operational signal.
- ๐งช Test sequential and concurrent duplicates.
- ๐ฅ Test ambiguous failures where the server succeeds but the client loses the response.
- ๐ฌ Combine idempotency with an Outbox for durable offline operations.
- ๐ง Define idempotency according to business semantics, not only technical execution.
- โ๏ธ Don't introduce durable idempotency where duplicate execution has no meaningful consequence.
๐ฏ Conclusion
Preventing a button from being tapped twice is useful.
But it isn't the same thing as guaranteeing that a business operation happens only once from the application's perspective.
A production mobile application has many paths through which duplicate operations can appear:
Double taps
Retries
Network failures
Lost responses
Lifecycle events
Offline synchronization
Persistent queues
Application restarts
A simple concurrency guard addresses only part of that problem. Idempotent commands introduce a stronger concept:
Give every logical operation an identity, then make duplicate executions converge on the same business result.
The architecture becomes:
User Action
โ
โผ
Command
โ
CommandId
โ
โผ
Duplicate Detection
โ
โโโโโดโโโโโโโโโโโโโโโโ
โ โ
New Existing
โ โ
โผ โผ
Execute Previous Result
โ
โผ
Persist Result
And when a remote API is involved:
.NET MAUI Command
โ
โผ
Stable CommandId
โ
โผ
HTTP Idempotency-Key
โ
โผ
ASP.NET Core API
โ
โผ
Idempotency Store
โ
โโโโโดโโโโโ
โผ โผ
New Duplicate
โ โ
Execute Return
โ previous
โผ result
Persist
The important distinction is that concurrency protection controls simultaneous execution, while idempotency controls duplicate logical effects.
In many production systems, you need both.
And when combined with a durable Outbox, the model becomes even stronger:
Durable Outbox
+
Stable Command Identity
+
Retry
+
Server-Side Idempotency
=
Reliable Mobile Operations
That architecture allows a .NET MAUI application to retry aggressively enough to be reliable without turning retries, double taps, connectivity changes, or application restarts into duplicate business transactions. ๐งท๐
๐ References
- Microsoft Learn โ Asynchronous programming with async and await https://learn.microsoft.com/dotnet/csharp/asynchronous-programming/
- Microsoft Learn โ SemaphoreSlim Class https://learn.microsoft.com/dotnet/api/system.threading.semaphoreslim
- Microsoft Learn โ ConcurrentDictionary<TKey,TValue> https://learn.microsoft.com/dotnet/api/system.collections.concurrent.concurrentdictionary-2
- Microsoft Learn โ Cancellation in Managed Threads https://learn.microsoft.com/dotnet/standard/threading/cancellation-in-managed-threads
- Microsoft Learn โ .NET MAUI Dependency Injection https://learn.microsoft.com/dotnet/maui/fundamentals/dependency-injection
- Microsoft Learn โ .NET MAUI Connectivity https://learn.microsoft.com/dotnet/maui/platform-integration/communication/networking
- Microsoft Learn โ .NET MAUI App Lifecycle https://learn.microsoft.com/dotnet/maui/fundamentals/app-lifecycle
- Microsoft Learn โ HttpClient Guidelines for .NET https://learn.microsoft.com/dotnet/fundamentals/networking/http/httpclient-guidelines
- CommunityToolkit.Mvvm โ RelayCommand and AsyncRelayCommand https://learn.microsoft.com/dotnet/communitytoolkit/mvvm/relaycommand
- IETF RFC 9110 โ HTTP Semantics: Idempotent Methods https://www.rfc-editor.org/rfc/rfc9110.html#name-idempotent-methods
- Microsoft Azure Architecture Center โ Retry Pattern https://learn.microsoft.com/azure/architecture/patterns/retry
- Microsoft Azure Architecture Center โ Competing Consumers Pattern https://learn.microsoft.com/azure/architecture/patterns/competing-consumers
Was this useful?
Sign in to react. Guest comments are still welcome.




Comments (0)
No approved comments yet.