← Back to list

Idempotency in .NET Backend Systems: How to Prevent Duplicate Processing

In Part 1, I wrote about boundaries. In Part 2, I wrote about aggregates and invariants. In Part 3, I wrote about domain events. In Part 4…

Oshadha Thrimavithana · 2026-05-27 03:01 · 18 claps · 10.6 min read
#software-architecture #backend-development #distributed-systems #idempotency #dotnet
Open on Medium ↗
Wiki topics: 🌐 · Web Development 🧠 · Mental Wellness 🏛️ · Architecture

Idempotency in .NET Backend Systems: How to Prevent Duplicate Processing

In Part 1, I wrote about boundaries. In Part 2, I wrote about aggregates and invariants. In Part 3, I wrote about domain events. In Part 4, I wrote about commands and queries. In Part 5, I wrote about modular monoliths and microservices. In Part 6, I wrote about synchronous vs asynchronous communication. In Part 7, I wrote about HTTP, messaging, and gRPC. In Part 8, I wrote about resiliency in backend systems. In Part 9, I wrote about observability. In Part 10, I wrote about transactions and eventual consistency.

Now we come to one of the most important production concerns in backend systems:

idempotency.

It is not always the first thing developers think about when designing APIs, message handlers, or workflows.

But in production systems, it becomes critical very quickly.

Because real systems do not always process things exactly once.

Requests can be retried. Messages can be delivered more than once. Payment callbacks can arrive multiple times. Users can click a button twice. Network responses can be lost. Background jobs can restart halfway through. External systems can repeat the same notification.

If the backend is not designed for this, duplicate processing can create serious business problems.

A patient may be charged twice. A doctor may be double-booked. A prescription may be generated twice. A notification may be sent repeatedly. An order may be created more than once.

This is where idempotency matters.

What is idempotency?

Idempotency means an operation can be performed multiple times, but the final result remains the same as if it was performed once.

In simple terms:

If the same request is processed again, the system should not create a duplicate business effect.

For example, if a payment confirmation is received twice, the system should not confirm the payment twice.

If an appointment booking request is retried, the system should not create two appointments.

If a message is delivered again, the handler should not repeat a business action that has already happened.

Idempotency is about making repetition safe.

The practical scenario

Let’s use a telemedicine backend as the example.

Imagine this flow:

  1. A patient books a consultation
  2. The patient completes payment
  3. The payment provider sends a callback
  4. The backend confirms payment
  5. The consultation is ready
  6. Notifications are sent to the patient and the doctor

Now imagine the payment callback is delivered twice.

This happens in real systems.

Without idempotency, the backend may:

  • Create duplicate payment records
  • Update the consultation multiple times
  • Publish duplicate events
  • Send duplicate notifications
  • Confuse reporting and audit trails

With idempotency, the backend can safely say:

This operation was already processed. I will not repeat the business effect.

That is the behavior we want.

Where idempotency is needed

Idempotency is most important for operations that change state or trigger side effects.

Examples:

  • booking a consultation
  • confirming a payment
  • issuing a refund
  • completing a consultation
  • generating a prescription
  • sending a notification
  • processing an integration event
  • consuming a message from a queue

Read operations usually do not need special idempotency handling because they do not change state.

For example:

  • get doctor profile
  • list consultations
  • search available slots
  • view payment status

Running these multiple times usually does not damage the system.

The risk is in write operations.

Idempotency in the API layer

One common way to handle idempotency in APIs is to use an idempotency key.

The client sends a unique key with the request.

Idempotency-Key: 7f7db7e3-6e9d-4d32-b51e-20b746b69c45

This key represents one unique client intention.

For example:

“Book this consultation request once.”

If the client retries the same request with the same key, the backend should not create another consultation.

Booking command with an idempotency key

In a CQRS-style application flow, the command can carry the idempotency key.

public sealed record BookConsultationCommand(
    Guid PatientId,
    Guid DoctorId,
    DateTime ScheduledAtUtc,
    string IdempotencyKey
) : ICommand<Guid>;

The command clearly represents a write operation.

The IdempotencyKey helps the backend detect whether this operation has already been processed.

This is useful when the mobile app retries after a timeout or network failure.

