目次を表示する

NestJS Deep Dive 2026 ─ 内部構造・再発明回避・パフォーマンスを 10 章で読み解く

再発明 TOP 10 ─ 自前実装をやめて準公式に寄せる

第6章: 再発明 TOP 10 ─ 自前実装をやめて準公式に寄せる

再発明 TOP 10 の Before/After

第5章で公式 18 モジュールを見た。本章はその外側 ─ 「公式に同等品はないが、業界に定着した準公式パッケージがある」領域を扱う。毎プロジェクト書き直している実装が、実は標準化されていることが多い。

「準公式」と呼ぶ基準:

  • TypeScript first
  • NestJS の DI / Decorator パターンに準拠
  • 数千スターの GitHub リポジトリ、または NestJS 公式ドキュメントで言及
  • 主要バージョン(v11)に追従

TOP 10 マップ

mindmap
  root((再発明 TOP 10))
    HTTP / API
      1 Pagination
      10 Idempotency
    Reliability
      2 Retry
      3 Circuit Breaker
      4 Distributed Lock
    Identity / Audit
      5 ID 生成
      6 Soft Delete
      7 Audit Log
    Context
      8 Tenant Context
      9 Request-scoped Logger

順に「自前実装パターン → 準公式の置き換え」の対比で見ていく。

1. Pagination → nestjs-paginate

自前実装あるある

// ❌ 毎プロジェクト書き直している
@Get()
async findAll(
  @Query('page') page = 1,
  @Query('limit') limit = 10,
  @Query('sortBy') sortBy = 'createdAt',
  @Query('order') order: 'asc' | 'desc' = 'desc',
) {
  const skip = (page - 1) * limit;
  const [data, total] = await this.repo.findAndCount({
    skip, take: limit,
    order: { [sortBy]: order },
  });
  return { data, meta: { page, limit, total, totalPages: Math.ceil(total / limit) } };
}

毎回書く。Swagger 記述も毎回書く。カーソルベースに切り替えたくなったら全部書き直し

nestjs-paginate

import { Paginate, PaginateQuery, paginate, PaginateConfig } from 'nestjs-paginate';

const CONFIG: PaginateConfig<Cat> = {
  sortableColumns: ['id', 'name', 'createdAt'],
  searchableColumns: ['name', 'breed'],
  defaultSortBy: [['createdAt', 'DESC']],
  filterableColumns: { breed: [FilterOperator.EQ, FilterOperator.IN] },
};

@Get()
@PaginatedSwaggerDocs(CatDto, CONFIG) // ← Swagger 自動
async findAll(@Paginate() query: PaginateQuery) {
  return paginate(query, this.repo, CONFIG);
}
  • offset / cursor 両対応
  • フィルタ(?filter.breed=$eq:Persian)・ソート(?sortBy=name:ASC)・全文検索が標準
  • Swagger 自動生成(@PaginatedSwaggerDocs()
  • 数千行の自前 Pagination Helper が数行に縮む

2. Retry → nestjs-resilience

自前実装あるある

// ❌ try/catch + sleep ループ
async callExternalApi() {
  let lastError;
  for (let i = 0; i < 3; i++) {
    try {
      return await fetch('...').then(r => r.json());
    } catch (e) {
      lastError = e;
      await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i))); // 自前 backoff
    }
  }
  throw lastError;
}

エクスポネンシャルバックオフ・ジッタ・最大遅延・特定エラーのみリトライ ─ 毎回書く。

nestjs-resilience

import { ResilienceModule, RetryStrategy } from 'nestjs-resilience';

@Injectable()
export class ExternalApiService {
  @UseResilience(
    new RetryStrategy({
      maxRetries: 3,
      shouldRetry: (e) => e.statusCode >= 500,
      backoffStrategy: new ExponentialBackoffStrategy({ initialDelay: 1000, maxDelay: 10000 }),
    }),
  )
  async callExternalApi() {
    return await fetch('...').then(r => r.json());
  }
}
  • Decorator で宣言的
  • shouldRetry で再試行条件を細かく制御
  • ジッタ付き exponential backoff
  • 後述の Circuit Breaker と同じパッケージ

