Skip to content

Recipes

Common use cases and production patterns.

1. Payment Processing

typescript
import { Injectable, Controller, Post, Body, Res } from '@nestjs/common';
import { Idempotent } from '@nestjs-redisx/idempotency';
import { CreatePaymentDto, PaymentGateway, PaymentService, EmailService } from '../types';

@Injectable()
@Controller()
export class PaymentController {
  constructor(
    private readonly paymentGateway: PaymentGateway,
    private readonly paymentService: PaymentService,
    private readonly emailService: EmailService,
  ) {}

  @Post('payments')
  @Idempotent({
    ttl: 86400,  // 24 hours
    cacheHeaders: ['X-Payment-Id', 'X-Transaction-Reference'],
  })
  async createPayment(
    @Body() dto: CreatePaymentDto,
    @Res() res: any,
  ) {
    // Charge payment gateway
    const payment = await this.paymentGateway.charge({
      amount: dto.amount,
      currency: dto.currency,
      source: dto.source,
    });

    // Record in database
    await this.paymentService.record(payment);

    // Send receipt
    await this.emailService.sendReceipt(payment);

    // Set headers for client
    res.setHeader('X-Payment-Id', payment.id);
    res.setHeader('X-Transaction-Reference', payment.transactionRef);

    return res.status(201).json({
      id: payment.id,
      status: payment.status,
      amount: payment.amount,
      currency: payment.currency,
    });
  }
}

2. Order Creation with Side Effects

typescript
import { Injectable, Controller, Post, Body, Res } from '@nestjs/common';
import { Idempotent } from '@nestjs-redisx/idempotency';
import { CreateOrderDto, OrderService, InventoryService, EmailService, WarehouseService, AnalyticsService, User } from '../types';

@Injectable()
@Controller()
export class OrderController {
  constructor(
    private readonly orderService: OrderService,
    private readonly inventoryService: InventoryService,
    private readonly emailService: EmailService,
    private readonly warehouseService: WarehouseService,
    private readonly analyticsService: AnalyticsService,
  ) {}

  @Post('orders')
  @Idempotent({
    ttl: 3600,  // 1 hour
    cacheHeaders: ['Location', 'X-Order-Number'],
  })
  async createOrder(
    @Body() dto: CreateOrderDto,
    @Res() res: any,
  ) {
    // All of these execute ONCE per idempotency key
    const order = await this.orderService.create({
      items: dto.items,
    });

    // Reserve inventory
    await this.inventoryService.reserve(order.items);

    // Send confirmation email
    await this.emailService.sendOrderConfirmation(order);

    // Notify warehouse
    await this.warehouseService.notifyNewOrder(order);

    // Track analytics
    await this.analyticsService.trackOrderCreated(order);

    res.setHeader('Location', `/orders/${order.id}`);
    res.setHeader('X-Order-Number', order.orderNumber);

    return res.status(201).json(order);
  }
}

3. Webhook Handler

typescript
import { Injectable, Controller, Post, Body, Headers } from '@nestjs/common';
import { Idempotent } from '@nestjs-redisx/idempotency';
import { StripeWebhookEvent, StripeService } from '../types';

@Injectable()
@Controller()
export class WebhookController {
  constructor(
    private readonly stripeService: StripeService,
  ) {}

  @Post('webhooks/stripe')
  @Idempotent({
    ttl: 86400,
    keyExtractor: (ctx) => {
      const req = ctx.switchToHttp().getRequest();
      // Use Stripe's webhook ID
      return req.headers['stripe-webhook-id'];
    },
    // Body may vary slightly, don't validate fingerprint
    fingerprintFields: ['method', 'path'],
  })
  async handleStripeWebhook(
    @Body() event: StripeWebhookEvent,
    @Headers('stripe-signature') signature: string,
  ) {
    // Verify signature
    this.stripeService.verifySignature(event, signature);

    // Process event (only once even if Stripe retries)
    switch (event.type) {
      case 'payment_intent.succeeded':
        await this.handlePaymentSuccess(event.data);
        break;
      case 'payment_intent.failed':
        await this.handlePaymentFailure(event.data);
        break;
    }

    return { received: true };
  }

  private async handlePaymentSuccess(data: unknown): Promise<void> {}
  private async handlePaymentFailure(data: unknown): Promise<void> {}
}

