Learnixo
Back to blog
System Designadvanced

System Design: E-Commerce Checkout in .NET — Inventory Reservation, Idempotent Payments, and Distributed Failure Handling

Design a production e-commerce checkout flow in .NET: inventory reservation with optimistic concurrency, idempotent Stripe payment, distributed saga for order fulfilment, failure compensation, and the challenges of consistency under concurrent purchases.

Asma Hafeez KhanMay 26, 20269 min read
C#.NETE-CommerceCheckoutInventoryIdempotencySagaSystem DesignCase StudyPayments
Share:𝕏

System Design: E-Commerce Checkout in .NET — Inventory Reservation, Idempotent Payments, and Distributed Failure Handling

Checkout is the most failure-sensitive flow in any e-commerce system. A user who loses their cart at payment time is a lost sale. A double-charge is a legal problem. An oversold product is a fulfilment nightmare. This case study covers the design of a checkout system that handles these failure modes correctly — at the cost of explicit complexity that simpler systems avoid.

System: B2B wholesale marketplace, 8K concurrent users at peak, 200 SKUs with inventory down to single units, Stripe for payments, PostgreSQL for orders and inventory, Redis for sessions.


System Overview

Browser → CartService → CheckoutService → InventoryService
                                ↓
                         PaymentService (Stripe)
                                ↓
                         OrderFulfilmentSaga
                                ↓
                    WarehouseService / NotificationService

The checkout saga has five steps, each of which can fail:

  1. Reserve inventory (hold stock for 15 minutes)
  2. Create order record
  3. Charge payment
  4. Confirm inventory (convert reservation to deduction)
  5. Emit fulfilment event

Each step must be compensatable: if step 3 fails after step 2, the reservation must be released and the order record must be marked failed — not left as a ghost order.


Data Model

C#
public class Product
{
    public int Id { get; private set; }
    public string Sku { get; private set; } = "";
    public int StockQuantity { get; private set; }      // actual stock
    public int ReservedQuantity { get; private set; }   // held by active reservations
    public int AvailableQuantity => StockQuantity - ReservedQuantity;

    // Optimistic concurrency — prevents double-reserving the last unit
    public uint RowVersion { get; private set; }

    public bool Reserve(int quantity)
    {
        if (AvailableQuantity < quantity) return false;
        ReservedQuantity += quantity;
        return true;
    }

    public void ConfirmReservation(int quantity)
    {
        StockQuantity -= quantity;
        ReservedQuantity -= quantity;
    }

    public void ReleaseReservation(int quantity)
    {
        ReservedQuantity = Math.Max(0, ReservedQuantity - quantity);
    }
}

public class InventoryReservation
{
    public Guid Id { get; private set; }
    public int ProductId { get; private set; }
    public int Quantity { get; private set; }
    public string CheckoutSessionId { get; private set; } = "";
    public ReservationStatus Status { get; private set; }
    public DateTime ExpiresAt { get; private set; }

    public enum ReservationStatus { Active, Confirmed, Released, Expired }
}

public class Order
{
    public Guid Id { get; private set; }
    public string CustomerId { get; private set; } = "";
    public OrderStatus Status { get; private set; }
    public string? PaymentIntentId { get; private set; }
    public string? IdempotencyKey { get; private set; }
    public decimal Total { get; private set; }
    private readonly List<OrderLine> _lines = new();
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();

    public enum OrderStatus
    {
        PendingPayment, PaymentProcessing, Paid, PaymentFailed, Cancelled, Fulfilled
    }
}

Design Decision 1: Optimistic Concurrency for Inventory

The most common checkout failure at scale is two users buying the last unit simultaneously. The database receives two UPDATE products SET reserved = reserved + 1 WHERE id = X queries — both see available = 1, both succeed, and you've oversold.

The fix is optimistic concurrency on the Product row:

C#
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.HasKey(p => p.Id);
        builder.Property(p => p.RowVersion).IsRowVersion(); // SQL Server
        // PostgreSQL: use xmin as concurrency token
        // builder.UseXminAsConcurrencyToken();
    }
}
C#
// InventoryService.cs
public async Task<ReservationResult> ReserveAsync(
    int productId,
    int quantity,
    string checkoutSessionId,
    CancellationToken ct)
{
    const int MaxRetries = 3;

    for (int attempt = 0; attempt < MaxRetries; attempt++)
    {
        await using var tx = await _db.Database.BeginTransactionAsync(ct);

        var product = await _db.Products
            .FirstOrDefaultAsync(p => p.Id == productId, ct);

        if (product is null)
            return ReservationResult.Fail("Product not found");

        if (!product.Reserve(quantity))
            return ReservationResult.Fail($"Insufficient stock: {product.AvailableQuantity} available");

        var reservation = new InventoryReservation(
            productId, quantity, checkoutSessionId,
            expiresAt: DateTime.UtcNow.AddMinutes(15));

        _db.InventoryReservations.Add(reservation);

        try
        {
            await _db.SaveChangesAsync(ct); // throws DbUpdateConcurrencyException if row changed
            await tx.CommitAsync(ct);
            return ReservationResult.Ok(reservation.Id);
        }
        catch (DbUpdateConcurrencyException)
        {
            await tx.RollbackAsync(ct);
            if (attempt == MaxRetries - 1)
                return ReservationResult.Fail("Inventory temporarily unavailable — please retry");

            await Task.Delay(TimeSpan.FromMilliseconds(50 * (attempt + 1)), ct);
        }
    }

    return ReservationResult.Fail("Inventory temporarily unavailable");
}