3. Circuit Breaker → nestjs-resilience(opossum ラッパ)

import { CircuitBreakerStrategy } from 'nestjs-resilience';

@UseResilience(
  new CircuitBreakerStrategy({
    timeout: 3000,
    errorThresholdPercentage: 50,
    resetTimeout: 30000,
  }),
)
async callPaymentApi() { /* ... */ }

Circuit が open になっている間は外部呼び出しをスキップして、定期的に half-open で試す古典的パターン。opossum ベース。Retry と組み合わせて層を作るのが定石。

4. Distributed Lock → murlock

自前実装あるある

// ❌ Redis SETNX を直接書く
async processOrder(orderId: string) {
  const lock = await redis.set(`lock:${orderId}`, '1', 'NX', 'PX', 5000);
  if (!lock) throw new ConflictException('Already processing');
  try {
    return await this.process(orderId);
  } finally {
    await redis.del(`lock:${orderId}`);
  }
}

正しくRedlock アルゴリズムを実装するのは難しい。

murlock

import { MurLockModule, MurLock } from 'murlock';

@Injectable()
export class OrderService {
  @MurLock(5000, 'orderId') // ← 5秒の lock、orderId を key に
  async processOrder(orderId: string) {
    return await this.process(orderId);
  }
}

Decorator で宣言的。Redis Redlock ベース。lock 取得失敗時は MurLockException

5. ID 生成 → uuid / ulid を Provider 化

自前実装あるある

// ❌ 直接 import、テストでモック化困難
import { v4 as uuidv4 } from 'uuid';

@Injectable()
export class CatService {
  create(name: string) {
    return { id: uuidv4(), name }; // ← 直書き、テストで時刻順序を制御できない
  }
}

Provider 化

// id.provider.ts
export const ID_GENERATOR = 'ID_GENERATOR';

@Module({
  providers: [
    { provide: ID_GENERATOR, useValue: { generate: () => randomUUID() } },
  ],
  exports: [ID_GENERATOR],
})
export class IdModule {}

// service
@Injectable()
export class CatService {
  constructor(@Inject(ID_GENERATOR) private idGen: { generate: () => string }) {}
  create(name: string) {
    return { id: this.idGen.generate(), name };
  }
}

// test
{ provide: ID_GENERATOR, useValue: { generate: () => 'fixed-uuid-1' } }

これだけでテストが決定論的になる。ulid / nanoid への切り替えも 1 箇所で済む。

6. Soft Delete → TypeORM @DeleteDateColumn / Prisma 拡張

自前実装あるある

// ❌ deletedAt を全クエリで if 文
@Entity()
class Cat {
  @PrimaryGeneratedColumn() id: number;
  @Column({ nullable: true }) deletedAt: Date | null;
}

// 全クエリで where 条件追加
const cats = await repo.find({ where: { deletedAt: IsNull() } });

忘れた瞬間に削除済みデータが見える」事故が多発。

TypeORM @DeleteDateColumn

@Entity()
class Cat {
  @PrimaryGeneratedColumn() id: number;
  @DeleteDateColumn() deletedAt: Date | null;
}

// 削除
await repo.softDelete(id); // deletedAt が自動セット
await repo.softRemove(cat);

// 取得(deletedAt is null が自動付与)
const cats = await repo.find();

// 削除済みも含める
const all = await repo.find({ withDeleted: true });

// 復元
await repo.restore(id);

全クエリでの自動フィルタが標準で効く。Prisma も拡張で同等のことができる。

7. Audit Log → TypeORM EntitySubscriber or NestJS Interceptor + CLS

自前実装あるある

// ❌ Service 内で audit log を毎回呼ぶ
@Injectable()
export class CatService {
  async update(id: number, dto: UpdateCatDto, userId: string) {
    const before = await this.repo.findOne(id);
    const after = await this.repo.update(id, dto);
    await this.auditLog.record({ userId, entity: 'Cat', id, before, after }); // ← 毎メソッド
    return after;
  }
}

