DEV Community

Ugochukwu Oluo
Ugochukwu Oluo

Posted on

Idempotence in Software Engineering: A Comprehensive Guide

Idempotence in Software Engineering: A Comprehensive Guide

Introduction

Picture this: a customer hits "Pay Now" on your app. The network stalls. Nothing happens on their screen. So they click again. And again. By the time the page finally responds, three charges have gone through instead of one.

That's not a bug you want to ship. And it's exactly the kind of problem that idempotence exists to prevent.

I ran into this while building out a payment flow in ASP.NET. The fix turned out to be one of the most important patterns I've learned — and once you see it, you'll start spotting places in your own code where it's needed. So I put together this guide to break it all down: what idempotence is, why it matters, and how to actually implement it in C# with six real patterns you can drop into your projects.

In simple terms, an operation is idempotent if running it multiple times has the exact same effect as running it once.

Formula: f(f(x)) = f(x)

Why Idempotence Matters

1. Reliability in Distributed Systems

Network failures can cause requests to be sent multiple times. Idempotent operations ensure that duplicate requests don't cause unintended side effects.

2. Safe Retries

When operations fail, systems can safely retry them without worrying about duplicating actions like charging a customer twice or creating multiple records.

3. Consistency

Idempotent operations help maintain data consistency across microservices and databases, even in the face of failures.

4. User Experience

Prevents double-submissions from impatient users clicking "Submit" multiple times.

5. API Design

REST APIs following HTTP standards should implement idempotent methods correctly (GET, PUT, DELETE).

HTTP Methods and Idempotence

HTTP Method Idempotent Safe Description
GET Yes Yes Retrieves data without side effects
PUT Yes No Updates/replaces a resource completely
DELETE Yes No Removes a resource
POST No No Creates new resources or performs actions
PATCH No No Partially updates a resource

Common Scenarios Requiring Idempotence

Scenario 1: Payment Processing

A customer's payment request times out, but the charge went through. Without idempotence, retrying could charge them twice.

Scenario 2: Message Queue Processing

A message consumer crashes after processing a message but before acknowledging it. The message is redelivered and should not be processed twice.

Scenario 3: API Gateway Retries

An API gateway automatically retries failed requests. The backend must handle duplicate requests gracefully.

Scenario 4: User Interface Double-Clicks

Users may click submit buttons multiple times due to slow responses or impatience.

Scenario 5: Distributed Transactions

In microservices, compensating transactions may need to be retried, requiring idempotent operations.

Implementation Patterns in C# and ASP.NET

Pattern 1: Idempotency Key (Request Deduplication)

This pattern uses a unique identifier for each request to detect and handle duplicates. The code is organised across three layers: Contract (interface), Dto (data models), and Controller (endpoint logic).

Contract — IPaymentService.cs

Defines the service boundary. The controller depends on this interface, not on a concrete class.

using IdempotenceGuide.Dto;

namespace IdempotenceGuide.Contract
{
    public interface IPaymentService
    {
        /// <summary>
        /// Processes a payment and returns the result.
        /// Throws on failure — the controller decides how to handle it.
        /// </summary>
        Task<PaymentResponse> ProcessAsync(PaymentRequest request);
    }
}
Enter fullscreen mode Exit fullscreen mode

Dto — PaymentRequest.cs & PaymentResponse.cs

Data Annotations drive automatic model validation in ASP.NET. When the controller uses [ApiController], any request that fails these rules is rejected with a 400 before your code even runs.

using System.ComponentModel.DataAnnotations;

namespace IdempotenceGuide.Dto
{
    public class PaymentRequest
    {
        [Required(ErrorMessage = "Amount is required")]
        [Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than zero")]
        public decimal Amount { get; set; }

        [Required(ErrorMessage = "Currency is required")]
        [StringLength(3, MinimumLength = 3, ErrorMessage = "Currency must be a 3-letter code (e.g. USD)")]
        public string Currency { get; set; }

        [Required(ErrorMessage = "CustomerId is required")]
        public string CustomerId { get; set; }
    }