4. Subscription Management

typescript
import { Injectable, Controller, Post, Delete, Body, Param, Res } from '@nestjs/common';
import { Idempotent } from '@nestjs-redisx/idempotency';
import { CreateSubscriptionDto, SubscriptionService, BillingService, EmailService, ScheduleService, User } from '../types';

@Injectable()
@Controller()
export class SubscriptionController {
  constructor(
    private readonly subscriptionService: SubscriptionService,
    private readonly billingService: BillingService,
    private readonly emailService: EmailService,
    private readonly scheduleService: ScheduleService,
  ) {}

  @Post('subscriptions')
  @Idempotent({
    ttl: 3600,
    cacheHeaders: ['X-Subscription-Id'],
  })
  async createSubscription(
    @Body() dto: CreateSubscriptionDto,
    @Res() res: any,
  ) {
    // Create subscription
    const subscription = await this.subscriptionService.create({
      planId: dto.planId,
      paymentMethodId: dto.paymentMethodId,
    });

    // Charge first payment
    await this.billingService.chargeInitial(subscription);

    // Send welcome email
    await this.emailService.sendWelcome({}, subscription);

    // Set up recurring billing
    await this.scheduleService.scheduleRecurring(subscription);

    res.setHeader('X-Subscription-Id', subscription.id);
    return res.status(201).json(subscription);
  }

  @Delete('subscriptions/:id')
  @Idempotent({
    ttl: 3600,
    keyExtractor: (ctx) => {
      const req = ctx.switchToHttp().getRequest();
      return `cancel-subscription-${req.params.id}`;
    },
  })
  async cancelSubscription(@Param('id') id: string) {
    const subscription = await this.subscriptionService.findOne(id);

    // Cancel at billing provider
    await this.billingService.cancel(subscription);

    // Update database
    await this.subscriptionService.markCancelled(id);

    // Send confirmation
    await this.emailService.sendCancellationConfirmation(subscription);

    return { status: 'cancelled' };
  }
}

5. Account Registration

typescript
import { Injectable, Controller, Post, Body, ConflictException } from '@nestjs/common';
import { createHash } from 'crypto';
import { Idempotent } from '@nestjs-redisx/idempotency';
import { RegisterDto, UserService, TokenService, EmailService, SettingsService, AnalyticsService } from '../types';

@Injectable()
@Controller()
export class AuthController {
  constructor(
    private readonly userService: UserService,
    private readonly tokenService: TokenService,
    private readonly emailService: EmailService,
    private readonly settingsService: SettingsService,
    private readonly analyticsService: AnalyticsService,
  ) {}

  @Post('register')
  @Idempotent({
    ttl: 3600,
    keyExtractor: (ctx) => {
      const req = ctx.switchToHttp().getRequest();
      // Use email as key (natural idempotency)
      return `register-${req.body.email}`;
    },
  })
  async register(@Body() dto: RegisterDto) {
    // Check if email already exists
    const existing = await this.userService.findByEmail(dto.email);
    if (existing) {
      throw new ConflictException('Email already registered');
    }

    // Create user
    const user = await this.userService.create({
      email: dto.email,
      password: await this.hashPassword(dto.password),
      name: dto.name,
    });

    // Send verification email
    const token = await this.tokenService.generateVerification(user);
    await this.emailService.sendVerification(user.email, token);

    // Create default settings
    await this.settingsService.createDefaults(user.id);

    // Track signup
    await this.analyticsService.trackSignup(user);

    return {
      id: user.id,
      email: user.email,
      message: 'Verification email sent',
    };
  }

  private async hashPassword(password: string): Promise<string> {
    return createHash('sha256').update(password).digest('hex');
  }
}

6. Money Transfer

typescript
import { Injectable, Controller, Post, Body, Res, ForbiddenException, BadRequestException } from '@nestjs/common';
import { Idempotent } from '@nestjs-redisx/idempotency';
import { CreateTransferDto, AccountService, TransferService, NotificationService, DatabaseTx, User } from '../types';

@Injectable()
@Controller()
export class TransferController {
  constructor(
    private readonly accountService: AccountService,
    private readonly transferService: TransferService,
    private readonly notificationService: NotificationService,
    private readonly db: DatabaseTx,
  ) {}

