REST APIの書き込み設計——冪等性・トランザクション・楽観的ロックの実装パターン

REST APIの書き込み操作(POST・PUT・PATCH・DELETE)における冪等性の確保、トランザクション設計、楽観的ロック・悲観的ロックの実装パターンをNestJS/TypeScriptのコード例で解説。

REST APIの読み取りはシンプルだが、書き込みは難しい。

「ユーザーがダブルクリックした」「ネットワークが不安定でリクエストが重複した」「複数ユーザーが同じリソースを同時に編集した」——こうした現実の課題に対応するための設計パターンを整理する。


書き込みAPIの設計原則

HTTPメソッドのべき等性

メソッド安全べき等用途
GET取得
HEADヘッダのみ取得
DELETE削除
PUT全体更新・作成
PATCH部分更新
POST作成・その他

べき等とは、同じリクエストを何度送っても結果が変わらない性質だ。

  • DELETE /users/123 → 何度送っても「ユーザー123が存在しない状態」になる(べき等)
  • POST /orders → 送るたびに注文が増える(べき等でない)

べき等なAPIはリトライが安全なため、ネットワーク不安定時の重複リクエスト対策が容易になる。


POST の冪等性を確保する

POSTはべき等でないため、重複リクエストによる二重処理が問題になる。これを解決するのが**冪等性キー(Idempotency Key)**パターンだ。

// クライアント側:リクエストに一意のキーを付与
const idempotencyKey = crypto.randomUUID();

await fetch('/api/orders', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Idempotency-Key': idempotencyKey,
  },
  body: JSON.stringify({ items: [...] }),
});
// NestJS サーバ側:冪等性キーで重複を検知
@Injectable()
export class OrdersService {
  constructor(
    private readonly ordersRepository: OrdersRepository,
    private readonly cache: CacheService, // RedisなどのKVS
  ) {}

  async createOrder(dto: CreateOrderDto, idempotencyKey: string) {
    // 同じキーのリクエストが処理済みか確認
    const cached = await this.cache.get(`idempotency:${idempotencyKey}`);
    if (cached) {
      return JSON.parse(cached); // 前回と同じレスポンスを返す
    }

    const order = await this.ordersRepository.create(dto);

    // 処理結果を24時間キャッシュ
    await this.cache.set(
      `idempotency:${idempotencyKey}`,
      JSON.stringify(order),
      86400,
    );

    return order;
  }
}

PUTとPATCHの使い分け

// PUT: リソース全体を置き換える(べき等)
// 省略フィールドはnullや初期値になる
PUT /users/123
{
  "name": "Taka Updated",
  "email": "taka@example.com",
  "role": "admin"  // 全フィールド必須
}

// PATCH: 部分更新(送ったフィールドだけ変更)
PATCH /users/123
{
  "name": "Taka Updated"  // nameだけ変更
}
// NestJS での PATCH 実装
@Patch(':id')
async update(
  @Param('id') id: number,
  @Body() dto: UpdateUserDto, // すべてオプショナルなDTO
) {
  return this.usersService.update(id, dto);
}

// DTO
export class UpdateUserDto {
  @IsOptional()
  @IsString()
  name?: string;

  @IsOptional()
  @IsEmail()
  email?: string;
}

楽観的ロック——並行更新の衝突を検知する

複数ユーザーが同じリソースを同時に編集する場合、後から保存した方が先の変更を上書きする「Lost Update」問題が起きる。

楽観的ロックは「競合は滅多に起きない」という前提で、更新時に競合を検知して失敗させるアプローチだ。

// ETagを使った楽観的ロック
// 1. リソース取得時にETagを返す
GET /articles/1
ETag: "version-5"

// 2. 更新時にIf-Matchヘッダでバージョンを指定
PUT /articles/1
If-Match: "version-5"
{ "title": "Updated Title" }

// 3. バージョンが一致 → 更新成功 (200)
// 4. バージョン不一致 → 409 Conflict
// NestJS での実装例
@Injectable()
export class ArticlesService {
  async update(id: number, dto: UpdateArticleDto, etag: string) {
    const article = await this.articlesRepository.findOne(id);

    if (!article) throw new NotFoundException();

    // バージョン確認
    const currentEtag = `version-${article.version}`;
    if (currentEtag !== etag) {
      throw new ConflictException('リソースが他のユーザーによって更新されました。最新の内容を確認してください。');
    }

    return this.articlesRepository.update(id, {
      ...dto,
      version: article.version + 1, // バージョンインクリメント
    });
  }
}

@Put(':id')
async update(
  @Param('id') id: number,
  @Body() dto: UpdateArticleDto,
  @Headers('If-Match') etag: string,
) {
  return this.articlesService.update(id, dto, etag);
}

悲観的ロック——競合を事前に防ぐ

「競合が頻繁に起きる」「競合コストが高い」場合は、悲観的ロックで先にロックを取得する。

// トランザクション内でSELECT FOR UPDATEを使う
@Injectable()
export class InventoryService {
  async decrementStock(productId: number, quantity: number) {
    return this.dataSource.transaction(async (manager) => {
      // ロックを取得(他のトランザクションはここで待機)
      const product = await manager.findOne(Product, {
        where: { id: productId },
        lock: { mode: 'pessimistic_write' }, // SELECT FOR UPDATE
      });

      if (!product) throw new NotFoundException();
      if (product.stock < quantity) throw new BadRequestException('在庫不足');

      product.stock -= quantity;
      return manager.save(product);
    });
  }
}

楽観的ロック vs 悲観的ロック:

観点楽観的ロック悲観的ロック
前提競合は稀競合が頻繁
パフォーマンス高い(ロック待ちなし)低い(ロック待ちあり)
競合時の動作409エラーでクライアントに再試行させるサーバ側で直列化
向いているケースCMS・ドキュメント編集在庫管理・決済

トランザクションリソースのパターン

複数のリソースをまたがる操作を原子的に実行したい場合、トランザクションリソースを作るパターンがある。

# 送金のトランザクション
POST /transfers
{
  "fromAccountId": 1,
  "toAccountId": 2,
  "amount": 10000
}

# トランザクションリソースが作成される
→ 201 Created
{ "id": "txn-abc123", "status": "pending" }

# トランザクションのコミット
PUT /transfers/txn-abc123/commit

# またはロールバック
DELETE /transfers/txn-abc123

このパターンはREST的な設計を保ちながら、複数リソースの原子的操作を表現できる。


まとめ

書き込みAPIの設計で押さえるべきポイント:

  1. べき等性を意識する — PUT/DELETEはべき等に設計する
  2. POSTには冪等性キー — 重複リクエスト対策
  3. PUTは全体置換、PATCHは部分更新 — 混同しない
  4. 楽観的ロックを基本に — 競合コストが高い場合のみ悲観的ロック
  5. エラーは適切なステータスコードで — 競合は409、不正は400

設計の理論的な背景はWebを支える技術の16章(書き込み可能なWebサービスの設計)で詳しく解説されている。