    public class PaymentResponse
    {
        public string TransactionId { get; set; }
        public string Status { get; set; }
        public DateTime ProcessedAt { get; set; }
    }
}
Enter fullscreen mode Exit fullscreen mode

Controller — RequestDuplicationController.cs

This is where the idempotency logic lives. Every line below is intentional — the reasoning is in the comments.

using IdempotenceGuide.Contract;
using IdempotenceGuide.Dto;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using System.Text.Json;

namespace IdempotenceGuide.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class RequestDuplicationController : ControllerBase
    {
        private readonly IMemoryCache _cache;
        private readonly IPaymentService _paymentService;
        private readonly ILogger<RequestDuplicationController> _logger;

        // Cache key prefix — centralised so it only lives in one place
        private const string CacheKeyPrefix = "idempotency:payment:";

        // How long a processed result is remembered
        private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(24);

        public RequestDuplicationController(
            IMemoryCache cache,
            IPaymentService paymentService,
            ILogger<RequestDuplicationController> logger)
        {
            _cache = cache;
            _paymentService = paymentService;
            _logger = logger;
        }

        [HttpPost]
        public async Task<IActionResult> ProcessPayment(
            [FromBody] PaymentRequest request,
            [FromHeader(Name = "Idempotency-Key")] string idempotencyKey)
        {
            // ── 1. Validate the idempotency key ────────────────────────────
            if (string.IsNullOrEmpty(idempotencyKey))
            {
                _logger.LogWarning("ProcessPayment called without an Idempotency-Key header");
                return BadRequest(new { error = "Idempotency-Key header is required" });
            }

            var cacheKey = $"{CacheKeyPrefix}{idempotencyKey}";

            // ── 2. Return the cached result if this key was already used ───
            if (_cache.TryGetValue(cacheKey, out string cachedJson))
            {
                _logger.LogInformation(
                    "Duplicate request detected for key {IdempotencyKey}. Returning cached result.",
                    idempotencyKey);

                var cachedResponse = JsonSerializer.Deserialize<PaymentResponse>(cachedJson);
                return Ok(cachedResponse);
            }

            // ── 3. Process the payment (first time for this key) ──────────
            try
            {
                var response = await _paymentService.ProcessAsync(request);

                // ── 4. Cache the result with a proper expiration ──────────
                _cache.Set(cacheKey, JsonSerializer.Serialize(response), new MemoryCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = CacheDuration
                });

                _logger.LogInformation(
                    "Payment processed successfully. TransactionId: {TransactionId}, IdempotencyKey: {IdempotencyKey}",
                    response.TransactionId,
                    idempotencyKey);

                return Ok(response);
            }
            catch (Exception ex)
            {
                // ── 5. Do NOT cache failures ───────────────────────────────
                // The same idempotency key can be retried after an error.
                _logger.LogError(ex,
                    "Payment processing failed for IdempotencyKey: {IdempotencyKey}",
                    idempotencyKey);

                return StatusCode(500, new { error = "Payment processing failed. You may retry with the same Idempotency-Key." });
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Program.cs — registering IMemoryCache

// IMemoryCache is a single call — no connection string needed
builder.Services.AddMemoryCache();
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Database-Level Idempotency with Unique Constraints

Using database constraints to prevent duplicate operations.

using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;

namespace IdempotenceExample.Models
{
    // Entity Framework model with unique constraint
    [Index(nameof(OrderId), IsUnique = true)]
    public class Order
    {
        [Key]
        public int Id { get; set; }

        [Required]
        public string OrderId { get; set; } // Client-generated unique ID

        public string CustomerId { get; set; }
        public decimal TotalAmount { get; set; }
        public DateTime CreatedAt { get; set; }
    }
}

namespace IdempotenceExample.Services
{
    public class OrderService
    {
        private readonly ApplicationDbContext _context;

        public OrderService(ApplicationDbContext context)
        {
            _context = context;
        }

        public async Task<Order> CreateOrderAsync(string orderId, string customerId, decimal amount)
        {
            try
            {
                var order = new Order
                {
                    OrderId = orderId,
                    CustomerId = customerId,
                    TotalAmount = amount,
                    CreatedAt = DateTime.UtcNow
                };

                _context.Orders.Add(order);
                await _context.SaveChangesAsync();

                return order;
            }
            catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex))
            {
                // Order already exists, retrieve and return it
                return await _context.Orders
                    .FirstOrDefaultAsync(o => o.OrderId == orderId);
            }
        }

        private bool IsUniqueConstraintViolation(DbUpdateException ex)
        {
            // Check for unique constraint violation
            return ex.InnerException?.Message.Contains("UNIQUE constraint") == true ||
                   ex.InnerException?.Message.Contains("duplicate key") == true;
        }
    }

