Webアプリのアーキテクチャとは何か? ECサイトの裏側から設計の全体像を理解する
Webアプリの構成図を見ると、ブラウザ、API、データベース、キャッシュなど、いくつもの箱と矢印が並んでいます。ところが、その名前を覚えただけでは、アーキテクチャを理解したことにはなりません。大切なのは、なぜ箱を分けたのか、どこまでを一つの責務としたのか、障害や変更が起きたときに何を守りたいのかまで考えることです。
この記事ではECサイトを例に、商品ページを開き、ログインし、カートへ商品を入れ、注文と決済を行い、在庫を更新して通知を送るまでを追います。コード内部の分け方とインフラ構成を別々に扱わず、一つの処理としてつなげていきます。
1. 「アーキテクチャ」という言葉が分かりにくい理由
「このWebアプリのアーキテクチャは何ですか」と聞かれて、Next.js、Node.js、PostgreSQL、AWSと答えることがあります。これは使用技術の一覧、つまり技術スタックとしては正しいのですが、アーキテクチャの説明としては足りません。同じ技術を使っていても、注文処理をどこに置くか、データを誰が更新できるか、障害時にどこまで止まるかによって、システムの性質は変わるからです。
この記事ではアーキテクチャを、システムの主要な責務と境界、依存関係、データの流れ、そして簡単には変更できない設計判断のまとまりとして扱います。「画面を表示する」「価格を決める」「注文を保存する」「決済会社と通信する」といった仕事を、どの部分へ任せるのか。その部分同士をどうつなぎ、失敗した場合に何を守るのか。ここまで含めて考えるのがアーキテクチャです。
ディレクトリをcontrollers、services、repositoriesへ分けることや、MVCを採用することは、アーキテクチャを実現する一つの手段です。ただし、フォルダを分けただけで責務が分かれるわけではありません。Controllerが価格計算もSQL実行もメール送信も担当していれば、見た目だけが整った大きな一枚岩のままです。
さらに、アーキテクチャには複数の縮尺があります。ブラウザ、API、DBをどう配置するかというシステム全体の話もあれば、API内部をController、UseCase、Domainへどう分けるかというコード内部の話もあります。本来、この二つは切り離せません。注文という業務処理をどこで確定するかが決まれば、DBの更新方法や外部決済との接続方法、テストの境界まで連動して決まるためです。
つまり、よいアーキテクチャは「箱が多い構成」ではありません。要件に対して必要な境界があり、変更や障害の影響を説明できる構成です。
2. ECサイトに必要な機能と品質
題材にするECサイトには、商品一覧と詳細の表示、ログイン、カート、注文、決済、在庫更新、注文完了通知があるとします。これらは利用者や運営者が直接確認できる機能要件です。しかし、機能が動くだけではサービスとして十分ではありません。
たとえば商品ページは速く表示したい一方、在庫数は古すぎてはいけません。注文確定ボタンを二度押しても、請求まで二重になっては困ります。APIサーバーの1台が停止しても閲覧を続けられること、権限のない利用者が他人の注文を見られないこと、価格ルールを変更したときに修正箇所が広がりすぎないことも必要です。性能、可用性、整合性、セキュリティ、保守性といった、動き方の質に関する条件を非機能要件または品質特性と呼びます。
ここで難しいのは、すべてを同時に最大化できないことです。データを何重にも複製すれば障害には強くできますが、費用と運用作業が増え、複製の遅延も考えなければなりません。毎回DBから最新の商品情報を読めば整合性は高めやすいものの、アクセス集中時の負荷は上がります。サービスを細かく分割すればチームごとに変更しやすくなる場合がありますが、ネットワーク通信や監視、データ同期は複雑になります。
そのため設計の出発点は、「流行している構成は何か」ではなく、「このECサイトでは何を優先し、どの失敗を許容できないか」です。閲覧の一時的な遅延と二重決済では、重さが違います。前者は再読み込みで回復できても、後者は返金や問い合わせを発生させます。アーキテクチャは、こうした差を構造へ反映させる作業でもあります。
3. ECサイト全体を構成する要素

同期通信・非同期処理・外部サービスを含むデータの流れ
全体像を上から追います。利用者が最初に触れるのはブラウザです。HTML、CSS、JavaScriptを読み込み、操作を受け取り、必要に応じてAPIへHTTPリクエストを送ります。商品画像やビルド済みJavaScriptのような静的ファイルは、オブジェクトストレージに置き、CDNから配信できます。CDNは利用者に近い拠点へレスポンスを保存し、オリジンまで取りに行く回数と通信距離を減らします。ただし、何をどのくらい保存できるかはHTTPキャッシュの規則とレスポンスヘッダーで決まります。RFC 9111は、HTTPキャッシュと、それを制御するヘッダーフィールドを定義しています。
商品データや注文のように、その都度処理が必要な要求はAPIへ送ります。APIサーバーが複数ある場合、その手前にロードバランサーを置いてリクエストを分配します。AWSのApplication Load Balancerを例にすると、登録された複数ターゲットへ通信を分散し、ヘルスチェックで正常と判断したターゲットへルーティングします。これはAWS固有の名前ですが、ロードバランサーの役割を理解する具体例になります。AWS公式ドキュメント
APIの後ろには、注文や商品を永続化するRDB、読み取りを高速化するキャッシュ、画像などを保持するオブジェクトストレージがあります。メール送信のように注文レスポンスと同時に終わらせる必要がない処理は、メッセージキューへ要求を送り、別のWorkerが実行できます。決済は自前で完結させず、外部の決済APIへ依頼する構成が一般的です。
ただし、この図は「最初から全部そろえるべき構成」ではありません。小規模な段階なら、一つのアプリケーションと一つのRDBだけで十分なこともあります。CDN、キャッシュ、キュー、複数サーバーには、それぞれ設定、監視、障害対応という代償があります。構成要素は勲章ではなく、観測した問題を解くために追加するものです。
4. 商品ページが表示されるまで

