2026年10月8日にMicrosoftのマイクロサービスと共有責任の現行本文を再確認した。アーキテクチャの原則は「最新技術」ではなく、業務境界、品質属性、依存、失敗モード、データ所有、変更・復旧の証拠から選ぶ。SaaS/PaaS/IaaSや冗長化機能があっても、利用者が設定・監視・バックアップ・権限・アプリケーションの耐障害性を担う範囲は残る。複雑さを追加する判断には、測定可能な効果と撤回条件を置く。
30秒で要点
- アーキテクチャは、変更の境界、データの整合性、障害時の復旧、担当者の運用能力から選びます。会社の人数だけでモノリスかマイクロサービスかを決めません。
- まず業務の不変条件を一か所で守り、独立した変更や拡張が必要な境界を探します。サービスを分けると通信・重複・途中失敗への対応が増えます。
- イベント駆動では「処理が一度だけ届く」と仮定しません。以下は型、冪等な処理、集約を試すローカル教材で、本番DBのトランザクションや永続キューを完成させるコードではありません。
要件を構成図へ変える前に
最初に、利用者の操作と守る必要のある状態を書きます。架空の受注管理なら「空の注文を確定しない」「同じ注文を二重確定しない」「決済結果と配送開始を混同しない」が候補です。次に、応答時間、処理量、復旧時間、許容するデータ損失、監査の必要性を具体的な条件として埋めます。
表が収まらない場合は、横にスクロールできます
| 判断対象 | 確認する問い | 先に残す記録 |
|---|---|---|
| 変更の境界 | 在庫と請求は独立に変える必要があるか | 担当と変更例、共通ルール |
| 整合性 | どの更新を一緒に成功させる必要があるか | 注文確定の不変条件 |
| 拡張 | 一部分だけが実測で詰まっているか | 負荷条件と観測値 |
| 復旧 | 途中で止まった仕事を誰が再開するか | 再試行・照合・手動復旧の手順 |
| 運用 | 分散ログや夜間障害に対応できるか | 責任者、通知先、戻す条件 |
「長期利用だからマイクロサービス」「少人数だから必ずモノリス」では、この情報を省略してしまいます。Microsoftのアーキテクチャ様式も、N層、Web-Queue-Worker、マイクロサービス、イベント駆動などを異なる制約の選択肢として扱っています。
1. モジュールを分ける段階と配備を分ける段階
一つのアプリ内でも、受注・在庫・通知の責任をモジュールとして分離できます。コードの依存方向をそろえ、データ変更の入口を限定し、テストで境界を確かめます。独立配備まで必要かは次の問いで判断します。
同じ業務ルール・同じ整合性境界を共有するか
はい → 一つの配備内でモジュール境界を明確にする
いいえ → 独立変更・独立拡張が必要かを観測する
必要なし → 境界は保ち、配備の分離は保留する
必要あり → 所有者・通信契約・復旧手順を設計する
運用できる → 一つの境界で小さく分離して検証する
運用できない → 先に監視・復旧・責任分担を整える
これは著者の判断手順で、公式の採点式ではありません。たとえば夜間の帳票生成が遅いなら、全体をサービス分割する前にWeb-Queue-Worker型で非同期の仕事を分ける選択肢があります。利用者へすぐ結果が必要な処理を、説明なしに非同期へ移すことは避けます。
スケール、キャッシュ、データベース
水平拡張する前に、状態をどこへ置くかを確認します。アプリ内メモリのセッションやキューを複数台で共有したつもりにしないでください。DBの索引、問い合わせ回数、接続上限、整合性の必要範囲を調べてから構成を変更します。
キャッシュにはキー、失効、更新失敗、古い値を許せる時間を設定します。在庫数や権限のように誤ると困る値は、読み込み速度だけを理由に長期キャッシュしません。読み取り分散で遅延が生じる構成なら、直前の自分の更新が見えない場合の扱いも設計します。
2. イベントは出来事と契約を表す
イベント名は「起きた事実」を表し、処理の依頼と区別します。OrderCreatedとPaymentProcessedを同じものとして扱わず、それぞれのID、版、時刻、金額の単位を明示します。外部から届いたJSONはTypeScriptの型を書くだけでは検証されないため、実運用では入口でスキーマ検証します。
export type OrderCreated = {
type: 'OrderCreated'; version: 1; eventId: string;
occurredAt: string; orderId: string;
items: ReadonlyArray<{ sku: string; quantity: number }>;
};
export type PaymentProcessed = {
type: 'PaymentProcessed'; version: 1; eventId: string;
occurredAt: string; orderId: string;
amountMinor: number; currency: 'JPY';
};
export type DomainEvent = OrderCreated | PaymentProcessed;
JPYの例では最小単位の整数を使い、小数の丸めを暗黙にしません。他通貨へ広げるなら、その通貨の桁数と返金・失敗イベントも別途設計します。イベントの版を上げた場合は、古いconsumerが何を受け取れるかを決めます。
3. 途中まで成功した仕事を再配信する
注文保存と通知送信を別々に行うと、「注文は保存したが通知が失敗」「通知は届いたが送信側は応答を受け取れなかった」といった状態が起こります。Transactional Outboxでは、業務データと送るイベントを同じトランザクションで保存し、後から配信する方法を説明しています。それでも再配信は起こり得るため、consumer側の冪等性が必要です。
次は在庫確保、通知、集計の各仕事に別の冪等キーを渡す教材です。onceは「処理と完了記録をどこまで一体にできるか」を受け持つ依存であり、単なるメモリSetが本番の耐久性を保証するわけではありません。各サービスが冪等キーを受け付けることや、結果不明時の照合も必要です。
type OrderEvent = { eventId: string; orderId: string };
type Effects = {
once(key: string, work: () => Promise<void>): Promise<void>;
reserve(orderId: string, key: string): Promise<void>;
notify(orderId: string, key: string): Promise<void>;
count(orderId: string, key: string): Promise<void>;
};
export async function handleOrder(event: OrderEvent, effects: Effects) {
for (const step of ['reserve', 'notify', 'count'] as const) {
const key = event.eventId + ':' + step;
await effects.once(key, () => effects[step](event.orderId, key));
}
}
たとえばreserve完了後にnotifyが失敗すると、次の配信では完了済みreserveを飛ばし、notifyから進める契約です。通知が外部で完了した直後に記録前で停止した場合は、キーの照合を通知先でも行わなければ二重送信が残ります。図の「処理済み」と「配信元へ確認を返した」は別の観測です。
再試行するもの・しないもの
Retryパターンに沿って、一時的な障害と入力不備を分けます。接続の一時切断は回数と待機を決めた候補ですが、存在しない商品や権限不足を何度送っても改善しません。表が収まらない場合は、横にスクロールできます
| 状態 | この設計での対応 | 残す証拠 |
|---|---|---|
| 一時障害、結果未確定 | 同じ冪等キーで照合し、上限付き再試行 | 試行回数・応答・照合結果 |
| 入力不備 | 再送せず修正可能なエラーへ | どの条件に違反したか |
| 部分的に完了 | 完了済み工程と未完了工程を分ける | 工程ごとの状態 |
| 上限到達 | 保留キューや担当者へ引き継ぐ | 元イベント・最後の状態 |
待機を増やすだけで、重複課金や二重通知の問題は解消しません。「タイムアウトだから失敗した」と断定せず、相手側で成功した可能性を扱います。
4. 集約で不変条件を守る
DDDの集約は、一緒に整合性を保つデータと操作の境界です。次は注文を空で確定しない、数量を正の整数にする、二重確定しない、というローカルのモデルです。イベントは戻り値にし、呼出側が注文更新とoutbox保存を同じトランザクションへ接続します。
type Item = { sku: string; quantity: number };
type Created = {
type: 'OrderCreated'; version: 1; eventId: string;
occurredAt: string; orderId: string; items: ReadonlyArray<Item>;
};
export class Order {
private status: 'draft' | 'confirmed' = 'draft';
private items: Item[] = [];
constructor(private readonly id: string) {
if (!id) throw new Error('order id');
}
add(item: Item) {
if (this.status !== 'draft') throw new Error('already confirmed');
if (!item.sku || !Number.isInteger(item.quantity) || item.quantity <= 0) throw new Error('invalid item');
this.items.push({ ...item });
}
confirm(eventId: string, occurredAt: string): Created {
if (this.status !== 'draft' || this.items.length === 0) throw new Error('cannot confirm');
if (!eventId || !Number.isFinite(Date.parse(occurredAt))) throw new Error('event metadata');
this.status = 'confirmed';
return { type: 'OrderCreated', version: 1, eventId, occurredAt,
orderId: this.id, items: this.items.map(item => ({ ...item })) };
}
}
このメモリ上の状態変更はDBへの保存ではありません。保存失敗時の再読込、同時更新の競合、outboxとの原子的な更新、リポジトリの責任を追加して初めて運用構成になります。ローカルテストでは空・数量0・二重確定・返した配列の外部変更を試し、境界の意味を確認できます。
架空の受注システムで判断記録を作る
一つの配備に受注・在庫・通知のモジュールがあるとします。通知だけが遅く、画面応答を待たせているなら、まずoutboxとworkerへ通知を移す仮説を立てます。受注と在庫の同時更新が必要な間は、そこを無理に別DBへ分けません。
架空の100注文に対し、配信再試行が5回追加された場合、配信回数は105回です。冪等性が正しければ「業務上の処理完了」は100件のままですが、これは説明用の算数で実稼働の実績ではありません。監視では両方の分母を持ち、再試行が増えたことを注文増加と取り違えないようにします。
ADR(設計判断の記録)には「通知を非同期へ移す」「受注と在庫は同じ整合性境界を維持」「通知先の冪等キーと結果照合を実装」「滞留件数と最古イベントの経過時間を観測」「担当者が保留イベントを処理」「許容遅延を超えたら停止して照合」と書きます。採用しなかった全サービス分割案には、運用負担と整合性の理由を残します。
実装後は、成功経路だけでなく保存直後、通知直後、確認応答前に止まった状態を検証します。サービス数を増やすことではなく、変更と復旧の責任を説明できる構成を目指してください。