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の設計で押さえるべきポイント:
- べき等性を意識する — PUT/DELETEはべき等に設計する
- POSTには冪等性キー — 重複リクエスト対策
- PUTは全体置換、PATCHは部分更新 — 混同しない
- 楽観的ロックを基本に — 競合コストが高い場合のみ悲観的ロック
- エラーは適切なステータスコードで — 競合は409、不正は400
設計の理論的な背景はWebを支える技術の16章(書き込み可能なWebサービスの設計)で詳しく解説されている。