Idempotency for Subscribers

Overview

Idempotency ensures that event subscribers process each message exactly once, even when the message bus redelivers the same message multiple times. This is critical for maintaining data integrity in distributed systems.

Why Do Messages Get Redelivered?

Message buses like NServiceBus, RabbitMQ, and Azure Service Bus may redeliver messages due to:

Scenario
Description

Transient Failures

Network timeouts, database connection issues

Process Crashes

Application crashes before acknowledging the message

Retry Policies

Automatic retries on failed message processing

Infrastructure Issues

Message broker restarts, failovers

At-Least-Once Delivery

Most message buses guarantee delivery but may duplicate

What Problems Can Duplicate Processing Cause?

Without idempotency, duplicate message processing can lead to:

  • 📧 Multiple emails sent to the same user

  • 💳 Duplicate financial transactions

  • 📦 Duplicate orders or shipments

  • 🔢 Incorrect counters or statistics

  • 💾 Duplicate database records

  • 🔗 Inconsistent data across systems


Quick Start

FlexBase provides automatic idempotency for subscribers using the Decorator Pattern. No changes are required to your existing subscriber code.

Step 1: Add Configuration to appsettings.json

Step 2: Register Idempotency Services

In your CommonHostConfig.cs or startup configuration:

That's it! All your subscribers now have automatic idempotency protection.


Configuration Options

FlexIdempotency Section

Setting
Default
Description

Enabled

true

Enable/disable idempotency checking globally

StoreType

"Cache"

Storage backend: "Cache" or "Database"

DefaultTimeToLiveDays

7

How long to remember processed events

KeyPrefix

"flex:idempotency"

Prefix for cache keys (Cache store only)

EnableAutoCleanup

false

Auto-cleanup expired records (Database store only)

CleanupBatchSize

1000

Records per cleanup batch (Database store only)

FlexCache Section (for Cache Store)

Setting
Default
Description

MaxPayloadBytes

1048576 (1MB)

Maximum cache entry size

DefaultExpirationMinutes

60

Default cache entry expiration


Store Types

FlexBase offers two storage backends for tracking processed events:

Option 1: Cache Store (Default)

Uses FlexHybridCache with L1 (in-memory) + L2 (distributed) architecture.

Advantages:

  • ✅ High performance (in-memory L1 cache)

  • ✅ Scales across multiple instances (with distributed L2)

  • ✅ Simple setup for development

Cache Backend Options:

Backend
Use Case
Setup Complexity

In-Memory Only

Development, single instance

None

Redis

Production, high performance

Requires Redis server

SQL Server

Production, no Redis available

Requires cache table

Option 2: Database Store (EF Core)

Uses your application's Entity Framework Core DbContext to store processed events.

Advantages:

  • ✅ Data persists across application restarts

  • ✅ Queryable (you can analyze processed events)

  • ✅ Uses existing database infrastructure

  • ✅ No additional cache infrastructure needed

Disadvantages:

  • ⚠️ Slower than cache-based storage

  • ⚠️ Increases database load

  • ⚠️ Requires EF Core migration


Cache Store Configuration

Development: In-Memory Only

For local development, no additional configuration is needed. FlexHybridCache uses in-memory storage by default.

⚠️ Warning: In-memory cache is lost on application restart and not shared across instances. Use distributed cache for production.


Production: Redis Backend

Redis provides the best performance for distributed caching.

Step 1: Install NuGet Package

Step 2: Configure Redis

Step 3: Add Connection String


Production: SQL Server Distributed Cache

If you don't have Redis, you can use SQL Server as a distributed cache backend.

Step 1: Install NuGet Package

Step 2: Create the Cache Table

Option A: Using dotnet tool

Option B: Using SQL Script

Step 3: Configure SQL Server Distributed Cache

Step 4: Add Connection String


Database Store Configuration (EF Core)

For full database persistence with a typed entity, use the EF Core store.

Step 1: Configure Store Type

Step 2: Choose Your DbContext Approach

FlexBase provides two options for the Database store:

Approach
Description
Best For

Dedicated Context

Uses FlexProcessedEventDbContext (provided by FlexBase)

Isolation, separate migrations

Shared Context

Add entity to your ApplicationEFDbContext

Simplicity, single migration stream


Use the built-in FlexProcessedEventDbContext. No changes to your application DbContext required.

Register in IdempotencyConfig.cs:

Create migrations:

💡 Tip: Using --output-dir Idempotency keeps idempotency migrations separate from your main application migrations.


Option B: Shared Context (Simpler Setup)

Add the FlexProcessedEventEntity to your existing ApplicationEFDbContext. This keeps all entities and migrations together.

Step B1: Modify your ApplicationEFDbContext:

Step B2: Register in IdempotencyConfig.cs:

Step B3: Create migrations:


Which Approach Should I Choose?

Consideration
Dedicated Context
Shared Context

Setup complexity

