System Design Interview
Design an Order & Payment Service in .NET
Idempotent payments, outbox events, sagas, and EF Core transactions — the patterns senior .NET interviews expect you to defend
The Interview Question
"Design an order and payment service in ASP.NET Core. Users can place orders and pay. The system must handle duplicate requests, payment provider failures, and inventory consistency. Show how you'd implement this in .NET."
This tests distributed consistency without two-phase commit — the core of senior .NET backend interviews.
Step 1: Requirements
Functional
POST /orders— create order, reserve inventoryPOST /orders/{id}/pay— charge payment providerGET /orders/{id}— order status for UI polling
Non-functional
- Idempotent — double-click "Pay" must not double-charge
- At-least-once event delivery to warehouse service
- p99 place-order under 200ms (excluding payment provider latency)
- Audit trail of every state transition
States: Draft → Reserved → PaymentPending → Paid → Shipped (or Failed / Cancelled)
Step 2: Wrong Approaches
Saga via distributed transaction (MSDTC / 2PC). Not available across PostgreSQL + Stripe. Interviewers mark this down immediately.
Publish to queue before DB commit. If the DB transaction rolls back, you've sent a ghost event. Classic dual-write bug.
Charge payment before reserving inventory. User pays, then inventory fails — painful refunds and support tickets.
Step 3: Correct Write Path — Local Transaction + Outbox
POST /orders/{id}/pay
1. Validate Idempotency-Key header
2. BEGIN transaction
3. Load order (with row version for optimistic concurrency)
4. If already Paid → return 200 (idempotent replay)
5. Call payment provider (or use pre-authorised token)
6. Update order status → Paid
7. INSERT into outbox: OrderPaid { orderId, items, total }
8. COMMIT
9. Return 200public async Task<Result> PayOrderAsync(Guid orderId, string idempotencyKey, CancellationToken ct)
{
var existing = await _db.IdempotencyRecords
.FirstOrDefaultAsync(x => x.Key == idempotencyKey, ct);
if (existing is not null)
return Result.FromStoredResponse(existing.ResponseJson);
await using var tx = await _db.Database.BeginTransactionAsync(ct);
var order = await _db.Orders
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == orderId, ct)
?? throw new NotFoundException();
if (order.Status == OrderStatus.Paid)
{
await tx.CommitAsync(ct);
return Result.Ok(order);
}
var charge = await _payments.ChargeAsync(order.Total, order.PaymentMethodId, ct);
if (!charge.Success)
{
order.MarkPaymentFailed(charge.Error);
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return Result.Fail(charge.Error);
}
order.MarkPaid(charge.TransactionId);
_db.OutboxMessages.Add(OutboxMessage.From(new OrderPaidEvent(order)));
await _db.SaveChangesAsync(ct);
var responseJson = JsonSerializer.Serialize(order);
_db.IdempotencyRecords.Add(new IdempotencyRecord(idempotencyKey, responseJson));
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return Result.Ok(order);
}CREATE TABLE idempotency_records (
key text PRIMARY KEY,
response jsonb NOT NULL,
created_at timestamptz DEFAULT now()
);
-- TTL cleanup job removes keys older than 24hStep 4: Outbox Relay
public class OutboxRelay : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var batch = await _db.OutboxMessages
.Where(m => m.SentAt == null)
.OrderBy(m => m.CreatedAt)
.Take(50)
.ToListAsync(ct);
foreach (var msg in batch)
{
await _bus.PublishAsync(msg.Payload, ct);
msg.SentAt = DateTimeOffset.UtcNow;
}
await _db.SaveChangesAsync(ct);
await Task.Delay(200, ct);
}
}
}Consumers must be idempotent — check ProcessedMessages table by messageId before acting.
Step 5: Inventory — Reserve Before Pay
Place order flow:
1. BEGIN transaction
2. SELECT inventory FOR UPDATE (pessimistic) OR optimistic with RowVersion
3. Decrement stock if available
4. Create order in Reserved state
5. COMMIT
If payment fails:
Compensating action: release reservation (saga step or domain event InventoryReleased)Saga orchestration (when payment + shipping + email span services):
Orchestrator (MassTransit StateMachine or Durable Function):
ReserveInventory → ChargePayment → NotifyWarehouse
On ChargePayment failure → ReleaseInventoryChoreography works for two services; orchestration is clearer when 4+ steps exist.
Step 6: Read Model for UI
GET /orders/{id}
→ Read from Orders table (transactional store)
→ Or projected read model updated by OrderPaid consumer
For "order history" dashboard:
→ Denormalised OrderSummary table updated by event handler
→ Avoids heavy joins on hot read pathArchitecture Diagram
┌──────────────┐ ┌─────────────────────┐ ┌──────────────┐
│ ASP.NET API │────▶│ PostgreSQL │────▶│ Outbox Relay │
│ (commands) │ │ orders · inventory │ │ (hosted svc)│
└──────┬───────┘ │ outbox · idempotency│ └──────┬───────┘
│ └─────────────────────┘ │
│ ▼
▼ ┌─────────────────┐
┌──────────────┐ │ RabbitMQ / ASB │
│ Stripe API │ └────────┬────────┘
└──────────────┘ │
▼
┌─────────────────┐
│ Warehouse svc │
│ (idempotent) │
└─────────────────┘What Interviewers Are Testing
| Topic | What they want to hear |
|-------|------------------------|
| Idempotency | Idempotency-Key header + DB record, not just "check if paid" |
| Outbox | Same transaction as business write; relay polls unpublished rows |
| Compensation | Release inventory on payment failure — not rollback across services |
| EF Core | Optimistic concurrency with RowVersion; transactions explicit |
| Failure modes | Provider timeout → mark PaymentPending, retry with backoff |
Staff-level bonus: Mention payment webhooks (Stripe payment_intent.succeeded) as the source of truth for async confirmation, with your API idempotency handling the happy-path synchronous flow.
Related Case Studies
Go Deeper
Case studies teach the "what". Our courses teach the "how" — the patterns behind these decisions, built up from first principles.
Explore Courses