メインコンテンツへスキップ
ブログ一覧に戻る
Web制作・運用

システムアーキテクチャ設計の考え方:スケーラブルで保守性の高い設計原則

2026年2月4日
最終更新: 2026年10月8日
8分で読めます

この記事でわかること

システム設計を、変更の境界、データ整合性、復旧と運用能力から考えるガイドです。モノリスとサービス分割、イベント契約、Outbox、冪等性、DDDの集約を受注例で解説します。途中失敗と再配信を扱う型付き教材を使い、ローカル検証と実際の永続化を区別しながら、採用理由と戻す条件を設計判断の記録へまとめます。

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側の冪等性が必要です。

注文とOutboxの同時保存、worker配信、冪等な受信処理、確認前の再配信を示す設計図
著者作成。処理完了と配信確認を分け、同じイベントの再配信を扱います。ローカル教材は永続化の実証ではありません。

次は在庫確保、通知、集計の各仕事に別の冪等キーを渡す教材です。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(設計判断の記録)には「通知を非同期へ移す」「受注と在庫は同じ整合性境界を維持」「通知先の冪等キーと結果照合を実装」「滞留件数と最古イベントの経過時間を観測」「担当者が保留イベントを処理」「許容遅延を超えたら停止して照合」と書きます。採用しなかった全サービス分割案には、運用負担と整合性の理由を残します。

実装後は、成功経路だけでなく保存直後、通知直後、確認応答前に止まった状態を検証します。サービス数を増やすことではなく、変更と復旧の責任を説明できる構成を目指してください。

よくある質問(FAQ)