    public class ApplicationDbContext : DbContext
    {
        public DbSet<Order> Orders { get; set; }

        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Idempotent PUT Operations

PUT operations should replace the entire resource with the provided data.

using Microsoft.AspNetCore.Mvc;

namespace IdempotenceExample.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class ProductController : ControllerBase
    {
        private readonly IProductRepository _repository;

        public ProductController(IProductRepository repository)
        {
            _repository = repository;
        }

        // Idempotent PUT - always produces the same result
        [HttpPut("{id}")]
        public async Task<IActionResult> UpdateProduct(int id, [FromBody] Product product)
        {
            if (id != product.Id)
            {
                return BadRequest("ID mismatch");
            }

            var existingProduct = await _repository.GetByIdAsync(id);

            if (existingProduct == null)
            {
                // Create if doesn't exist (PUT can be used for create)
                await _repository.CreateAsync(product);
                return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
            }

            // Replace entire resource with new data
            existingProduct.Name = product.Name;
            existingProduct.Price = product.Price;
            existingProduct.Description = product.Description;
            existingProduct.Stock = product.Stock;
            existingProduct.UpdatedAt = DateTime.UtcNow;

            await _repository.UpdateAsync(existingProduct);
            return Ok(existingProduct);
        }

        [HttpGet("{id}")]
        public async Task<IActionResult> GetProduct(int id)
        {
            var product = await _repository.GetByIdAsync(id);
            if (product == null)
                return NotFound();

            return Ok(product);
        }
    }

    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
        public string Description { get; set; }
        public int Stock { get; set; }
        public DateTime UpdatedAt { get; set; }
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Middleware for Automatic Idempotency

Creating reusable middleware to handle idempotency across all endpoints.

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Distributed;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

namespace IdempotenceExample.Middleware
{
    public class IdempotencyMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly IDistributedCache _cache;

        public IdempotencyMiddleware(RequestDelegate next, IDistributedCache cache)
        {
            _next = next;
            _cache = cache;
        }

        public async Task InvokeAsync(HttpContext context)
        {
            // Only apply to POST requests
            if (context.Request.Method != HttpMethods.Post)
            {
                await _next(context);
                return;
            }

            var idempotencyKey = context.Request.Headers["Idempotency-Key"].FirstOrDefault();

            if (string.IsNullOrEmpty(idempotencyKey))
            {
                await _next(context);
                return;
            }

            // Check cache for existing response
            var cachedResponse = await _cache.GetStringAsync($"idempotency:{idempotencyKey}");

            if (cachedResponse != null)
            {
                // Return cached response
                var cached = JsonSerializer.Deserialize<CachedResponse>(cachedResponse);
                context.Response.StatusCode = cached.StatusCode;
                context.Response.ContentType = cached.ContentType;
                await context.Response.WriteAsync(cached.Body);
                return;
            }

            // Capture the response
            var originalBodyStream = context.Response.Body;
            using var responseBody = new MemoryStream();
            context.Response.Body = responseBody;

            await _next(context);

            // Cache the response
            context.Response.Body.Seek(0, SeekOrigin.Begin);
            var responseText = await new StreamReader(context.Response.Body).ReadToEndAsync();
            context.Response.Body.Seek(0, SeekOrigin.Begin);

            var cacheEntry = new CachedResponse
            {
                StatusCode = context.Response.StatusCode,
                ContentType = context.Response.ContentType ?? "application/json",
                Body = responseText
            };

            var cacheOptions = new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24)
            };