利用者が商品ページのURLを開いた場面を追ってみます。ブラウザは最初にドメイン名をIPアドレスへ解決し、接続先へHTTPリクエストを送ります。CDNにHTMLやJavaScript、画像の有効なキャッシュがあれば、オリジンサーバーまで到達せずに返せます。キャッシュが古い、または存在しない場合はオリジンへ取得しに行き、条件を満たせば次回に備えて保存します。
画面の枠が届いたあと、JavaScriptがGET /api/products/123を呼び出す構成を考えます。APIは商品IDの形式を確認し、商品取得の処理を呼び出します。アプリケーションキャッシュに商品があれば、その値を返します。なければRDBへ問い合わせ、取得結果をキャッシュへ保存してからJSONを返します。Redisの公式資料で紹介されているCache-Asideも、アプリケーションがキャッシュを先に確認し、ミスした場合に主データへフォールバックし、結果をキャッシュする方式です。Redis公式ドキュメント
この流れには失敗もあります。商品が存在しなければ404 Not Found、入力が不正なら400 Bad Request、DBが時間内に返答しなければタイムアウトとして処理します。何でも500 Internal Server Errorにまとめると、利用者にも運用者にも原因が伝わりません。一方で、DB内部の例外メッセージをそのまま返すのも情報漏えいにつながるため、外部向けのエラーと内部ログは分けます。
表示方式によって、処理の位置は少し変わります。CSRは主にブラウザ上のJavaScriptがデータを取得して画面を組み立てます。SSRはリクエスト時にサーバー側でHTMLを生成し、SSGは事前にHTMLを生成して配信します。ただし、どの方式でも価格や権限の最終判断をブラウザへ任せてよいわけではありません。表示の作り方と、業務上の正しさを保証する場所は別の問題です。
また、画面とAPIのオリジンが異なる場合はCORSの設定が関係します。CORSは認証方式ではなく、サーバーがどのオリジンのブラウザコードへレスポンスの共有を許すかをHTTPヘッダーで伝える仕組みです。MDNのCORSガイド ここを混同すると、「CORSを設定したからAPIが安全」という誤解が生まれます。
5. バックエンド内部をレイヤーに分ける理由

