Skip to content

Installation

1. Install

bash
npm install @nestarc/idempotency

For Redis storage (recommended for production):

bash
npm install ioredis

2. Register the Module

typescript
// app.module.ts
import { Module } from '@nestjs/common';
import { IdempotencyModule, MemoryStorage } from '@nestarc/idempotency';

@Module({
  imports: [
    IdempotencyModule.forRoot({
      storage: new MemoryStorage(),
      ttl: 86400, // 24 hours
    }),
  ],
})
export class AppModule {}
typescript
import { ConfigModule, ConfigService } from '@nestjs/config';
import { IdempotencyModule, RedisStorage } from '@nestarc/idempotency';
import { Redis } from 'ioredis';

@Module({
  imports: [
    IdempotencyModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        storage: new RedisStorage({
          client: new Redis({
            host: config.get('REDIS_HOST'),
            port: config.get('REDIS_PORT'),
          }),
        }),
        ttl: config.get('IDEMPOTENCY_TTL', 86400),
      }),
    }),
  ],
})
export class AppModule {}

3. Wire the Interceptor

The module does not auto-register the interceptor — you opt in with one of three patterns:

typescript
// 1. App-global — applies to every controller
import { APP_INTERCEPTOR } from '@nestjs/core';
import { IdempotencyInterceptor } from '@nestarc/idempotency';

@Module({
  providers: [{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor }],
})
export class AppModule {}
typescript
// 2. Controller-scoped
@Controller('payments')
@UseInterceptors(IdempotencyInterceptor)
export class PaymentsController { ... }
typescript
// 3. Method-scoped
@Post()
@UseInterceptors(IdempotencyInterceptor)
@Idempotent()
createPayment() { ... }

In all three cases, only handlers decorated with @Idempotent() are processed. Routes without the decorator pass through untouched.

4. Decorate Your Handlers

typescript
import { Body, Controller, Post, UseInterceptors } from '@nestjs/common';
import { Idempotent, IdempotencyInterceptor } from '@nestarc/idempotency';

@Controller('payments')
@UseInterceptors(IdempotencyInterceptor)
export class PaymentsController {
  @Post()
  @Idempotent()
  createPayment(@Body() dto: CreatePaymentDto) {
    // Runs at most once per Idempotency-Key.
    return this.paymentService.process(dto);
  }

  @Post('refund')
  @Idempotent({ ttl: 300, required: false })
  refund(@Body() dto: RefundDto) {
    // Custom TTL, optional header.
    return this.paymentService.refund(dto);
  }
}

Module Options

OptionTypeDefaultDescription
storageIdempotencyStoragerequiredStorage adapter instance (MemoryStorage or RedisStorage)
ttlnumber86400Default TTL in seconds. Per-handler can override
headerNamestring'Idempotency-Key'HTTP header carrying the idempotency key
fingerprintbooleantrueCompute SHA-256 fingerprint of request body
scopeIdempotencyScope'endpoint'Key namespace strategy. See below
isGlobalbooleantrueRegister as a NestJS global module

Decorator Options (@Idempotent())

OptionTypeDefaultDescription
requiredbooleantrueIf true, missing header returns 400
ttlnumberinheritOverride module-level TTL for this handler
fingerprintbooleaninheritOverride module-level fingerprint setting

Scope

The scope option controls how the storage key is derived from the raw header value.

ValueBehavior
'endpoint'Default. Prefixes HTTP_METHOD /route:: using NestJS PATH_METADATA. Isolates v1/v2 APIs with same class names
'global'Raw header value as-is. Safe only if clients guarantee globally-unique keys
function(ctx: ExecutionContext) => string. Custom scoping — e.g. include tenant ID
typescript
// Multi-tenant example
IdempotencyModule.forRoot({
  storage: new MemoryStorage(),
  scope: (ctx) => {
    const req = ctx.switchToHttp().getRequest();
    return req.user.tenantId;
  },
});

Released under the MIT License.