Service の責務が肥大化、忘れる箇所も出る。

TypeORM EntitySubscriber

@EventSubscriber()
export class AuditSubscriber implements EntitySubscriberInterface {
  afterUpdate(event: UpdateEvent<any>) {
    const userId = ClsServiceManager.getClsService().get('userId'); // ← AsyncLocalStorage
    auditLogger.record({
      userId,
      entity: event.metadata.name,
      id: event.entity.id,
      before: event.databaseEntity,
      after: event.entity,
    });
  }
}

DB レイヤで横断的に拾う。Service コードに audit の存在が出てこない。CLS と組み合わせると userId も自動で取れる。

8. Tenant Context → nestjs-cls(AsyncLocalStorage)

自前実装あるある

// ❌ tenantId を全 Service メソッドの引数で引き回す
@Get(':id')
async findOne(@Param('id') id: number, @Req() req) {
  return this.svc.findOne(id, req.tenantId); // ← 引数追加
}

// ❌ または REQUEST scope で注入(伝染問題)
@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
  constructor(@Inject(REQUEST) private req: Request) {}
  get tenantId() { return this.req.tenantId; }
}
// ↑ これを使う Service / Controller も全て REQUEST スコープに伝染

nestjs-cls

// app.module.ts
ClsModule.forRoot({
  global: true,
  middleware: {
    mount: true,
    setup: (cls, req) => cls.set('tenantId', req.headers['x-tenant-id']),
  },
});

// service(Singleton のまま)
@Injectable()
export class CatService {
  constructor(private cls: ClsService) {}
  async findAll() {
    const tenantId = this.cls.get('tenantId'); // ← AsyncLocalStorage 経由
    return this.repo.find({ where: { tenantId } });
  }
}

Service は Singleton のままで、AsyncLocalStorage 経由で request context が取れる。REQUEST scope 伝染を回避する2026年の現実解。第7章で詳しく扱う。

9. Request-scoped Logger → nestjs-pino

自前実装あるある

// ❌ Request ID を引数で引き回す
@Get(':id')
async findOne(@Param('id') id: number, @Req() req) {
  this.logger.log({ requestId: req.id, id }, 'finding cat');
}

// ❌ または REQUEST scope の Logger(伝染問題)
@Injectable({ scope: Scope.REQUEST })
export class RequestLogger { /* ... */ }

nestjs-pino + AsyncLocalStorage

// app.module.ts
LoggerModule.forRoot({
  pinoHttp: {
    autoLogging: true,
    customProps: (req) => ({ requestId: req.id }), // ← 自動付与
  },
});

// service(Singleton のまま)
@Injectable()
export class CatService {
  constructor(@InjectPinoLogger(CatService.name) private logger: PinoLogger) {}
  async findOne(id: number) {
    this.logger.info({ id }, 'finding cat');
    // ↑ pino-http が AsyncLocalStorage 経由で requestId を自動付与
  }
}
  • Pino がワーカースレッドで flush(同期 stdout を回避)
  • requestId全ログに自動付与
  • Service は Singleton のまま

第8章でパフォーマンス面を深掘り。

10. Idempotency → Interceptor + Redis

自前実装あるある

// ❌ Service / Controller で if 文
@Post()
async create(@Body() dto: CreateOrderDto, @Headers('x-idempotency-key') key: string) {
  if (key) {
    const cached = await redis.get(`idem:${key}`);
    if (cached) return JSON.parse(cached);
  }
  const order = await this.svc.create(dto);
  if (key) await redis.set(`idem:${key}`, JSON.stringify(order), 'EX', 86400);
  return order;
}

毎エンドポイントに同じコードを書く。

Interceptor + Redis(公開実装多数)

@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
  constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}

  async intercept(ctx: ExecutionContext, next: CallHandler) {
    const req = ctx.switchToHttp().getRequest();
    const key = req.headers['x-idempotency-key'];
    if (!key) return next.handle();

    const cached = await this.cache.get(`idem:${key}`);
    if (cached) return of(cached);

    return next.handle().pipe(
      tap(async (result) => {
        await this.cache.set(`idem:${key}`, result, 86400_000);
      }),
    );
  }
}