More configuration

Simpler

Migration management

Separate migrations

Single migration stream

Transaction scope

Separate transactions

Same transaction as business entities

Connection pooling

Uses separate pool

Shares connection pool

Schema isolation

Fully isolated

Mixed with app entities

Recommendation:

  • Use Shared Context for most applications (simpler, fewer moving parts)

  • Use Dedicated Context if you need schema isolation or want separate database

Database Table Structure

The migration creates a FlexProcessedEvents table:

Column
Type
Description

Id

bigint

Auto-increment primary key

EventId

nvarchar(450)

Unique event identifier

SubscriberType

nvarchar(450)

Fully qualified subscriber type name

CorrelationId

nvarchar(450)

Optional correlation ID for tracing

ProcessedAtUtc

datetime2

When the event was processed

ExpiresAtUtc

datetime2

When the record can be cleaned up

Indexes:

  • Composite unique index on (EventId, SubscriberType)

  • Index on ExpiresAtUtc for cleanup queries

Cleanup of Expired Records

For Cache Store: Expired records are automatically cleaned up by the cache infrastructure (Redis, HybridCache). No action needed.

For Database Store: Records have an ExpiresAtUtc column and expired records are automatically ignored during idempotency checks. However, you need to periodically clean up expired records to prevent table growth.

FlexBase provides a CleanupExpiredAsync() method you can call from your own scheduled job:

Alternative: Direct SQL cleanup

If you prefer, you can also clean up using a SQL Agent job or scheduled query:

Note: Even without cleanup, expired records don't affect correctness - they are ignored during idempotency checks. Cleanup is only needed to manage table size.


Opting Out of Idempotency

Some subscribers should always run, even on retries (e.g., logging, metrics, notifications). Use the [SkipIdempotency] attribute:


How It Works

FlexBase automatically adds a "gatekeeper" in front of your subscribers. This gatekeeper checks if an event has already been processed before allowing it through to your code.

The key point: Your existing subscriber code doesn't change at all. FlexBase handles everything behind the scenes when you register your services.

What Happens When a Message Arrives

Why Your Code Doesn't Need to Change

When you call AddFlexIdempotency(), FlexBase automatically:

  1. Intercepts all calls to your subscribers

  2. Checks if the EventId has been seen before

  3. Blocks duplicates from reaching your code

  4. Passes through new messages to your subscriber

Your subscriber just receives messages and processes them - it doesn't know (or care) that there's a gatekeeper in front of it.

Key Points

  1. No code changes required - Your subscribers work exactly as before

  2. EventId-based deduplication - Each event has a unique EventId (auto-generated)

  3. Per-subscriber tracking - The same event can be processed by different subscribers

  4. TTL-based cleanup - Old records are automatically expired after the configured time


Troubleshooting

Events Being Skipped Unexpectedly

  1. Check EventId generation - Events should have unique EventId values

  2. Verify TTL settings - If TTL is too long, old events may still be "remembered"

  3. Check cache connectivity - Ensure Redis/SQL Server is reachable

Duplicate Processing Still Occurring

  1. Verify registration - Ensure AddFlexIdempotency() is called

  2. Check store type - Verify the configured store is working

  3. Check for [SkipIdempotency] - Subscriber may be opted out

Debug Logging

Enable debug logging to see idempotency decisions:

Log messages:

  • "Event {EventId} already processed by {Subscriber}; skipping" - Duplicate blocked

  • "Successfully processed event {EventId} by {Subscriber}" - First-time processing


Best Practices

1. Choose the Right Store Type

Scenario
Recommended Store

Development

Cache (in-memory)

Single instance, simple app

Cache (in-memory)

Multi-instance, high throughput

Cache + Redis

Multi-instance, no Redis

Cache + SQL Server

Need queryable history

Database (EF Core)

Regulatory/audit requirements

Database (EF Core)

2. Set Appropriate TTL

  • Too short: Events may be reprocessed if redelivered after TTL expires

  • Too long: Storage grows unnecessarily, cleanup slower

Recommended: 7 days (default) for most scenarios

3. Monitor Storage Growth

For Database store, periodically review the FlexProcessedEvents table size and enable EnableAutoCleanup if needed.

4. Use SkipIdempotency Sparingly

Only use [SkipIdempotency] for:

  • Logging/auditing subscribers

  • Metrics/telemetry collectors

  • Notification dispatchers (where duplicates are acceptable)


Complete Example

appsettings.json

IdempotencyConfig.cs

CommonHostConfig.cs


Summary

Feature
Description

Purpose

Prevent duplicate event processing

Default Store

Cache (FlexHybridCache)

Alternative Store

Database (EF Core)

Cache Backends

In-memory, Redis, SQL Server

Opt-out

[SkipIdempotency] attribute

Configuration

FlexIdempotency section in appsettings.json

Code Changes Required

None (Decorator Pattern)

Last updated