The concurrency exception means another transaction won the race. We retry up to 3 times with backoff — for the majority of real-world cases (non-flash-sale), the retry succeeds because the other buyer took a different product.


Design Decision 2: Idempotent Payment with Stripe

Stripe charges can fail in transit. The browser might timeout, the server might crash after the charge succeeded but before the order was updated. Charging twice is a cardinal sin. Idempotency keys prevent it.

C#
public class PaymentService
{
    private readonly StripeClient _stripe;

    public async Task<PaymentResult> ChargeAsync(
        Order order,
        string paymentMethodId,
        CancellationToken ct)
    {
        // Idempotency key derived from the order ID — same order always produces same key
        // Stripe deduplicates requests with the same key within 24 hours
        var idempotencyKey = $"order-charge-{order.Id}";

        try
        {
            var options = new PaymentIntentCreateOptions
            {
                Amount = (long)(order.Total * 100),  // Stripe uses cents
                Currency = "gbp",
                PaymentMethod = paymentMethodId,
                Confirm = true,
                ReturnUrl = $"https://myapp.com/checkout/complete?order={order.Id}",
                Metadata = new Dictionary<string, string>
                {
                    ["order_id"] = order.Id.ToString(),
                    ["customer_id"] = order.CustomerId,
                },
            };

            var requestOptions = new RequestOptions
            {
                IdempotencyKey = idempotencyKey,
            };

            var intent = await _stripe.PaymentIntents.CreateAsync(
                options, requestOptions, ct);

            return intent.Status switch
            {
                "succeeded"        => PaymentResult.Ok(intent.Id),
                "requires_action"  => PaymentResult.RequiresAction(intent.ClientSecret),
                _                  => PaymentResult.Fail(intent.LastPaymentError?.Message ?? "Payment failed"),
            };
        }
        catch (StripeException ex) when (ex.HttpStatusCode == HttpStatusCode.Conflict)
        {
            // Idempotency conflict: a different request with the same key is in flight
            // Wait and retrieve the result
            await Task.Delay(500, ct);
            var existing = await _stripe.PaymentIntents.GetAsync(
                $"order-charge-{order.Id}", cancellationToken: ct);
            return existing.Status == "succeeded"
                ? PaymentResult.Ok(existing.Id)
                : PaymentResult.Fail("Payment state unknown — contact support");
        }
    }
}

Design Decision 3: The Checkout Saga

C#
public class CheckoutSaga
{
    private readonly IInventoryService _inventory;
    private readonly IPaymentService _payment;
    private readonly IOrderRepository _orders;
    private readonly IPublishEndpoint _bus;
    private readonly ILogger<CheckoutSaga> _logger;

    public async Task<CheckoutResult> ExecuteAsync(
        CheckoutCommand command,
        CancellationToken ct)
    {
        Guid? reservationId = null;
        Guid? orderId = null;

        try
        {
            // Step 1: Reserve inventory
            var reservation = await _inventory.ReserveAsync(
                command.ProductId, command.Quantity, command.SessionId, ct);

            if (!reservation.Success)
                return CheckoutResult.Fail(reservation.Error!);

            reservationId = reservation.ReservationId;

            // Step 2: Create order (status = PendingPayment)
            var order = Order.Create(command.CustomerId, command.ProductId,
                command.Quantity, command.UnitPrice);
            await _orders.AddAsync(order, ct);
            orderId = order.Id;

            // Step 3: Charge payment
            var payment = await _payment.ChargeAsync(
                order, command.PaymentMethodId, ct);

            if (!payment.Success)
            {
                await CompensateAsync(reservationId, orderId, "payment_failed", ct);
                return CheckoutResult.Fail(payment.Error!);
            }

            // Step 4: Confirm inventory + mark order paid (same DB transaction)
            await using var tx = await _orders.BeginTransactionAsync(ct);
            await _inventory.ConfirmReservationAsync(reservationId.Value, ct);
            await _orders.MarkPaidAsync(orderId.Value, payment.PaymentIntentId!, ct);
            await tx.CommitAsync(ct);

            // Step 5: Emit fulfilment event
            await _bus.Publish(new OrderPaidEvent(orderId.Value, command.CustomerId), ct);

            return CheckoutResult.Ok(orderId.Value);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            _logger.LogError(ex, "Checkout saga failed for session {SessionId}", command.SessionId);

            if (reservationId.HasValue || orderId.HasValue)
                await CompensateAsync(reservationId, orderId, "exception", ct);

            throw;
        }
    }