全体構成の次は、APIサーバーの中へ入ります。注文APIを急いで作ると、ルートハンドラーに入力チェック、価格計算、SQL、決済API、メール送信まで書きたくなります。
app.post('/orders', async (req, res) => {
const product = await db.query('SELECT ...');
const total = product.price * req.body.quantity;
await db.query('INSERT INTO orders ...');
await payment.charge(total);
await mail.send(req.user.email);
res.status(201).json({ ok: true });
});短い間は読めますが、価格計算を変える、決済失敗時の状態を追加する、メールだけ再送する、といった変更が同じ関数へ集まります。HTTP、業務ルール、DB、外部サービスは変更される理由が違います。それらが密着すると、一つを直すたびに別の部分まで壊していないか確認しなければなりません。
そこで、この例では四つの責務に分けます。
層主な責務知ってよいものControllerHTTP入力の取得、認証済み利用者の受け渡し、HTTPレスポンスへの変換HTTP、UseCaseの入口UseCase注文処理の順序、トランザクション境界、外部処理の調整Domain、Repositoryの抽象Domain価格、割引、注文状態などの業務ルール業務上の概念Repository / InfrastructureRDB、キャッシュ、決済APIなど具体的なI/O製品SDK、SQL、外部通信
Controllerは、受け取った値をUseCaseへ渡すところまでにします。中心になる処理は、次のような入口を持たせます。
const order = await placeOrder({
userId: req.user.id,
items: req.body.items,
idempotencyKey: req.get('Idempotency-Key')
});placeOrderは商品を取得し、Domainの価格計算を呼び、在庫確保と注文保存を調整します。商品取得がPostgreSQLなのか、テスト用のメモリ実装なのかは、業務ルール側から見えないようにします。これにより価格計算のテストでHTTPサーバーやDBを立ち上げる必要がなくなり、DB製品を変えてもDomainまで書き換えずに済みます。
ただし、層を増やすほどよいわけではありません。単純な商品参照まで機械的に多数のクラスへ分ければ、処理を追うために何ファイルも移動することになります。境界を置く基準は名前の美しさではなく、独立して変更・検証したい責務があるかどうかです。また、レイヤー名や依存方向には複数の流派があります。ここで示しているのは、業務ルールをHTTPやDBの都合から守るための一例です。
6. データベースは保存場所だけではない
ECサイトのRDBには、少なくともusers、products、orders、order_items、inventoryのようなテーブルが登場します。ordersは誰の注文かをuser_idで示し、order_itemsは注文と商品、購入時点の単価、数量を保持します。商品マスタの価格は将来変わるため、過去注文の明細には注文時点の価格を保存しておく必要があります。
主キーは行を一意に識別します。外部キーは、存在しない注文へ明細を結び付けるといった矛盾をDB側でも防ぎます。payment_request_idなどにUNIQUE制約を付ければ、同じ決済要求を重複登録しないための最後の防波堤になります。アプリケーションのif文だけでは、ほぼ同時に届いた二つの処理がどちらもチェックを通過する可能性があるためです。
インデックスは、全行を順番に調べずに対象へ到達しやすくする構造です。商品IDや注文番号による検索では効果が期待できますが、付けるほど無条件に速くなるわけではありません。PostgreSQLの公式文書も、インデックスをテーブルと同期するためデータ変更へオーバーヘッドが加わると説明しています。PostgreSQL:Indexes Introduction 読み取りの速さと、書き込み・保存容量・保守コストの交換条件を、実際のクエリと実行計画から判断します。
複数の更新をひとまとまりにするのがトランザクションです。注文だけ作成されて明細が保存されなかった、在庫だけ減って注文が存在しない、といった途中状態を残さないために使います。ただし、トランザクションがあれば並行処理を考えなくてよいわけではありません。同じ在庫行を複数の注文が同時に読めば、分離レベルやロック方法によって見える値と待ち方が変わります。SQL標準には四つの分離レベルが定義され、PostgreSQLも並行実行で起き得る現象との関係を説明しています。PostgreSQL:Transaction Isolation
DBは単なるファイル置き場ではなく、データの一意性、参照関係、同時更新時の見え方を保証する層です。何をアプリで検証し、何を制約としてDBにも守らせるかが、壊れにくさを左右します。
7. キャッシュを置けば速くなる、とは限らない
キャッシュは「計算済み・取得済みの結果を、次回すぐ使える場所へ一時保存する仕組み」です。ECサイトでは、ブラウザがCSSや画像を保存するブラウザキャッシュ、各地域で静的ファイルを配るCDNキャッシュ、APIが商品データなどを置くアプリケーションキャッシュを分けて考えます。同じキャッシュという名前でも、所有者と無効化の方法が違います。
アプリケーションでよく使われるCache-Asideは、次の流れです。
async function getProduct(id) {
const cached = await cache.get(`product:${id}`);
if (cached) return JSON.parse(cached);
const product = await productRepository.findById(id);
await cache.set(`product:${id}`, JSON.stringify(product), { ttl: 300 });
return product;
}速くなる代わりに、「DBとキャッシュの値が違う時間」が生まれます。TTLは古い値が残り続ける時間へ上限を設けますが、正しさを自動的に保証する数字ではありません。商品説明なら数分古くても問題にならない場合があります。一方、残り在庫を同じ感覚でキャッシュすると、画面では購入可能なのに注文時には売り切れという差が増えます。表示用の在庫は参考値としてキャッシュできても、注文確定時の判定はRDBなどの正しい更新元でやり直す必要があります。
更新時にDBへ書いたあとキャッシュキーを削除する方法もありますが、途中で削除に失敗すれば古い値が残ります。人気商品のTTLが同じ瞬間に切れると、多数のリクエストが一斉にDBへ向かうキャッシュスタンピードも起きます。ロックによって一つの処理だけが再取得する、TTLへばらつきを加える、期限切れ直後は古い値を返しながら裏で更新する、といった対策があります。Redisの実装ガイドでも、ヒット、ミス、更新時の無効化に加えてスタンピード保護が扱われています。Redis公式ガイド
キャッシュの難しさは、入れることより消すことにあります。対象データがどの程度古くてよいか、正しい値はどこにあるか、キャッシュが停止したらDBへ負荷が集中しないかまで決めて、初めて設計になります。
8. ログイン状態はどこで管理されるのか
HTTPのリクエストは基本的に一回ごとに独立しています。それでもECサイトが「この人はログイン済み」と判断できるのは、リクエストへセッションIDやトークンを付け、サーバー側の認証状態と結び付けているからです。OWASPも、セッションIDまたはトークンが認証済みの状態、HTTP通信、アクセス制御を結び付けると説明しています。OWASP Session Management Cheat Sheet
ここで、認証と認可を分けます。認証は「誰か」を確認する処理です。認可は「その人が何をしてよいか」を確認します。ログインできた利用者でも、他人の注文詳細や管理画面を見てよいわけではありません。GET /orders/:idでは、セッションが有効かだけでなく、その注文の所有者または必要な権限を持つかをサーバーで確認します。
Cookie+サーバーセッション方式では、ブラウザは推測困難なセッションIDをCookieで送り、実際の状態はサーバーやセッションストアに持ちます。署名付きトークン方式では、利用者IDや期限などをトークンへ持たせる場合があります。トークンは「DBを一切見なくてよい魔法」ではありません。無効化、権限変更、漏えい時の対応、更新方法を設計する必要があります。
Cookieには防御を補助する属性があります。HttpOnlyはJavaScriptからCookieを読み取れないようにし、Secureは原則としてHTTPS通信時だけ送信させます。SameSiteはクロスサイト要求でCookieを送る条件を制御します。各属性の正確な挙動はMDNのSet-Cookieリファレンスで確認できます。ただし、どれか一つを付ければ安全になるわけではありません。
XSSでは、攻撃者が用意したスクリプトをページ内で実行させないため、出力先に応じたエスケープ、危険なHTMLのサニタイズ、CSPなどを組み合わせます。OWASP XSS Prevention Cheat Sheet CSRFでは、利用者のCookieが自動送信される性質を悪用した状態変更を防ぐため、フレームワークの保護機能、CSRFトークン、SameSiteなどを検討します。OWASP CSRF Prevention Cheat Sheet
ログイン成功時や権限変更時にはセッションIDを更新し、ログアウト時にはサーバー側でも無効化します。認証情報をどこへ置くかだけでなく、その生存期間と失効経路までが認証アーキテクチャです。
9. カート内の価格を信用してはいけない理由
ブラウザの開発者ツールを使えば、APIへ送るJSONは利用者側で書き換えられます。したがって、注文リクエストにunitPrice: 100やdiscountRate: 0.9が含まれていても、それを正しい価格として保存してはいけません。画面に表示した金額は、利用者へ見せるための情報であって、サーバーが信用できる根拠ではないからです。
クライアントから受け取る情報は、できるだけ「何を、いくつ買いたいか」に限定します。
{
"items": [
{ "productId": "p_123", "quantity": 2 }
],
"couponCode": "SUMMER10"
}APIはproductIdを使って商品マスタを読み直し、販売中か、注文可能な数量か、利用者がクーポンの条件を満たすかを確認します。そのうえで、単価、割引、税、送料をサーバー側のルールから再計算します。カート画面で見せた金額と注文時の金額が変わる可能性があるなら、勝手に確定せず、新しい合計額を示して利用者の同意を取り直す設計も必要です。
入力検証は二段階に分けると理解しやすくなります。一つ目は、文字列の長さ、数値の範囲、必須項目、配列の要素数などを調べる構造上の検証です。二つ目は、「この商品は現在販売中か」「このクーポンをこの利用者が使えるか」「一人5個までという上限を超えていないか」といった業務上の検証です。OWASPも、入力検証は構文上の正しさと意味上の正しさの両方を扱うと説明しています。OWASP Input Validation Cheat Sheet
構造が正しいから安全とは限りません。数量が整数の3でも、一人1個までの商品なら業務上は不正です。また、画面でボタンを無効化しただけでは、その制限を迂回した直接のAPI呼び出しを防げません。割引の適用順序や購入回数制限など、通常のセキュリティ検査だけでは見落としやすい業務ロジックも攻撃対象になります。OWASP Business Logic Security Cheat Sheet
反対に、エラーで内部事情を教えすぎるのも避けます。外部には「注文条件を確認できませんでした」のように安全で対処可能な情報を返し、どの検証で失敗したか、SQLやスタックトレースは権限を制限した内部ログへ残します。ブラウザは便利な表示装置ですが、アーキテクチャ上は常に信頼境界の外側です。
10. 注文・決済・在庫更新が最も難しい