            await _cache.SetStringAsync(
                $"idempotency:{idempotencyKey}",
                JsonSerializer.Serialize(cacheEntry),
                cacheOptions);

            await responseBody.CopyToAsync(originalBodyStream);
        }
    }

    public class CachedResponse
    {
        public int StatusCode { get; set; }
        public string ContentType { get; set; }
        public string Body { get; set; }
    }

    // Extension method for easy registration
    public static class IdempotencyMiddlewareExtensions
    {
        public static IApplicationBuilder UseIdempotency(this IApplicationBuilder builder)
        {
            return builder.UseMiddleware<IdempotencyMiddleware>();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 5: Message Queue Idempotent Processing

Handling duplicate messages in message queues.

using System.Collections.Concurrent;

namespace IdempotenceExample.Services
{
    public interface IMessageProcessor
    {
        Task ProcessMessageAsync(OrderMessage message);
    }

    public class OrderMessage
    {
        public string MessageId { get; set; }
        public string OrderId { get; set; }
        public string CustomerId { get; set; }
        public decimal Amount { get; set; }
    }

    public class IdempotentMessageProcessor : IMessageProcessor
    {
        private readonly IDistributedCache _cache;
        private readonly IOrderService _orderService;
        private readonly ILogger<IdempotentMessageProcessor> _logger;

        public IdempotentMessageProcessor(
            IDistributedCache cache,
            IOrderService orderService,
            ILogger<IdempotentMessageProcessor> logger)
        {
            _cache = cache;
            _orderService = orderService;
            _logger = logger;
        }

        public async Task ProcessMessageAsync(OrderMessage message)
        {
            var processingKey = $"message:processed:{message.MessageId}";
            var lockKey = $"message:lock:{message.MessageId}";

            // Check if already processed
            if (await _cache.GetStringAsync(processingKey) != null)
            {
                _logger.LogInformation(
                    "Message {MessageId} already processed, skipping",
                    message.MessageId);
                return;
            }

            // Distributed lock to prevent concurrent processing
            var lockAcquired = await TryAcquireLockAsync(lockKey);
            if (!lockAcquired)
            {
                _logger.LogWarning(
                    "Could not acquire lock for message {MessageId}",
                    message.MessageId);
                throw new InvalidOperationException("Message is being processed");
            }

            try
            {
                // Double-check after acquiring lock
                if (await _cache.GetStringAsync(processingKey) != null)
                {
                    return;
                }

                // Process the message
                await _orderService.CreateOrderAsync(
                    message.OrderId,
                    message.CustomerId,
                    message.Amount);

                // Mark as processed (store for 7 days)
                var cacheOptions = new DistributedCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(7)
                };
                await _cache.SetStringAsync(
                    processingKey,
                    DateTime.UtcNow.ToString("O"),
                    cacheOptions);

                _logger.LogInformation(
                    "Successfully processed message {MessageId}",
                    message.MessageId);
            }
            finally
            {
                await ReleaseLockAsync(lockKey);
            }
        }

        private async Task<bool> TryAcquireLockAsync(string lockKey)
        {
            var lockOptions = new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
            };

            try
            {
                await _cache.SetStringAsync(lockKey, "locked", lockOptions);
                return true;
            }
            catch
            {
                return false;
            }
        }

        private async Task ReleaseLockAsync(string lockKey)
        {
            await _cache.RemoveAsync(lockKey);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 6: Conditional Updates (Optimistic Locking)

Using version numbers or ETags to ensure updates are idempotent.

using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;

namespace IdempotenceExample.Controllers
{
    public class Document
    {
        public int Id { get; set; }
        public string Title { get; set; }
        public string Content { get; set; }

        [Timestamp] // EF Core concurrency token
        public byte[] RowVersion { get; set; }

        public DateTime UpdatedAt { get; set; }
    }

    [ApiController]
    [Route("api/[controller]")]
    public class DocumentController : ControllerBase
    {
        private readonly ApplicationDbContext _context;

        public DocumentController(ApplicationDbContext context)
        {
            _context = context;
        }

        [HttpGet("{id}")]
        public async Task<IActionResult> GetDocument(int id)
        {
            var document = await _context.Documents.FindAsync(id);
            if (document == null)
                return NotFound();

            // Return ETag for version tracking
            var etag = Convert.ToBase64String(document.RowVersion);
            Response.Headers.Add("ETag", etag);

            return Ok(document);
        }

        [HttpPut("{id}")]
        public async Task<IActionResult> UpdateDocument(
            int id,
            [FromBody] DocumentUpdateRequest request,
            [FromHeader(Name = "If-Match")] string ifMatch)
        {
            if (string.IsNullOrEmpty(ifMatch))
            {
                return BadRequest("If-Match header is required for idempotent updates");
            }

            var document = await _context.Documents.FindAsync(id);
            if (document == null)
                return NotFound();

            var currentEtag = Convert.ToBase64String(document.RowVersion);
            if (ifMatch != currentEtag)
            {
                return StatusCode(412, "Precondition Failed: Document has been modified");
            }

            try
            {
                document.Title = request.Title;
                document.Content = request.Content;
                document.UpdatedAt = DateTime.UtcNow;

                await _context.SaveChangesAsync();

                var newEtag = Convert.ToBase64String(document.RowVersion);
                Response.Headers.Add("ETag", newEtag);

                return Ok(document);
            }
            catch (DbUpdateConcurrencyException)
            {
                return StatusCode(409, "Conflict: Document was modified by another request");
            }
        }
    }

    public class DocumentUpdateRequest
    {
        public string Title { get; set; }
        public string Content { get; set; }
    }
}
Enter fullscreen mode Exit fullscreen mode

Configuration in Program.cs

using IdempotenceExample.Middleware;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Add services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Add distributed cache for idempotency (Redis recommended for production)
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "IdempotencyCache:";
});

// For development/testing, use in-memory cache
// builder.Services.AddDistributedMemoryCache();

// Add database context using EF Core In-Memory provider
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseInMemoryDatabase("IdempotenceExampleDb"));

// Register services
builder.Services.AddScoped<IPaymentService, PaymentService>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

// Add idempotency middleware
app.UseIdempotency();

app.UseAuthorization();
app.MapControllers();

app.Run();
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Choose the Right Pattern