Thin API endpoint

The API layer should stay thin.

It should receive the request, extract the idempotency key, create a command, and send it to the application layer.

app.MapPost("/api/v1/consultations/book",
    async (
        BookConsultationRequest request,
        HttpContext httpContext,
        IRequestDispatcher dispatcher,
        CancellationToken cancellationToken) =>
    {
        var idempotencyKey = httpContext.Request.Headers["Idempotency-Key"]
            .FirstOrDefault();

        if (string.IsNullOrWhiteSpace(idempotencyKey))
            return Results.BadRequest("Idempotency-Key header is required.");

        var command = new BookConsultationCommand(
            request.PatientId,
            request.DoctorId,
            request.ScheduledAtUtc,
            idempotencyKey);

        var consultationId = await dispatcher.Send(command, cancellationToken);

        return Results.Ok(new { ConsultationId = consultationId });
    });

The endpoint does not know how idempotency is implemented.

That responsibility belongs in the application layer.

This keeps the API simple and the business workflow testable.

public sealed class IdempotencyRecord
{
    public Guid Id { get; private set; }
    public string Key { get; private set; } = default!;
    public string Operation { get; private set; } = default!;
    public string RequestHash { get; private set; } = default!;
    public Guid? ResourceId { get; private set; }
    public DateTime CreatedAtUtc { get; private set; }

    private IdempotencyRecord()
    {
    }

    public IdempotencyRecord(
        string key,
        string operation,
        string requestHash,
        Guid? resourceId)
    {
        Id = Guid.NewGuid();
        Key = key;
        Operation = operation;
        RequestHash = requestHash;
        ResourceId = resourceId;
        CreatedAtUtc = DateTime.UtcNow;
    }
}

The important fields are:

  • Key — the idempotency key from the caller
  • Operation — the operation name, such as BookConsultation
  • RequestHash — a hash of the request payload
  • ResourceId — the created resource, such as ConsultationId

The request hash is useful because the same key should not be reused for a different request body.

If the same key is used with different data, the backend should reject it.

Idempotency store abstraction

The application layer should not depend directly on database details.

Use an abstraction.

public interface IIdempotencyStore
{
    Task<IdempotencyRecord?> GetAsync(
        string key,
        string operation,
        CancellationToken cancellationToken);

    Task SaveAsync(
        IdempotencyRecord record,
        CancellationToken cancellationToken);
}

This keeps the command handler focused on the use case.

The actual persistence can be implemented in the infrastructure layer using EF Core or another storage mechanism.

Booking handler with idempotency

Now we can apply idempotency in the booking flow.

public sealed class BookConsultationHandler
    : ICommandHandler<BookConsultationCommand, Guid>
{
    private const string OperationName = "BookConsultation";

    private readonly IIdempotencyStore _idempotencyStore;
    private readonly IConsultationRepository _consultationRepository;
    private readonly IDoctorScheduleRepository _scheduleRepository;
    private readonly IUnitOfWork _unitOfWork;

    public BookConsultationHandler(
        IIdempotencyStore idempotencyStore,
        IConsultationRepository consultationRepository,
        IDoctorScheduleRepository scheduleRepository,
        IUnitOfWork unitOfWork)
    {
        _idempotencyStore = idempotencyStore;
        _consultationRepository = consultationRepository;
        _scheduleRepository = scheduleRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Guid> Handle(
        BookConsultationCommand command,
        CancellationToken cancellationToken)
    {
        var requestHash = IdempotencyHash.Create(command);

        var existingRecord = await _idempotencyStore.GetAsync(
            command.IdempotencyKey,
            OperationName,
            cancellationToken);

        if (existingRecord is not null)
        {
            if (existingRecord.RequestHash != requestHash)
                throw new InvalidOperationException(
                    "The same idempotency key was used with a different request.");

            return existingRecord.ResourceId!.Value;
        }

        var schedule = await _scheduleRepository.GetByDoctorIdAsync(
            command.DoctorId,
            cancellationToken);

        if (schedule is null)
            throw new DomainException("Doctor schedule was not found.");

        schedule.EnsureSlotIsAvailable(command.ScheduledAtUtc);

        var consultation = Consultation.Request(
            command.PatientId,
            command.DoctorId,
            command.ScheduledAtUtc);

        await _consultationRepository.AddAsync(consultation, cancellationToken);

        await _idempotencyStore.SaveAsync(
            new IdempotencyRecord(
                command.IdempotencyKey,
                OperationName,
                requestHash,
                consultation.Id),
            cancellationToken);

        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return consultation.Id;
    }
}

The important point is this:

The consultation and the idempotency record should be saved in the same transaction.

Otherwise, the system may create the consultation but fail to store the idempotency key. If the client retries, the backend may create another consultation.

Idempotency is only reliable when it is part of the same consistency boundary as the business change it protects.

Request hash helper

The request hash helps detect accidental misuse of an idempotency key.

A simplified helper could look like this:

public static class IdempotencyHash
{
    public static string Create<T>(T request)
    {
        var json = JsonSerializer.Serialize(request);

        using var sha256 = SHA256.Create();

        var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json));

        return Convert.ToHexString(bytes);
    }
}