商品閲覧では少し古いキャッシュを返せる場合があります。しかし注文では、「在庫が1個しかないのに二人へ売る」「注文はないのに請求だけ成立する」「再送したら二重に請求する」といった矛盾を避けなければなりません。ここには同時実行、DBトランザクション、外部API、ネットワーク障害が同時に関係します。
在庫1個の商品を、利用者AとBがほぼ同時に注文する場面を考えます。二つの処理がどちらも在庫数1を読み、その後それぞれ0へ更新した場合、注文は二つ作られたのに在庫の減少は一つ分に見える「ロストアップデート」が起こり得ます。対策は一種類ではありません。
方法仕組み向いている状況と注意点悲観的ロックSELECT ... FOR UPDATEなどで対象行をロックし、他の更新を待たせる競合が多く、処理を直列化したい場合。待ち時間とデッドロックへ注意する楽観的ロックversionを条件に更新し、先に変更されていたら失敗させて再試行する競合が比較的少ない場合。衝突時の再試行と利用者への結果設計が必要条件付き更新UPDATE inventory SET stock = stock - :qty WHERE product_id = :id AND stock >= :qtyの更新件数で成否を判定する単純な在庫減算をDB内の一操作で完結できる。複雑な割当条件には別の設計が要る
ロックを使う場合は、複数商品を常に同じ順序でロックするなど、デッドロックを減らす規則を決めます。それでもデッドロックは起こり得るため、DBが片方の処理を中断した場合に安全に再試行できる必要があります。PostgreSQLの公式文書も、明示的ロックは競合を制御できる一方、使い方によってデッドロックを発生させ得ると説明しています。PostgreSQL:Explicit Locking
さらに難しいのが決済です。RDBのトランザクションは自分のDB更新をまとめられますが、通常、外部の決済APIまで同じACIDトランザクションへ参加させることはできません。「注文をINSERTし、決済APIを呼び、在庫を更新してCOMMIT」という長い処理にすると、決済APIの応答を待つ間、DBロックを保持し続ける可能性があります。そして決済成功直後に自分のプロセスが停止すれば、決済会社では成功、自分のDBでは未確定という状態が残ります。
そこで、注文を一回の関数呼び出しではなく、状態を持つ処理として設計します。たとえば、在庫を確保して注文をPENDING_PAYMENTとして保存し、DBトランザクションをいったん確定したあとで決済APIを呼びます。成功を確認できたらPAIDへ、明確な失敗ならPAYMENT_FAILEDへ遷移させます。応答がタイムアウトしただけなら、失敗と断定せずPAYMENT_UNKNOWNまたはPAYMENT_CONFIRMINGとして、Webhookや状態照会で後から確定します。ネットワークのタイムアウトは「相手が処理しなかった」ではなく、「こちらが結果を観測できなかった」ことしか示さないためです。
PENDING_PAYMENT
├─ 成功を確認 ─────────→ PAID
├─ 失敗を確認 ─────────→ PAYMENT_FAILED
└─ 結果を確認できない ─→ PAYMENT_CONFIRMING
├─ 成功確認 → PAID
└─ 失敗確認 → PAYMENT_FAILEDこの状態遷移は、どこからでも自由に書き換えられる文字列ではなく、Domainのルールとして制限します。PAIDからPENDING_PAYMENTへ戻すなど、意味のない遷移を拒否できるからです。在庫確保には有効期限を持たせ、期限までに決済が確定しなければ解放する処理も必要になります。ただし、解放と遅れて届いた決済成功が競合する可能性があるため、期限だけで決済失敗と決めつけず、最終状態を照合します。
注文ボタンの二度押しや通信再送には冪等性を使います。同じ操作を複数回受けても、結果が一回実行した場合と同じになる性質です。クライアントが操作ごとのIdempotency-Keyを送り、サーバーは利用者IDとの組み合わせをUNIQUE制約付きで保存します。同じキーが再び来たら、新しい注文を作らず、最初の注文結果を返します。同じキーなのに商品や数量が違う場合は、別の操作として処理せず競合エラーにします。そのため、キーとともにリクエスト内容のハッシュ、処理中・完了などの状態、返却結果を保存しておくと判断しやすくなります。決済APIへ送るときも対応する冪等キーを使います。StripeのAPIも、POST要求へ冪等キーを付けることで、ネットワークエラー時の再試行による同一操作の重複を避けられると説明しています。Stripe:Idempotent requests
Webhookを受け取る側でも、署名を検証したうえでイベントIDを記録し、処理済みイベントを重ねて適用しないようにします。Webhookは重複することがあり、イベントの到着順も保証されないためです。Stripe:Webhooks 受信したイベントをそのまま最終状態として上書きするのではなく、現在の注文状態と許可された遷移を確認し、必要なら決済APIから最新状態を取得します。StripeのPaymentIntentにもprocessing、succeeded、canceledなど複数の状態があり、決済が一瞬で成功か失敗かに二分できないことが分かります。Stripe:PaymentIntent object
つまり、注文処理で守るべきものは「すべてを一度に成功させること」だけではありません。途中状態を記録し、同じ要求を識別し、結果不明を正しく表現し、後から事実と照合して収束させることです。分散システムでは遅延、部分的失敗、結果の不確実性が通常の設計条件になります。AWS Builders' Library:Challenges with distributed systems
11. メール通知を注文処理から切り離す