  • Use idempotency keys for financial transactions and critical operations
  • Use database constraints for entity creation
  • Use optimistic locking for concurrent updates
  • Use middleware for cross-cutting concerns

2. Set Appropriate Cache Expiration

  • Financial operations: 24-48 hours minimum
  • Regular API calls: 1-24 hours
  • Message processing: 7 days or based on message retention policy

3. Handle Edge Cases

  • Cache failures should not break functionality
  • Provide meaningful error messages
  • Log idempotency violations for monitoring

4. Client-Side Considerations

  • Generate unique idempotency keys on the client (UUIDs recommended)
  • Store idempotency keys with requests for retry logic
  • Don't reuse idempotency keys across different operations

5. Documentation

  • Clearly document which endpoints require idempotency keys
  • Specify cache duration and behavior
  • Provide examples in API documentation

Testing Idempotency

The tests below target RequestDuplicationController directly. A real MemoryCache is used instead of a mock so the Set/TryGetValue round-trip is exercised exactly as it runs in production.

using Xunit;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using IdempotenceGuide.Contract;
using IdempotenceGuide.Dto;
using IdempotenceGuide.Controllers;
using Microsoft.AspNetCore.Mvc;

namespace IdempotenceGuide.Tests
{
    public class RequestDuplicationControllerTests
    {
        /// <summary>
        /// Sending the same Idempotency-Key twice must return the
        /// identical response and must only call the payment service once.
        /// </summary>
        [Fact]
        public async Task ProcessPayment_SameKey_ReturnsCachedResultAndCallsServiceOnce()
        {
            // ── Arrange ─────────────────────────────────────────────────
            using var cache = new MemoryCache(new MemoryCacheOptions());
            var logger = NullLogger<RequestDuplicationController>.Instance;

            var expectedResponse = new PaymentResponse
            {
                TransactionId = "TXN-001",
                Status = "Success",
                ProcessedAt = DateTime.UtcNow
            };

            var paymentServiceMock = new Mock<IPaymentService>();
            paymentServiceMock
                .Setup(s => s.ProcessAsync(It.IsAny<PaymentRequest>()))
                .ResolveAsync(expectedResponse);

            var controller = new RequestDuplicationController(cache, paymentServiceMock.Object, logger);

            var request = new PaymentRequest
            {
                Amount = 100.00m,
                Currency = "USD",
                CustomerId = "CUST-123"
            };

            var idempotencyKey = Guid.NewGuid().ToString();

            // ── Act ─────────────────────────────────────────────────────
            var firstResult  = await controller.ProcessPayment(request, idempotencyKey);
            var secondResult = await controller.ProcessPayment(request, idempotencyKey);

            // ── Assert ──────────────────────────────────────────────────
            // Both calls must hand back an OkObjectResult
            var firstOk  = Assert.IsType<OkObjectResult>(firstResult);
            var secondOk = Assert.IsType<OkObjectResult>(secondResult);

            // The serialised JSON inside must be identical
            Assert.Equal(
                System.Text.Json.JsonSerializer.Serialize(firstOk.Value),
                System.Text.Json.JsonSerializer.Serialize(secondOk.Value));

            // The real service must have been called exactly once
            paymentServiceMock.Verify(
                s => s.ProcessAsync(It.IsAny<PaymentRequest>()),
                Times.Once);
        }