  @Post('transfers')
  @Idempotent({
    ttl: 86400,
    cacheHeaders: ['X-Transfer-Id', 'X-Transaction-Hash'],
  })
  async createTransfer(
    @Body() dto: CreateTransferDto,
    @Res() res: any,
  ) {
    // Validate accounts
    const fromAccount = await this.accountService.findOne(dto.fromAccountId);
    const toAccount = await this.accountService.findOne(dto.toAccountId);

    // Check balance
    if (fromAccount.balance < dto.amount) {
      throw new BadRequestException('Insufficient funds');
    }

    // Execute transfer (atomic)
    const transfer = await this.db.transaction(async (tx: any) => {
      // Debit from account
      await this.accountService.debit(
        dto.fromAccountId,
        dto.amount,
        { transaction: tx },
      );

      // Credit to account
      await this.accountService.credit(
        dto.toAccountId,
        dto.amount,
        { transaction: tx },
      );

      // Record transfer
      return await this.transferService.create(
        {
          fromAccountId: dto.fromAccountId,
          toAccountId: dto.toAccountId,
          amount: dto.amount,
          currency: dto.currency,
        },
        { transaction: tx },
      );
    });

    // Send notifications
    await Promise.all([
      this.notificationService.notifyTransferSent(fromAccount, transfer),
      this.notificationService.notifyTransferReceived(toAccount, transfer),
    ]);

    res.setHeader('X-Transfer-Id', transfer.id);
    res.setHeader('X-Transaction-Hash', transfer.hash);

    return res.status(201).json(transfer);
  }
}

7. Batch Processing

typescript
import { Injectable, Controller, Post, Body } from '@nestjs/common';
import { Idempotent } from '@nestjs-redisx/idempotency';
import { ProcessBatchDto, BatchService, EmailService } from '../types';

@Injectable()
@Controller()
export class BatchController {
  constructor(
    private readonly batchService: BatchService,
    private readonly emailService: EmailService,
  ) {}

  @Post('batch/process')
  @Idempotent({
    ttl: 3600,
    keyExtractor: (ctx) => {
      const req = ctx.switchToHttp().getRequest();
      return `batch-${req.body.batchId}`;
    },
  })
  async processBatch(@Body() dto: ProcessBatchDto) {
    const items = await this.batchService.getItems(dto.batchId);

    const results = await Promise.allSettled(
      items.map((item: any) => this.processItem(item)),
    );

    const summary = {
      total: results.length,
      succeeded: results.filter(r => r.status === 'fulfilled').length,
      failed: results.filter(r => r.status === 'rejected').length,
    };

    // Mark batch as processed
    await this.batchService.markCompleted(dto.batchId, summary);

    // Send notification
    await this.emailService.sendBatchReport(dto.batchId, summary);

    return summary;
  }

  private async processItem(item: any): Promise<any> {
    return item;
  }
}

8. Email Campaign Send

typescript
import { Injectable, Controller, Post, Param, ConflictException } from '@nestjs/common';
import { Idempotent } from '@nestjs-redisx/idempotency';
import { CampaignService, EmailQueue } from '../types';

@Injectable()
@Controller()
export class CampaignController {
  constructor(
    private readonly campaignService: CampaignService,
    private readonly emailQueue: EmailQueue,
  ) {}

  @Post('campaigns/:id/send')
  @Idempotent({
    ttl: 86400,
    keyExtractor: (ctx) => {
      const req = ctx.switchToHttp().getRequest();
      return `campaign-send-${req.params.id}`;
    },
  })
  async sendCampaign(@Param('id') campaignId: string) {
    const campaign = await this.campaignService.findOne(campaignId);

    if (campaign.status === 'sent') {
      throw new ConflictException('Campaign already sent');
    }

    // Get recipients
    const recipients = await this.campaignService.getRecipients(campaignId);

    // Queue emails
    await Promise.all(
      recipients.map((recipient: any) =>
        this.emailQueue.add('send-campaign-email', {
          campaignId,
          recipientId: recipient.id,
        }),
      ),
    );

    // Mark as sent
    await this.campaignService.markSent(campaignId);

    return {
      campaignId,
      status: 'sent',
      recipientCount: recipients.length,
    };
  }
}

Next Steps

Released under the MIT License.