注文保存の直後にメールAPIを呼び、その完了までHTTPレスポンスを待つ構成は分かりやすい反面、利用者の待ち時間へメールサービスの遅延が加わります。メール側の一時障害で、注文自体は成立しているのに注文APIがエラーを返すこともあります。利用者が再送すれば、重複注文の入口にもなります。通知は重要ですが、多くの場合、注文成立と同じ瞬間に終わらせる必要はありません。
そこで、注文APIは「通知してほしい」というメッセージをキューへ送り、別のWorkerがメールAPIを呼びます。APIは注文の確定に必要な処理を終えた時点で応答でき、通知側の処理能力はWorkerの台数で独立して調整できます。負荷が一時的に増えても、キューが緩衝材となり、すぐ処理できない分を保持します。
ただし、次のように書くだけでは新しい穴ができます。
await orderRepository.commit(order);
await queue.publish({ type: 'OrderConfirmed', orderId: order.id });DBのCOMMIT直後、キューへ送る前にプロセスが停止すると、注文は存在するのに通知イベントは失われます。逆の順序にすれば、DBのCOMMITに失敗したのに通知だけ送られる可能性があります。異なる二つのシステムを一つのローカルトランザクションでは確定できない問題です。
この隙間を狭める代表的な方法がTransactional Outboxです。注文と同じDBトランザクション内で、ordersとoutbox_eventsの両方へ書き込みます。別のRelayが未送信のOutbox行を読み、キューへ発行し、送信済みとして記録します。少なくとも「注文だけ保存され、発行すべきイベントの記録がない」という状態を避けられます。AWSのパターン解説でも、業務データとOutbox行を同じトランザクションで更新し、別サービスがOutboxからメッセージを送る構成が示されています。AWS Prescriptive Guidance:Transactional outbox pattern
Outboxを使っても、Relayがキューへ発行した直後、送信済みを記録する前に停止すれば、同じイベントを再発行します。また、RabbitMQのようなメッセージブローカーでAcknowledgementを使う構成は、処理途中で接続が切れると配信が再度行われる可能性があります。これは一般にat-least-once delivery、つまり一回以上届く前提です。RabbitMQ:Consumer Acknowledgements and Publisher Confirms
そのためWorker側も冪等にします。たとえば(order_id, notification_type)へUNIQUE制約を持つ送信記録を用意し、すでに完了していれば同じイベントを重ねて処理しません。メールサービスが冪等キーを受け付けるなら、イベントIDを渡して外部の送信も重複から守ります。処理が成功して送信結果を保存したあとにACKし、一時的な通信障害なら回数制限付きで再試行します。
それでも、メールサービスが送信を受け付けた直後、Workerが結果を保存する前に停止すると、結果は不明になります。外部サービスに冪等性の仕組みがなければ、再試行による重複を完全には排除できません。「同じ完了メールがまれに二通届く」ことを許容するのか、送信状況を照会できるサービスを選ぶのかまでが要件です。宛先形式が恒久的に不正など、再試行しても直らないエラーや、上限まで失敗したメッセージはDead Letter Queueへ移し、調査・修正・再投入の対象にします。RabbitMQの信頼性ガイドも、ネットワーク障害や重複配信を考慮し、確認応答を用いてデータ安全性を扱う必要を説明しています。RabbitMQ:Reliability Guide
非同期化は、処理を消しているのではなく、時間と失敗の境界を移しています。キューの滞留件数、最古メッセージの待ち時間、再試行回数、Dead Letter Queueの件数を監視しなければ、「APIは速いがメールが何時間も届かない」状態を見逃します。
12. 障害が起きる前提で設計する
ネットワーク越しの処理は、成功か失敗のどちらかがすぐ返るとは限りません。接続できない、応答が遅い、相手は処理したが返答だけ失われる、といった部分的な失敗があります。外部サービスへ無期限に待ち続ければ、自分のAPIの接続枠やスレッドが埋まり、関係のない要求まで巻き込みます。そのため、DB、キャッシュ、決済、メールなど依存先ごとにタイムアウトを設け、呼び出し全体の期限から逆算します。
再試行は有効ですが、条件なしに行うと障害を悪化させます。読み取りや、冪等キーで保護された操作など、安全に繰り返せる要求に絞ります。待ち時間を指数的に増やすExponential Backoffで短時間の再送集中を避け、さらにランダムな揺らぎであるJitterを加えて、多数のサーバーが同時刻に再試行することを防ぎます。回数と総時間には上限を設けます。AWS Builders' Libraryは、タイムアウト、回数制限付き再試行、Backoff、Jitterを組み合わせ、負荷を一か所へ集中させない考え方を説明しています。Timeouts, retries and backoff with jitter
複数の層がそれぞれ再試行すると、呼び出し回数が掛け算で増えます。ブラウザ、API、決済クライアントが各自で再試行するのではなく、どの層が責任を持つかを決めます。また、再試行できることと冪等であることは別です。サーバーが同じ要求を識別できなければ、再試行は二重処理になります。AWSの冪等APIに関する資料も、呼び出し元が一意な識別子を渡し、同じ要求の再送を区別する方式を解説しています。Making retries safe with idempotent APIs
障害中の依存先を呼び続けないためにはCircuit Breakerも使えます。失敗率などが閾値を超えたら回路を開き、一定期間は呼び出しを即座に失敗させ、回復確認後に閉じます。Retryが一時失敗に対して再び試す仕組みなのに対し、Circuit Breakerは失敗している依存先から自分の資源を守り、回復の時間を与える仕組みです。Azure Architecture Center:Circuit Breaker pattern
こうした制御が働いているかは、観測できなければ分かりません。ログには注文ID、要求ID、処理段階、エラー種別を構造化して残します。メトリクスではリクエスト数、エラー率、レイテンシ、キュー滞留などを時系列で見ます。トレースでは、ブラウザからAPI、DB、外部決済まで、一つの要求がどこで時間を使ったかを関連付けます。OpenTelemetryは、テレメトリ信号としてTraces、Metrics、Logsなどを扱い、Traceを要求がアプリケーション内を進む経路として説明しています。OpenTelemetry:Signals OpenTelemetry:Traces
テストも境界ごとに役割を変えます。価格計算や注文状態遷移は、外部I/OなしのUnit Testで細かく確認します。Repositoryは実際のDBを使うIntegration Testで、制約、ロック、トランザクションを確かめます。決済アダプターは、相手のAPI仕様とのずれをContract Testやテスト環境で検出します。API全体はE2E Testで主要な利用者操作を確認し、キュー処理では重複配信、途中停止、再試行上限、Dead Letter Queueへの移動もテストします。
「正常系が一度通った」は出発点にすぎません。どこで待つのをやめるか、どの処理なら再試行できるか、どの状態から回復するか、何を見れば異常に気付けるかまで説明できて、障害に備えたアーキテクチャになります。
13. アクセスが増えると構成はどう変わるか