    private async Task CompensateAsync(
        Guid? reservationId,
        Guid? orderId,
        string reason,
        CancellationToken ct)
    {
        if (reservationId.HasValue)
        {
            try { await _inventory.ReleaseReservationAsync(reservationId.Value, ct); }
            catch (Exception ex) { _logger.LogError(ex, "Failed to release reservation {Id}", reservationId); }
        }

        if (orderId.HasValue)
        {
            try { await _orders.MarkFailedAsync(orderId.Value, reason, ct); }
            catch (Exception ex) { _logger.LogError(ex, "Failed to mark order {Id} failed", orderId); }
        }
    }
}

Challenge 1: Flash Sale — 5,000 Users Hit Checkout Simultaneously

When 5,000 users try to buy 100 units simultaneously, most will fail with concurrency exceptions. The naive implementation retries indefinitely — consuming thread pool, connection pool, and DB resources until the server crashes.

Solution: Queued checkout with a reservation token

C#
// Pre-sale: issue reservation tokens (like a virtual queue)
// Only 100 tokens issued — matches available stock
// Users without a token see "sold out" immediately without hitting DB

public class ReservationTokenService
{
    private readonly IDatabase _redis;

    public async Task<string?> AcquireTokenAsync(string productId, string userId)
    {
        var key = $"flash-sale-tokens:{productId}";
        var token = $"{userId}:{Guid.NewGuid()}";

        // SETNX-style: add to set only if set size < max
        var script = """
            local count = redis.call('SCARD', KEYS[1])
            if count < tonumber(ARGV[2]) then
                redis.call('SADD', KEYS[1], ARGV[1])
                redis.call('EXPIRE', KEYS[1], 900)
                return 1
            end
            return 0
            """;

        var result = await _redis.ScriptEvaluateAsync(
            script,
            keys: [new RedisKey(key)],
            values: [token, "100"]);

        return (int)result == 1 ? token : null;
    }
}

Only token holders proceed to checkout. Everyone else gets a "sold out" response without touching the database.


Challenge 2: Reservation Expiry — Stock Locked by Abandoned Carts

Users add to cart, start checkout, abandon. Reservations hold stock for 15 minutes. Under load, 15 minutes of abandoned reservations can lock all available stock.

C#
// ReservationExpiryWorker.cs
public class ReservationExpiryWorker : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));

        while (await timer.WaitForNextTickAsync(ct))
        {
            await using var scope = _scopeFactory.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

            var expired = await db.InventoryReservations
                .Where(r => r.Status == ReservationStatus.Active && r.ExpiresAt < DateTime.UtcNow)
                .ToListAsync(ct);

            foreach (var reservation in expired)
            {
                var product = await db.Products.FindAsync([reservation.ProductId], ct);
                if (product is not null)
                    product.ReleaseReservation(reservation.Quantity);

                reservation.MarkExpired();
            }

            await db.SaveChangesAsync(ct);

            _logger.LogInformation("Expired {Count} inventory reservations", expired.Count);
        }
    }
}

Also reduce the reservation window for flash sales to 5 minutes instead of 15.


Challenge 3: Payment Succeeded but Order Not Updated

The server processes the Stripe charge successfully, then crashes before writing ORDER status = Paid to the database. On restart, the order shows PendingPayment — the customer paid but their order looks stuck.

Solution: Stripe webhook as the source of truth for payment state

C#
// WebhookController.cs
[HttpPost("/webhooks/stripe")]
public async Task<IActionResult> Handle([FromBody] string json)
{
    var stripeEvent = EventUtility.ConstructEvent(
        json,
        Request.Headers["Stripe-Signature"],
        _config["Stripe:WebhookSecret"]);

    if (stripeEvent.Type == Events.PaymentIntentSucceeded)
    {
        var intent = (PaymentIntent)stripeEvent.Data.Object;
        var orderId = Guid.Parse(intent.Metadata["order_id"]);

        // Idempotent: if order is already paid, this is a no-op
        await _orders.EnsurePaidAsync(orderId, intent.Id);
    }

    return Ok();
}

The saga writes the optimistic "Paid" status. The webhook guarantees eventual consistency — even if the saga crashed, the webhook reconciles the state within seconds.


What We'd Do Differently

Use a proper saga framework. MassTransit's saga state machine handles persistence, retries, timeouts, and compensation automatically. Hand-rolling a saga is error-prone — you will miss compensation cases.

Separate the inventory service. Inventory contention is the performance bottleneck. A dedicated service with its own database (not shared with orders) allows independent scaling and cache warming per product.

Shorter reservation windows with renewal. 15-minute reservations lock stock unnecessarily. Implement 3-minute reservations with in-page renewal pings — the frontend pings every 90 seconds, the backend resets the expiry.

Pre-compute checkout eligibility. Before the user clicks "Pay", validate everything (stock, payment method validity, address) and issue a short-lived checkout token. The actual checkout step is then just token redemption — dramatically reducing failure modes.

Enjoyed this article?

Explore the System Design learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.