This is not the only way to do it.

The main idea is to detect this dangerous situation:

  1. The client sends request A with the key abc
  2. Backend processes it
  3. Client sends a different request B with the same key abc
  4. Backend should not treat B as a safe retry

The same key should represent the same intention.

Database uniqueness matters

Application checks alone are not enough.

Two identical requests can arrive at the same time.

Both may check for the idempotency key before either has inserted it.

That is why the database should enforce uniqueness.

public sealed class IdempotencyRecordConfiguration
    : IEntityTypeConfiguration<IdempotencyRecord>
{
    public void Configure(EntityTypeBuilder<IdempotencyRecord> builder)
    {
        builder.ToTable("IdempotencyRecords");

        builder.HasKey(x => x.Id);

        builder.Property(x => x.Key)
            .HasMaxLength(200)
            .IsRequired();

        builder.Property(x => x.Operation)
            .HasMaxLength(100)
            .IsRequired();

        builder.Property(x => x.RequestHash)
            .HasMaxLength(128)
            .IsRequired();

        builder.HasIndex(x => new { x.Key, x.Operation })
            .IsUnique();
    }
}

The unique index protects the system from concurrency.

Architecture should not depend only on “we checked before insert.”

The database should protect important uniqueness rules, too.

Idempotency in payment confirmation

Payments are one of the most important areas for idempotency.

A payment provider may send the same callback multiple times.

The backend must not process the same payment reference twice.

public sealed record ConfirmPaymentCommand(
    Guid ConsultationId,
    string ProviderReference,
    decimal Amount
) : ICommand;

The provider reference is a natural idempotency key.