// 使う側
@UseInterceptors(IdempotencyInterceptor)
@Post()
create(@Body() dto: CreateOrderDto) {
  return this.svc.create(dto);
}

@UseInterceptors() を 1 行付けるだけ。Service / Controller のコードがクリーンに保たれる。

TOP 10 早見表

#自前実装パターン準公式パッケージ置換難度
1自前 Pagination Helpernestjs-paginate
2try/catch + sleep ループnestjs-resilience (Retry)
3自前 Circuit Breakernestjs-resilience (CB)
4Redis SETNX 直書きmurlock
5uuid 直 importProvider 化(自前 1 module)
6deletedAt 手書きTypeORM @DeleteDateColumn
7Service 内 audit ログ呼び出しTypeORM Subscriber + CLS
8tenantId を引数で引き回すnestjs-cls
9console.log + req.idnestjs-pino + ALS
10エンドポイント毎 idempotencyInterceptor + Redis

「自前で書きがち」を見分けるサイン

社内コードレビューで以下のパターンを見たら、ほぼ「再発明」だ:

  • Map<string, ...> を Service の field に持っている → CacheModule
  • setInterval で何かを定期実行している → ScheduleModule
  • try { ... } catch { sleep + retry } のループ → nestjs-resilience
  • req.tenantId を関数引数で引き回している → nestjs-cls
  • console.log か独自 Logger class → nestjs-pino
  • ✅ Service メソッドの末尾で auditLog.record() を呼んでいる → EntitySubscriber + CLS
  • if (page) { skip = ... } を毎エンドポイント → nestjs-paginate
  • ✅ Service 内で try-catchHttpException 整形 → ExceptionFilter
  • ✅ Decorator メタデータを直接 Reflect.getMetadata で読んでいる → Reflector
  • ✅ DI で取れるものを new している → DI 設計の見直し

移行戦略 ─ 一度に全部やらない

15 個全部を一気に置き換えると失敗する。優先度:

  1. Logger → nestjs-pino(最も低コスト、効果大)
  2. Tenant Context → nestjs-cls(REQUEST scope 伝染を解消、第7章で詳述)
  3. Pagination → nestjs-paginate(毎エンドポイントの Boilerplate 消滅)
  4. Idempotency → Interceptor(POST が増えてきたら)
  5. Retry / Circuit Breaker → nestjs-resilience(外部 API 連携が増えてきたら)
  6. Distributed Lock → murlock(Redis を既に使っているなら)

今書いている機能の実装中に既に類似コードを 2 回書いた」と気づいた瞬間が、置き換えのタイミング。

本章の要点

#要点
1NestJS 公式に同等品はないが、業界に定着した準公式パッケージで再発明を回避できる領域が 10 個ある
2nestjs-pino + nestjs-cls は Logger と Context の組み合わせで最も価値が高い(次章で性能面も)
3nestjs-paginate は Pagination Helper の毎プロジェクト書き直しを終わらせる
4nestjs-resilience は Retry / Circuit Breaker / Timeout を Decorator で宣言できる
5murlock は Redis Redlock を Decorator で扱える
6TypeORM @DeleteDateColumn / EntitySubscriber は Service コードを変えずに横断機能を追加する手段
7移行は段階的に。Logger → Context → Pagination の順で効果が大きい
8「Map を Service field に持つ / setInterval 直書き / req.tenantId を引き回す」等のサインはほぼ再発明

効いている根本原理

本章は 原理1(DI コンテナを信頼する) + 原理4(メモリ効率は DI スコープ × ストリーム × Logger に集約) を実装に落とした章だった。「自前で書く」のではなく「DI で受け取る・準公式に寄せる」が原則。

ここまでで第2部「再発明していないか棚卸しする」は完了。次の第3部からパフォーマンス・メモリ効率に入る。第7章では本章で何度も登場した REQUEST scope 伝染を、Durable Providersnestjs-cls の比較で深掘りする。