公開直後のECサイトなら、アプリケーション1台とRDB1台から始める選択は合理的です。構成が少ないほど、デプロイ、監視、障害調査を単純にできます。重要なのは、その形を小さいから未完成と考えないことと、限界が近づいたときに何を観測するかを決めておくことです。
アクセスが増えたとき、最初に構成図へサーバーを足すのではなく、ボトルネックを測ります。APIのCPU使用率が高いのか、DBクエリが遅いのか、コネクション数が上限なのか、画像転送が帯域を占有しているのかで対策は変わります。平均レイテンシだけでは一部の遅い利用者を見落とすため、p95やp99のようなパーセンタイル、エラー率、要求数、依存先別の時間も確認します。たとえばp95が800ミリ秒なら、観測した要求の95%が800ミリ秒以内に完了し、残り5%はそれより遅かったという意味です。
一台のCPUやメモリを増やす垂直スケーリングは、変更が比較的少なく即効性があります。ただし、選べる機種には上限があり、その一台が停止した場合の影響は残ります。台数を増やす水平スケーリングでは、ロードバランサーの後ろへ複数のアプリケーションを置きます。AWS Well-Architected Frameworkも、水平スケーリングによって一つの共有資源への依存を減らし、一つの障害が全体へ与える影響を小さくする考え方を示しています。AWS:Use horizontal scaling to increase aggregate system availability
ここでアプリケーションをステートレスにしておくと、どのインスタンスが次のリクエストを受けても処理できます。セッション、アップロード途中の情報、冪等キーの記録を一台のメモリやローカルディスクだけに置くと、その台が停止したときに状態を失います。共有すべき状態をRDB、外部セッションストア、オブジェクトストレージなどへ置けば、ロードバランサーは正常な別インスタンスへ要求を送れます。ステートレスなコンピュート資源が水平スケールと障害回復を容易にする点は、AWSの設計原則でも説明されています。AWS:Design interactions in a distributed system to be stateless
ただし、アプリを3台にしただけで処理能力が3倍になるとは限りません。全台が同じRDBへより多くの問い合わせを送れば、ボトルネックがDBへ移るだけです。まず遅いクエリとインデックスを改善し、不要なN+1問い合わせを減らし、コネクションプールを適切に制限します。読み取りが支配的ならRead Replicaへ商品参照などを逃がせますが、レプリケーションには遅延があり得ます。注文直後の注文履歴をReplicaから読むと、まだ表示されない可能性があるため、「書いた直後の読み取りはPrimaryへ送る」などの整合性方針が要ります。PostgreSQLの資料では、待機系を最新状態へ近づける仕組みと、待機系で読み取り専用クエリを実行するHot Standbyが説明されています。PostgreSQL:Log-Shipping Standby Servers PostgreSQL:Hot Standby
静的ファイルや公開商品ページの負荷が大きいならCDN、同じ商品参照がDBへ集中するならキャッシュ、メールや画像処理がAPIの待ち時間を延ばすならキューとWorkerが候補になります。つまり、各要素が解く問題は異なります。
観測された問題主な対策候補新しく増える注意点画像・JS配信が遅い、オリジン帯域が大きいオブジェクトストレージ+CDNキャッシュ期限、無効化、公開範囲APIのCPU・メモリが上限ステートレス化+複数台+ロードバランサーセッション共有、ヘルスチェック、デプロイ方式同じ読み取りがDBへ集中キャッシュ古い値、無効化、スタンピード読み取りクエリがPrimaryを圧迫Read Replica複製遅延、読み取り先の選択後処理でAPI応答が遅いQueue+Worker重複処理、滞留、再試行、DLQ
Auto Scalingも、CPU使用率だけを見て台数を増減すればよいとは限りません。キュー処理なら滞留件数や最古メッセージの待ち時間、APIなら同時実行数やレイテンシが、仕事量をよく表す場合があります。また、急増後に一斉に台数を増やすとDB接続まで急増するため、下流の容量を超えた仕事を一時的に待たせる、または制限するBackpressureも考えます。
スケールとは箱を増やすことではなく、共有されたボトルネックと単一障害点を、必要な場所からほどいていくことです。そしてCDN、キャッシュ、Replica、Queueを加えるたび、データの古さ、重複、監視対象という新しい設計課題も増えます。
14. モノリスとマイクロサービスの選び方
一つのアプリケーションとして開発・デプロイする構成を、一般にモノリスと呼びます。商品、会員、注文、決済のコードが同じリポジトリやプロセスにあっても、内部のモジュール境界が明確なら、それは無秩序なコードと同義ではありません。反対に、サービスを別プロセスへ分けても、全サービスが一つのDBを自由に更新し、変更時に同時リリースが必要なら、境界は名前だけです。
ここでは三つの形を比べます。
観点単純なモノリスモジュラーモノリスマイクロサービスデプロイ単位アプリ全体アプリ全体サービスごとコード境界規則が弱いと混ざりやすいモジュールの公開APIで制限ネットワークAPIやイベントで制限データ更新一つのDBトランザクションを使いやすいモジュール所有を決めつつ同一DBも選べる原則として各サービスが自分のデータを所有障害プロセス障害が全体へ影響しやすい同左だが原因は内部で分離しやすい隔離できる一方、通信障害と連鎖障害が増える運用比較的単純比較的単純デプロイ、監視、通信、認証が複雑向く状況小規模、境界探索中境界を守りつつ速く開発したい独立したチーム・変更・拡張の必要が明確
モジュラーモノリスでは、たとえばcatalog、ordering、paymentsをモジュールとして分け、他モジュールのテーブルを直接更新しない規則を設けます。注文モジュールが商品情報を必要とするときは、Catalogの公開インターフェースを呼ぶか、注文に必要なスナップショットを受け取ります。プロセスは一つでも、責務とデータ所有の境界を先に育てられます。将来サービスを分離するときにも、この境界が候補になります。
マイクロサービスの強みは、注文だけを頻繁にリリースする、検索だけを大きくスケールする、障害を一定範囲へ隔離する、といった独立性です。Azure Architecture Centerは、小さく自律的なサービスがそれぞれ単一の事業能力を実装し、明確なAPIで通信するスタイルとしてマイクロサービスを説明しています。Azure:Microservices architecture style
しかしプロセス内の関数呼び出しをネットワーク通信へ変えると、タイムアウト、再試行、認証、API互換性、分散トレースが必要になります。一つのDBトランザクションで済んだ注文と在庫の更新は、イベントと補償処理を含む長い流れになるかもしれません。サービスごとのログやメトリクスを関連付け、複数のデプロイパイプラインと本番設定を安全に運用する基盤も要ります。Azureの導入評価ガイドも、マイクロサービスにはDevOps、コンテナ、オーケストレーターなどに習熟したチームが必要で、複雑さが利点を上回る場合があると注意しています。Azure:Assess your readiness for microservices
判断するときは、「コードが大きくなったから」だけでは不十分です。次のような圧力が継続しているかを確認します。
複数チームが同じアプリのリリース待ちで頻繁に止まる
特定機能だけ、負荷特性や可用性目標が大きく異なる
障害やセキュリティ上の影響範囲を、プロセスやデータ単位で隔離する必要がある
業務境界とデータ所有者が安定し、サービス間契約を決められる
独立したデプロイ、監視、オンコールを運用できる
これらがまだ曖昧なら、最初からネットワーク境界を増やすより、モジュラーモノリスで依存方向とデータ所有を明確にする方が学びやすく、変更もしやすい場合があります。境界が実際の変更や組織に合わないと分かったとき、同一プロセス内なら修正コストも比較的小さく済みます。
選択の本質は、モノリスかマイクロサービスかというラベルではありません。変更を独立させるために、どれだけの分散システム運用コストを引き受ける価値があるかです。
15. よいアーキテクチャに唯一の正解はない
ここまで見てきたECサイトでも、唯一の完成図は作れません。1日に数十件の注文を受けるサービスと、大規模セールで短時間に注文が集中するサービスでは、同じ商品・注文機能でも必要な構成が違います。扱う金額、停止が許される時間、開発人数、予算、法的・契約上の条件によって、守るべき境界も変わります。
設計を考えるときは、次の順序にすると技術名から入るのを避けやすくなります。
要件を具体化する。 誰が何をするかに加え、どの処理を速くしたいか、何を失ってはいけないか、どの程度の停止やデータの古さを許容できるかを決めます。
責務と正しいデータの場所を決める。 価格を誰が計算するか、在庫の正本はどこか、注文状態を誰が変更できるかを明確にします。
同期・非同期とトランザクション境界を描く。 利用者が待つ処理、後から実行できる処理、一緒に確定できない外部システムを区別します。
失敗を列挙する。 タイムアウト、重複、順序逆転、部分的成功、サーバー停止が起きたとき、どの状態が残り、どう回復するかを決めます。
観測と検証の方法を用意する。 ログ、メトリクス、トレース、アラートと、各境界に合ったテストを設計します。
測定結果から進化させる。 実際のボトルネックや変更の衝突が見えてから、キャッシュ、キュー、Replica、サービス分割を追加します。
設計判断は文章でも残します。大げさな資料でなくても、「背景」「決定」「検討した代案」「利点」「受け入れる欠点」「見直す条件」を一ページに記録すれば、後から来た人が構成の理由を理解できます。たとえば「商品キャッシュのTTLを5分にした」だけでなく、「商品説明は最大5分古くても許容する。在庫確定には使わない。DB負荷が一定値を下回ったら必要性を再評価する」と書けば、判断の前提まで共有できます。
今回の流れを一文ずつたどると、アーキテクチャのつながりが見えます。ブラウザは信用せず、価格をサーバーで再計算する。注文と在庫はDB制約と並行制御で守る。外部決済は結果不明を状態として持ち、冪等キーとWebhookで収束させる。通知はOutboxとQueueで切り離し、重複を前提に処理する。アクセス増加には、測定したボトルネックへだけ構成要素を追加する。コードの責務からインフラの台数まで、すべてが「何を守り、どの失敗を受け入れるか」でつながっています。
よいアーキテクチャは、図が複雑であることでも、最新技術を多く採用することでもありません。現在の要件を満たす最小限の構造があり、重要な判断の理由を説明でき、問題が変わったときに安全に進化できることです。構成図を見たら箱の名前だけでなく、「この境界は何を守っているのか」「この矢印が失敗したら何が残るのか」と問いかけてみてください。そこから、アーキテクチャを自分で考える力が始まります。
