ストーリー
高橋アーキテクトから4つの設計課題が出された。
「すべてデータ整合性に関する問題だ。それぞれ適切なパターンを選び、具体的な設計と実装を示してくれ。『なぜそのパターンを選んだか』の理由も重要だ」
ミッション概要
| 項目 | 内容 |
|---|---|
| 目標 | 4つの整合性課題に対する設計と実装 |
| 評価基準 | パターン選択の適切さ、実装の正確性、理由の明確さ |
| 制限時間 | 90分 |
Mission 1: 在庫管理の同時実行制御
シナリオ
ECサイトの人気商品で在庫が残り5個。同時に10人が購入ボタンを押した。
要件:
- 在庫以上の販売を絶対に防ぐ
- 可能な限り高いスループットを維持
- 「在庫切れ」のレスポンスを迅速に返す
課題: 同時実行制御の設計と実装を行え。悲観的ロックと楽観的ロック、どちらが適切か判断理由とともに示せ。
ヒント
在庫は「衝突が頻発する」ケース。SELECT FOR UPDATEまたはCAS(Compare-And-Swap)を検討しよう。
回答例
選択: 悲観的ロック(SELECT FOR UPDATE)
理由: 人気商品は同時アクセスが頻発するため、楽観的ロックではリトライが多発して効率が悪い。悲観的ロックで直列化した方がスループットが安定する。
async function purchaseProduct(
productId: number,
quantity: number,
userId: number
): Promise<PurchaseResult> {
return await prisma.$transaction(async (tx) => {
// FOR UPDATE NOWAIT: ロック取得できなければ即座にエラー
const [product] = await tx.$queryRaw<Product[]>`
SELECT id, stock, price
FROM products
WHERE id = ${productId}
FOR UPDATE NOWAIT
`;
if (!product) {
throw new Error('Product not found');
}
if (product.stock < quantity) {
throw new Error('Insufficient stock');
}
// 在庫更新
await tx.product.update({
where: { id: productId },
data: { stock: { decrement: quantity } },
});
// 注文作成
const order = await tx.order.create({
data: {
userId,
status: 'pending',
total: product.price * quantity,
items: {
create: {
productId,
quantity,
unitPrice: product.price,
},
},
},
});
return { success: true, orderId: order.id };
}, {
isolationLevel: 'ReadCommitted',
timeout: 5000,
});
}
// コントローラー: NOWAITエラーのハンドリング
app.post('/purchase', async (req, res) => {
try {
const result = await purchaseProduct(req.body.productId, req.body.quantity, req.user.id);
res.json(result);
} catch (error) {
if (error.message.includes('could not obtain lock')) {
res.status(409).json({ error: 'Product is being purchased by another user. Please try again.' });
} else if (error.message === 'Insufficient stock') {
res.status(400).json({ error: 'Out of stock' });
} else {
res.status(500).json({ error: 'Internal error' });
}
}
});
Mission 2: ECサイトの注文フロー(Saga)
シナリオ
マイクロサービス構成のECサイト。注文処理は以下の3ステップ:
- 注文作成(注文サービス)
- 在庫確保(在庫サービス)
- 決済処理(決済サービス)
各サービスは独立したDBを持つ。
要件:
- 途中で失敗した場合、すべての操作を巻き戻す
- 各ステップの失敗原因を記録する
- 処理状態の可視性を確保する
課題: Sagaパターン(Choreography or Orchestration)で設計せよ。補償トランザクションも含めること。
ヒント
3ステップで処理状態の可視性が求められているため、Orchestration方式が適切。各ステップの補償トランザクションを明確に定義しよう。
回答例
選択: Orchestration方式
理由: 3ステップで可視性が必要。Orchestratorで全体の状態を一元管理する。
// Saga状態管理
type SagaState = 'STARTED' | 'ORDER_CREATED' | 'INVENTORY_RESERVED'
| 'PAYMENT_PROCESSED' | 'COMPLETED' | 'COMPENSATING' | 'FAILED';
interface SagaLog {
sagaId: string;
orderId?: string;
state: SagaState;
steps: Array<{
service: string;
action: string;
status: 'success' | 'failed' | 'compensated';
error?: string;
timestamp: Date;
}>;
}
class OrderSagaOrchestrator {
async execute(orderData: OrderInput): Promise<SagaResult> {
const sagaId = crypto.randomUUID();
const log: SagaLog = {
sagaId,
state: 'STARTED',
steps: [],
};
try {
// Step 1: 注文作成
const order = await this.orderService.create(orderData);
log.orderId = order.id;
log.state = 'ORDER_CREATED';
log.steps.push({ service: 'order', action: 'create', status: 'success', timestamp: new Date() });
// Step 2: 在庫確保
await this.inventoryService.reserve(order.items);
log.state = 'INVENTORY_RESERVED';
log.steps.push({ service: 'inventory', action: 'reserve', status: 'success', timestamp: new Date() });
// Step 3: 決済処理
await this.paymentService.charge(order.userId, order.total);
log.state = 'PAYMENT_PROCESSED';
log.steps.push({ service: 'payment', action: 'charge', status: 'success', timestamp: new Date() });
// 注文確定
await this.orderService.confirm(order.id);
log.state = 'COMPLETED';
await this.saveSagaLog(log);
return { success: true, orderId: order.id };
} catch (error) {
log.state = 'COMPENSATING';
log.steps[log.steps.length] = {
...log.steps[log.steps.length - 1],
status: 'failed',
error: error.message,
};
// 補償トランザクション(逆順)
await this.compensate(log);
log.state = 'FAILED';
await this.saveSagaLog(log);
return { success: false, error: error.message };
}
}
private async compensate(log: SagaLog): Promise<void> {
const completedSteps = log.steps.filter(s => s.status === 'success');
for (const step of completedSteps.reverse()) {
try {
switch (`${step.service}:${step.action}`) {
case 'payment:charge':
await this.paymentService.refund(log.orderId!);
step.status = 'compensated';
break;
case 'inventory:reserve':
await this.inventoryService.release(log.orderId!);
step.status = 'compensated';
break;
case 'order:create':
await this.orderService.cancel(log.orderId!);
step.status = 'compensated';
break;
}
} catch (compError) {
// 補償失敗は手動対応キューに入れる
await this.deadLetterQueue.enqueue({
sagaId: log.sagaId,
step,
error: compError.message,
});
}
}
}
}
Mission 3: 銀行口座のイベントソーシング
シナリオ
銀行口座システムで、すべての取引を完全に記録する必要がある。
要件:
- 入金、出金、送金のすべての取引を記録
- 任意の時点の残高を再計算可能
- 完全な監査証跡
課題: イベントソーシングで口座のデータモデルを設計せよ。
ヒント
イベントの種類(Deposited, Withdrawn, Transferred)を定義し、イベントの適用で残高を計算する集約を設計しよう。
回答例
// イベント定義
type AccountEvent =
| { type: 'AccountOpened'; data: { ownerId: string; initialBalance: number } }
| { type: 'MoneyDeposited'; data: { amount: number; source: string } }
| { type: 'MoneyWithdrawn'; data: { amount: number; reason: string } }
| { type: 'MoneyTransferredOut'; data: { amount: number; toAccountId: string; transferId: string } }
| { type: 'MoneyTransferredIn'; data: { amount: number; fromAccountId: string; transferId: string } };
// 集約
class AccountAggregate {
id: string;
ownerId: string = '';
balance: number = 0;
status: 'active' | 'closed' = 'active';
version: number = 0;
private uncommittedEvents: AccountEvent[] = [];
// コマンド: ビジネスルールの検証 + イベント生成
deposit(amount: number, source: string): void {
if (amount <= 0) throw new Error('Amount must be positive');
if (this.status !== 'active') throw new Error('Account is not active');
this.applyEvent({ type: 'MoneyDeposited', data: { amount, source } });
}
withdraw(amount: number, reason: string): void {
if (amount <= 0) throw new Error('Amount must be positive');
if (this.balance < amount) throw new Error('Insufficient funds');
if (this.status !== 'active') throw new Error('Account is not active');
this.applyEvent({ type: 'MoneyWithdrawn', data: { amount, reason } });
}
// イベント適用
private applyEvent(event: AccountEvent): void {
this.apply(event);
this.uncommittedEvents.push(event);
}
apply(event: AccountEvent): void {
switch (event.type) {
case 'AccountOpened':
this.ownerId = event.data.ownerId;
this.balance = event.data.initialBalance;
this.status = 'active';
break;
case 'MoneyDeposited':
this.balance += event.data.amount;
break;
case 'MoneyWithdrawn':
this.balance -= event.data.amount;
break;
case 'MoneyTransferredOut':
this.balance -= event.data.amount;
break;
case 'MoneyTransferredIn':
this.balance += event.data.amount;
break;
}
this.version++;
}
getUncommittedEvents(): AccountEvent[] {
return [...this.uncommittedEvents];
}
clearUncommittedEvents(): void {
this.uncommittedEvents = [];
}
}
// リポジトリ
class AccountRepository {
constructor(private eventStore: EventStore) {}
async load(accountId: string): Promise<AccountAggregate> {
const events = await this.eventStore.getEvents('Account', accountId);
const aggregate = new AccountAggregate();
aggregate.id = accountId;
for (const event of events) {
aggregate.apply(event);
}
return aggregate;
}
async save(aggregate: AccountAggregate): Promise<void> {
const events = aggregate.getUncommittedEvents();
for (const event of events) {
await this.eventStore.appendEvent(
'Account',
aggregate.id,
event,
aggregate.version - events.length
);
}
aggregate.clearUncommittedEvents();
}
}
// 任意時点の残高計算
async function getBalanceAtTime(accountId: string, targetTime: Date): Promise<number> {
const events = await eventStore.getEventsBefore('Account', accountId, targetTime);
const aggregate = new AccountAggregate();
aggregate.id = accountId;
for (const event of events) {
aggregate.apply(event);
}
return aggregate.balance;
}
イベントストアのテーブル設計:
CREATE TABLE account_events (
id BIGSERIAL PRIMARY KEY,
aggregate_id VARCHAR(36) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
version INT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB,
UNIQUE (aggregate_id, version)
);
CREATE INDEX idx_account_events_time
ON account_events(aggregate_id, occurred_at);
Mission 4: Wiki の楽観的ロック
シナリオ
チームWikiで複数人が同じページを同時に編集している。
要件:
- 同時編集の衝突を検出する
- 後からの保存が先の変更を上書きしない
- 衝突時にユーザーに通知し、マージを促す
課題: 楽観的ロックで同時編集の衝突検出を設計せよ。
ヒント
バージョンカラムまたはupdated_atのタイムスタンプを使い、更新時に競合を検出する設計を検討しよう。
回答例
// Wikiページのモデル
interface WikiPage {
id: string;
title: string;
content: string;
version: number;
lastEditedBy: string;
updatedAt: Date;
}
class WikiService {
// ページ取得(バージョン情報を含む)
async getPage(pageId: string): Promise<WikiPage> {
return await prisma.wikiPage.findUnique({ where: { id: pageId } });
}
// ページ更新(楽観的ロック)
async updatePage(
pageId: string,
content: string,
expectedVersion: number,
editedBy: string
): Promise<UpdateResult> {
try {
const updated = await prisma.wikiPage.updateMany({
where: {
id: pageId,
version: expectedVersion, // バージョンチェック
},
data: {
content,
version: { increment: 1 },
lastEditedBy: editedBy,
updatedAt: new Date(),
},
});
if (updated.count === 0) {
// 競合検出
const currentPage = await this.getPage(pageId);
return {
success: false,
conflict: true,
currentVersion: currentPage.version,
currentContent: currentPage.content,
lastEditedBy: currentPage.lastEditedBy,
message: `${currentPage.lastEditedBy}さんが先に更新しました。内容を確認してください。`,
};
}
return { success: true, newVersion: expectedVersion + 1 };
} catch (error) {
throw error;
}
}
}
// APIエンドポイント
app.put('/wiki/:pageId', async (req, res) => {
const { content, version } = req.body;
const result = await wikiService.updatePage(
req.params.pageId,
content,
version,
req.user.name
);
if (result.conflict) {
res.status(409).json(result); // 409 Conflict
} else {
res.json(result);
}
});
達成チェックリスト
- Mission 1: 悲観的ロックで在庫管理の同時実行制御を設計できた
- Mission 2: Sagaパターンで分散トランザクションを設計できた
- Mission 3: イベントソーシングで口座管理を設計できた
- Mission 4: 楽観的ロックで同時編集の衝突検出を設計できた
推定所要時間: 90分