        /// <summary>
        /// An empty or missing Idempotency-Key must be rejected
        /// before any payment logic runs.
        /// </summary>
        [Theory]
        [InlineData(null)]
        [InlineData("")]
        public async Task ProcessPayment_MissingKey_ReturnsBadRequest(string idempotencyKey)
        {
            // ── Arrange ─────────────────────────────────────────────────
            using var cache = new MemoryCache(new MemoryCacheOptions());
            var logger = NullLogger<RequestDuplicationController>.Instance;
            var paymentServiceMock = new Mock<IPaymentService>();

            var controller = new RequestDuplicationController(cache, paymentServiceMock.Object, logger);

            var request = new PaymentRequest
            {
                Amount = 50.00m,
                Currency = "EUR",
                CustomerId = "CUST-456"
            };

            // ── Act ─────────────────────────────────────────────────────
            var result = await controller.ProcessPayment(request, idempotencyKey);

            // ── Assert ──────────────────────────────────────────────────
            Assert.IsType<BadRequestObjectResult>(result);

            // Service must never have been reached
            paymentServiceMock.Verify(
                s => s.ProcessAsync(It.IsAny<PaymentRequest>()),
                Times.Never);
        }

        /// <summary>
        /// When the payment service throws, the controller must return 500
        /// and must NOT cache the failure — so the same key can be retried.
        /// </summary>
        [Fact]
        public async Task ProcessPayment_ServiceThrows_ReturnsFiveHundredAndDoesNotCache()
        {
            // ── Arrange ─────────────────────────────────────────────────
            using var cache = new MemoryCache(new MemoryCacheOptions());
            var logger = NullLogger<RequestDuplicationController>.Instance;

            var paymentServiceMock = new Mock<IPaymentService>();
            paymentServiceMock
                .Setup(s => s.ProcessAsync(It.IsAny<PaymentRequest>()))
                .ThrowsAsync(new Exception("Payment gateway timeout"));

            var controller = new RequestDuplicationController(cache, paymentServiceMock.Object, logger);

            var request = new PaymentRequest
            {
                Amount = 75.00m,
                Currency = "GBP",
                CustomerId = "CUST-789"
            };

            var idempotencyKey = Guid.NewGuid().ToString();

            // ── Act ─────────────────────────────────────────────────────
            var result = await controller.ProcessPayment(request, idempotencyKey);

            // ── Assert ──────────────────────────────────────────────────
            var statusResult = Assert.IsType<ObjectResult>(result);
            Assert.Equal(500, statusResult.StatusCode);

            // Cache must still be empty — the key is reusable on retry
            Assert.False(cache.TryGetValue($"idempotency:payment:{idempotencyKey}", out _));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls to Avoid

1. Not Making DELETE Truly Idempotent

// ❌ Bad - returns different status codes
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteProduct(int id)
{
    var product = await _repository.GetByIdAsync(id);
    if (product == null)
        return NotFound(); // First call returns 404, but delete worked

    await _repository.DeleteAsync(id);
    return NoContent(); // Subsequent calls return different response
}

// ✅ Good - always returns success if resource is gone
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteProduct(int id)
{
    await _repository.DeleteAsync(id); // Delete if exists
    return NoContent(); // Always 204, whether deleted now or before
}
Enter fullscreen mode Exit fullscreen mode

2. Using POST for Updates

// ❌ Bad - POST is not idempotent
[HttpPost("update/{id}")]
public async Task<IActionResult> UpdateProduct(int id, Product product)
{
    // Multiple calls may have different effects
}

// ✅ Good - Use PUT for updates
[HttpPut("{id}")]
public async Task<IActionResult> UpdateProduct(int id, Product product)
{
    // Idempotent - same result regardless of calls
}
Enter fullscreen mode Exit fullscreen mode

3. Increment Operations Without Idempotency

// ❌ Bad - not idempotent
public async Task IncrementViewCount(int articleId)
{
    var article = await _repository.GetByIdAsync(articleId);
    article.ViewCount++; // Each retry increments again!
    await _repository.UpdateAsync(article);
}

// ✅ Good - use a specific value
public async Task RecordView(int articleId, string sessionId)
{
    // Use idempotency key to track unique views
    var key = $"view:{articleId}:{sessionId}";
    if (await _cache.GetAsync(key) != null)
        return; // Already counted

    // Increment only for unique views
    await _repository.IncrementViewCountAsync(articleId);
    await _cache.SetAsync(key, Array.Empty<byte>(), 
        new DistributedCacheEntryOptions 
        { 
            AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) 
        });
}
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

Idempotence isn't flashy, but it's one of those patterns that quietly keeps production systems from falling apart. Once you start designing with it in mind — especially around payments, message queues, and any endpoint that a user or a gateway might retry — you'll wonder how you got away without it before.

The six patterns above aren't all meant to be used at the same time. Pick the one that fits your situation: idempotency keys for anything involving money, unique constraints when you're creating records, middleware when you want the protection to apply everywhere without repeating yourself, and optimistic locking when concurrent updates are on the table.

I'm still learning as I go with this stuff, so if you've hit a scenario I didn't cover here — or if you've found a better way to handle any of these patterns — drop a comment. Would love to hear how you're handling idempotence in your own projects.

Thanks for reading, and good luck with your next build! 🚀

Top comments (0)