public sealed class ConfirmPaymentHandler
    : ICommandHandler<ConfirmPaymentCommand>
{
    private readonly IPaymentRepository _paymentRepository;
    private readonly IConsultationRepository _consultationRepository;
    private readonly IUnitOfWork _unitOfWork;

    public ConfirmPaymentHandler(
        IPaymentRepository paymentRepository,
        IConsultationRepository consultationRepository,
        IUnitOfWork unitOfWork)
    {
        _paymentRepository = paymentRepository;
        _consultationRepository = consultationRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task Handle(
        ConfirmPaymentCommand command,
        CancellationToken cancellationToken)
    {
        var alreadyConfirmed = await _paymentRepository
            .ExistsByProviderReferenceAsync(
                command.ProviderReference,
                cancellationToken);

        if (alreadyConfirmed)
            return;

        var consultation = await _consultationRepository.GetByIdAsync(
            command.ConsultationId,
            cancellationToken);

        if (consultation is null)
            throw new DomainException("Consultation was not found.");

        var payment = Payment.Confirm(
            command.ConsultationId,
            command.ProviderReference,
            command.Amount);

        consultation.ConfirmPayment();

        await _paymentRepository.AddAsync(payment, cancellationToken);

        await _unitOfWork.SaveChangesAsync(cancellationToken);
    }
}

This makes duplicate payment callbacks safe.

If the same provider reference arrives again, the handler does not repeat the business effect.

In production systems, they ProviderReference should also have a unique database constraint.

Idempotency through aggregate state transitions

Not every idempotency rule needs a separate idempotency table.

Sometimes the aggregate state itself can protect against duplicate processing.

For example, completing a consultation should be safe if the consultation has already been completed.

public void Complete(Guid doctorId)
{
    if (DoctorId != doctorId)
        throw new DomainException("Doctor is not assigned to this consultation.");

    if (Status == ConsultationStatus.Completed)
        return;

    if (Status != ConsultationStatus.Active)
        throw new DomainException("Only active consultations can be completed.");

    Status = ConsultationStatus.Completed;

    AddDomainEvent(new ConsultationCompletedDomainEvent(
        Id,
        PatientId,
        DoctorId,
        DateTime.UtcNow));
}

This makes the operation safe if the same completion request is repeated.

But be careful.

Returning early is correct only if repeating the operation should be treated as harmless.

For some operations, a repeated request should be rejected.

That depends on the business rule.

Idempotency in message processing

Idempotency is not only for HTTP APIs.

It is also critical in message processing.

Most messaging systems should be treated as at-least-once delivery.

That means the same message can be delivered more than once.

So message handlers should assume duplicates can happen.

A common solution is the Inbox pattern.

The consumer stores processed message IDs.

Before processing a message, it checks whether the message was already handled.

Inbox message table

public sealed class InboxMessage
{
    public Guid Id { get; private set; }
    public string MessageId { get; private set; } = default!;
    public string HandlerName { get; private set; } = default!;
    public DateTime ProcessedAtUtc { get; private set; }

    private InboxMessage()
    {
    }

    public InboxMessage(string messageId, string handlerName)
    {
        Id = Guid.NewGuid();
        MessageId = messageId;
        HandlerName = handlerName;
        ProcessedAtUtc = DateTime.UtcNow;
    }
}

The combination of MessageId and HandlerName should be unique.

That prevents the same handler from processing the same message twice.

Idempotent message handler

public sealed class PaymentConfirmedIntegrationEventHandler
{
    private readonly IInboxStore _inboxStore;
    private readonly INotificationService _notificationService;
    private readonly IUnitOfWork _unitOfWork;

    public PaymentConfirmedIntegrationEventHandler(
        IInboxStore inboxStore,
        INotificationService notificationService,
        IUnitOfWork unitOfWork)
    {
        _inboxStore = inboxStore;
        _notificationService = notificationService;
        _unitOfWork = unitOfWork;
    }

    public async Task Handle(
        PaymentConfirmedIntegrationEvent message,
        CancellationToken cancellationToken)
    {
        var handlerName = nameof(PaymentConfirmedIntegrationEventHandler);

        var alreadyProcessed = await _inboxStore.ExistsAsync(
            message.MessageId,
            handlerName,
            cancellationToken);

        if (alreadyProcessed)
            return;

        await _notificationService.SendAsync(
            message.PatientId,
            "Your payment is confirmed.",
            cancellationToken);

        await _inboxStore.SaveAsync(
            new InboxMessage(message.MessageId, handlerName),
            cancellationToken);

        await _unitOfWork.SaveChangesAsync(cancellationToken);
    }
}

This protects the notification workflow from duplicate message delivery.

But there is an important detail.

The side effects and the inbox record should be handled carefully.

If the notification is sent but the inbox record is not saved, the same message may be processed again later.

For external side effects, this is where provider-level idempotency or notification request IDs can help.

Outbox and inbox work well together

In a production modular monolith, the Outbox and Inbox patterns often work together.

Outbox

Used by the publisher.

It makes sure domain events are saved reliably with the business transaction.

Inbox

Used by the consumer.

It makes sure received messages are not processed more than once by the same handler.

Together, they help make eventual consistency safer.

Business transaction
      |
      v
Save aggregate + outbox message
      |
      v
Publish message
      |
      v
Consumer checks inbox
      |
      v
Process once

This does not magically create perfect, exactly-once processing.

But it gives the system practical protection against duplicate effects.

Observability for idempotency

Idempotency should not be invisible.

The system should log when duplicates are detected.

logger.LogInformation(
    "Duplicate request detected. Operation: {Operation}. IdempotencyKey: {IdempotencyKey}. ResourceId: {ResourceId}",
    OperationName,
    command.IdempotencyKey,
    existingRecord.ResourceId);

This helps production teams understand what is happening.

Duplicate detection may reveal:

  • unstable client networks
  • aggressive retry policies
  • payment provider repeated callbacks
  • message acknowledgement failures
  • background worker restarts
  • user double submissions

Idempotency protects the system.

Observability explains why duplicates are happening.

Metrics for idempotency

Useful metrics include:

  • duplicate API requests detected
  • duplicate payment callbacks ignored
  • duplicate messages skipped
  • idempotency key conflicts
  • inbox duplicate count
  • payment provider callback retry count

These metrics help the team see whether duplicate processing is normal or increasing unexpectedly.

For example, a sudden spike in duplicate payment callbacks may indicate a provider integration problem.

A spike in duplicate booking requests may indicate mobile app retry issues.

Common mistakes

Mistake 1: Retrying without idempotency

Retries are only safe when repeating the operation is safe.

Retrying a payment, booking, refund, or message handler without duplicate protection can create business damage.

Mistake 2: Only checking before insert

This is not enough under concurrency.

Use database uniqueness constraints for important idempotency rules.

Mistake 3: Reusing the same idempotency key for different requests

The same key should represent the same intention.

Store a request hash to detect misuse.

Mistake 4: Applying idempotency only to APIs

Duplicates also happen in:

  • message handlers
  • background jobs
  • provider callbacks
  • scheduled tasks
  • integration consumers

Idempotency must be designed across the workflow.

Mistake 5: Treating idempotency as “ignore duplicates.”

Sometimes you should return the previous result.

Sometimes you should skip the duplicate.

Sometimes you should reject the request.

The correct behavior depends on the business case.

Practical checklist

For every important write operation, ask:

  • Can this request be retried?
  • Can the user submit it twice?
  • Can an external provider send the same callback again?
  • Can a message be delivered more than once?
  • What uniquely identifies this business operation?
  • Should the system return the original result?
  • Should the duplicate be ignored?
  • Should the duplicate be rejected?
  • Is there a database constraint protecting uniqueness?
  • Are the business change and idempotency record saved together?
  • Is duplicate detection logged and measured?

These questions prevent many production problems.

Telemedicine workflow summary

| Workflow                  | Idempotency protection                        |
| ------------------------- | --------------------------------------------- |
| Book consultation         | Idempotency key + doctor/time-slot uniqueness |
| Confirm payment           | Payment provider reference uniqueness         |
| Send notification         | Notification request ID or message ID         |
| Complete consultation     | Safe aggregate state transition               |
| Generate prescription PDF | Prescription ID + generation record           |
| Process integration event | Inbox message table                           |

This shows that idempotency is not one technique.

It is a design habit across the whole system.

Final thought

Idempotency is one of the most important production-readiness patterns in backend architecture.

It protects the system from duplicate requests, repeated messages, network uncertainty, retries, payment callbacks, and background processing failures.

Without idempotency, resiliency patterns can accidentally create business damage.

With idempotency, the system can retry safely, process messages confidently, and recover from uncertainty without duplicating side effects.

That is why idempotency should not be treated as an afterthought.

It should be designed into every important write workflow.

Because in production, the question is not:

Will this operation ever be repeated?

The better question is:

When this operation is repeated, will the system stay correct?

That is the real value of idempotency.

Coming next

In Part 12, I will cover:

Caching in Backend Systems How to improve performance without creating stale data, hidden coupling, or consistency problems.


메타데이터
post_id
fd8a60b8ddd4
slug
idempotency-in-net-backend-systems-how-to-prevent-duplicate-processing-fd8a60b8ddd4
url
https://medium.com/@oshadhaj/idempotency-in-net-backend-systems-how-to-prevent-duplicate-processing-fd8a60b8ddd4
canonical_url
https://medium.com/@oshadhaj/idempotency-in-net-backend-systems-how-to-prevent-duplicate-processing-fd8a60b8ddd4
author_url
https://medium.com/@oshadhaj
status
ok
fetched_at
2026-06-24 16:30:55