Skip to content

Recipes ​

Daily Usage Quota ​

Count per user per day; the key cleans itself up:

typescript
import { Injectable, Inject } from '@nestjs/common';
import { COUNTER_SERVICE, ICounterService } from '@nestjs-redisx/counters';

@Injectable()
export class UsageService {
  constructor(
    @Inject(COUNTER_SERVICE)
    private readonly counters: ICounterService,
  ) {}

  // Window counter: the TTL applies only when the increment CREATES the
  // key - later increments never extend the window, so daily keys expire
  // on their own without a cleanup job.
  async onApiCall(userId: string): Promise<number> {
    const today = new Date().toISOString().slice(0, 10);
    return this.counters.increment(`api:${userId}:${today}`, 1, { ttl: 86_400_000 });
  }

  // Flush-style consumption: read the accumulated total and zero it in ONE
  // atomic step - periodic aggregation without double counting.
  async flushEmailBatch(): Promise<number> {
    return this.counters.getAndReset('emails:batch');
  }
}

Quota check at the call site:

typescript
const used = await this.counters.increment(`api:${userId}:${today}`, 1, { ttl: 86_400_000 });
if (used > plan.dailyLimit) {
  throw new PaymentRequiredException(); // the increment still counted the attempt
}

For token-bucket / sliding-window enforcement with guards and headers, use the rate limiter - quotas here are domain accounting.

Batch Flush Aggregation ​

Accumulate cheaply on the hot path, ship totals periodically, never double-count:

typescript
@Cron(CronExpression.EVERY_MINUTE)
@LeaderOnly() // one instance ships the batch
async shipEmailStats() {
  const sent = await this.counters.getAndReset('emails:batch');
  if (sent > 0) await this.analytics.track('emails_sent', sent);
}

Live Statistics ​

typescript
import { Injectable, Inject } from '@nestjs/common';
import { COUNTER_SERVICE, ICounterService } from '@nestjs-redisx/counters';

@Injectable()
export class StatsService {
  constructor(
    @Inject(COUNTER_SERVICE)
    private readonly counters: ICounterService,
  ) {}

  // Atomic fleet-wide increment - concurrent updates never get lost.
  async onArticleView(articleId: string): Promise<number> {
    return this.counters.increment(`views:${articleId}`);
  }

  async getViews(articleId: string): Promise<number> {
    return this.counters.get(`views:${articleId}`); // 0 when absent
  }

  // Counters go negative freely - clamp in domain code if needed.
  async releaseSeat(eventId: string): Promise<number> {
    return this.counters.decrement(`seats:${eventId}`);
  }

  async resetViews(articleId: string): Promise<boolean> {
    return this.counters.reset(`views:${articleId}`);
  }
}

Seat / Stock Inventory ​

typescript
const left = await this.counters.decrement(`seats:${eventId}`);
if (left < 0) {
  await this.counters.increment(`seats:${eventId}`); // compensate
  throw new ConflictException('sold out');
}

For bounding concurrent work rather than stock, use the semaphore.

Fleet-Wide ID Generation ​

typescript
import { Injectable, Inject } from '@nestjs/common';
import { COUNTER_SERVICE, ICounterService } from '@nestjs-redisx/counters';

@Injectable()
export class InvoiceNumberService {
  constructor(
    @Inject(COUNTER_SERVICE)
    private readonly counters: ICounterService,
  ) {}

  // Monotonic fleet-wide sequence: 1, 2, 3, ... with no collisions across
  // instances. Sequences have no TTL by definition.
  async nextInvoiceNumber(): Promise<string> {
    const n = await this.counters.next('seq:invoices');
    return `INV-${String(n).padStart(6, '0')}`;
  }

  async lastIssued(): Promise<number> {
    return this.counters.current('seq:invoices'); // 0 before the first next()
  }
}

Released under the MIT License.