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

  1. What Does Idempotency Mean?
  2. Duplicate Execution in Mobile Apps
  3. Idempotency vs Concurrency Protection
  4. Designing an Idempotent Command
  5. Command IDs
  6. Command Results
  7. The Command Handler
  8. Tracking Processed Commands
  9. In-Memory Idempotency
  10. Persistent Idempotency
  11. Handling Concurrent Duplicates
  12. Integrating with MVVM
  13. HTTP Idempotency
  14. Client vs Server Idempotency
  15. Failure Windows
  16. Expiration and Cleanup
  17. Testing
  18. Common Mistakes
  19. When Idempotency Is Not Necessary
  20. Production Architecture
  21. Best Practices
  22. 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:

  1. ๐Ÿชช Give each logical operation a stable command ID.
  2. ๐Ÿ” Reuse that ID across retries.
  3. ๐Ÿšซ Never generate a new idempotency key per HTTP attempt.
  4. ๐Ÿ”’ Combine idempotency with concurrency protection when simultaneous duplicates are possible.
  5. ๐Ÿ’พ Persist processed commands when protection must survive application restarts.
  6. ๐Ÿ“ฆ Consider storing the original result, not only a processed flag.
  7. ๐ŸŒ Send the command ID to remote APIs when the operation has server-side effects.
  8. ๐Ÿ›ก๏ธ Enforce important business idempotency on the server.
  9. ๐Ÿ” Make business mutation and idempotency persistence atomic when strong guarantees are required.
  10. ๐Ÿงฌ Version persistent command contracts.
  11. ๐ŸŽฏ Keep command identity separate from entity identity.
  12. ๐Ÿ” Consider request fingerprints to detect key reuse with different payloads.
  13. โณ Define an explicit retention policy.
  14. ๐Ÿงน Clean expired idempotency records safely.
  15. ๐Ÿ“Š Monitor duplicate rates as an operational signal.
  16. ๐Ÿงช Test sequential and concurrent duplicates.
  17. ๐Ÿ’ฅ Test ambiguous failures where the server succeeds but the client loses the response.
  18. ๐Ÿ“ฌ Combine idempotency with an Outbox for durable offline operations.
  19. ๐Ÿง  Define idempotency according to business semantics, not only technical execution.
  20. โš–๏ธ 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

Was this useful?

Comments (0)

Leave a comment

Submit for moderation
An unhandled error has occurred. Reload ๐Ÿ—™