SPA を一つ動かすところまでなら、React で画面を作り、HTTP API を用意し、データベースへ接続すれば形になる。しかし、業務アプリケーションとして継続利用できる状態まで進めると、画面と API の外側に別の設計問題が現れる。利用者を識別する認証、操作可能範囲を決める認可、複数更新を一つの業務操作として確定するトランザクション、後から操作事実を追跡する監査、添付ファイルの保存、メールやイベントの配送、構成管理、テスト、継続的インテグレーション、デプロイである。spa-reference は、これらを一つのトランザクション中心の業務 SPA の中で整合するように組み合わせ、設計判断まで追跡できる形で公開した参照実装である[1]。
同じ React と NestJS を使ったアプリケーションでも、この横断的な責務の置き方によってシステムの性質は変わる。認可をブラウザ側の表示制御だけに置けば、HTTP API を直接呼ぶ経路を防げない。データベース更新の直後にメールを同期送信すれば、保存だけ成功して通知が失敗する状態を考える必要が生じる。HTTP の入力とエラーを実装コードだけで定義すれば、フロントエンドとバックエンドの変更時に、どちらが契約の正本なのかが曖昧になる。個々の技術は同じでも、責務境界と失敗時の意味が違えば、できあがるアーキテクチャは別物になる。
参照実装の価値は、要件から設計、実装、検証までの判断を追跡できることにある。第一に、対象とする要件を定める。第二に、その要件から責務境界、契約、一貫性の範囲を導く。第三に、その構造を具体的な技術へ写し、実装、文書、テスト、CI で同じ判断を維持する。spa-reference を実装だけから見ると、React、NestJS、PostgreSQL、AWS を使った一つの Web アプリケーションに見える。要件、基本設計、詳細設計、ポリシー、OpenAPI、テスト、CI までを一続きの判断体系として読むと、技術名の背後にある「なぜこの境界なのか」「どこまでを保証するのか」という設計思想が見えてくる。
1. 参照実装は判断を保存する
spa-reference の要件定義では、reference という語を意図的に使っている。目的は、既存の技術と横断的な責務を、トランザクション中心の業務 SPA という具体的な条件の下でどう組み合わせるかを示すことにある。この構成は、条件が一致するときに比較と採用の基準として使える参照実装として位置付けている[2]。
この位置付けによって、参照対象はコードそのものから設計判断へ広がる。たとえば現在のバックエンドが一つのデプロイ可能単位であるという実装事実には、要件と基本設計に記録された理由が対応する。単に実装が小さいため一つにまとまる場合もあれば、将来分割するための境界を設けた上で意図的に一つへ保つ場合もある。要件と基本設計に「現在は一つのデプロイ可能とし、将来分離可能な機能境界を保持する」と記録することで、現在の構造を意図された設計判断として再現できる。
同じことは API にも当てはまる。あるコントローラーが 409 を返すとき、それを公開契約として維持する根拠は OpenAPI と詳細設計に置く。OpenAPI に 409 と機械可読なエラーコードを定義し、詳細設計にバージョン競合と状態検証の評価順序まで記録すれば、利用者から観測される振る舞いとして固定できる。公開契約として維持する振る舞いを別の正本で明示することで、実装と仕様の関係を追跡できる。
この立場は、Martin Fowler が best practice の代わりに述べる Sensible Default と近い。Sensible Default は、追加の事情がなければ採用できる既知の出発点であり、条件が変われば再評価される。あらゆる状況に対する最適解を宣言するより、「この制約集合では、この構成を出発点にできる」と示す考え方である[3]。spa-reference の reference も同様に、技術選択を絶対化するより、選択が成立する条件を可視化することに重点を置いている。
参照実装には、ソースコードに加えて上位判断を保持する正本が必要である。コードは「現在どう動くか」を最も直接的に示し、REQUIREMENTS、BASIC_DESIGN、DETAILED_DESIGN、POLICY、OpenAPI は「なぜその構造を維持するのか」「変更時にどの判断を基準にするのか」を保持する。それぞれが異なる種類の変更理由を受け持つことで、要件、設計、実装規則、通信仕様の責務を分けて管理できる。
| 正本 | 保持する判断 | 分離する理由 |
|---|---|---|
| REQUIREMENTS | 対象とするアプリケーション、必要能力、現在対応する範囲、将来構想との境界を保持する。 | 実装方式が変わっても、何を満たすためのシステムなのかという要求を独立して判定できるようにする。 |
| BASIC_DESIGN | コンポーネント責務、依存方向、配置、トランザクション境界、将来分離可能な境界を保持する。 | 個々のクラスや file の形より長く維持するアーキテクチャ上の関係を、実装詳細から分離する。 |
| DETAILED_DESIGN | 認証、認可、競合、失敗時処理、回復、外部副作用など、実装結果を変える意味論を保持する。 | 同じ基本構造でも実装者によって結果が変わり得る処理順序や失敗条件を固定する。 |
| POLICY | 変更時に何を優先し、どの複雑性を増やさず、どこまで検証するかという保守判断を保持する。 | 個別変更のたびに判断基準を作り直さず、将来の変更でも同じ設計原則を適用できるようにする。 |
| OpenAPI | ブラウザから観測できる HTTP のパス、入力、出力、エラー、認証方式を機械可読な契約として保持する。 | React、NestJS、TypeScript などの実装方式から通信契約を切り離し、生成クライアントと検証処理の共通入力にする。 |
正本を分割すると、変更時の判断にも因果関係が生まれる。要求そのものが変わった場合は REQUIREMENTS から設計と実装へ変更が波及する。HTTP の公開仕様だけを変更する場合は OpenAPI と、その契約を実現するバックエンド、生成クライアント、テストが対象になる。実装内部を整理しても公開契約と要求が変わらなければ、それらの正本まで機械的に書き換える必要はない。変更理由ごとに正本を分けることで、変更範囲も制御できる。
これは template との違いにもつながる。template の中心的な価値は、複製して開発を開始できる初期状態にある。参照実装は、完成した構造を観察し、採用条件と設計理由を比較するためにも使われる。spa-reference の要件では、再利用するフレームワークやライブラリの要素と、採用先で変更される template や boilerplate の要素を区別することまで定めている[2]。参照元と同じ構造を永久に維持させることより、どの部分が共通原則で、どの部分が案件固有に変化してよいかを識別できることを優先している。
結果として、spa-reference が保存しているのは「React と NestJS でこう書いた」という実装例だけではない。トランザクション中心の業務 SPA という条件を置き、その条件から責務、一貫性、公開契約、外部サービスとの境界を決め、具体的な技術へ落とし、その対応を文書と検証系で維持するという判断経路そのものを保存している。別の案件で React や AWS を採用しなくても、どの条件を確認してから構造を選ぶかという判断方法は再利用できる。参照実装の寿命を決めるのは特定ライブラリの版より、この判断経路が追跡可能な状態で残っているかどうかである。
2. 要件は適用範囲を狭めて設計判断を可能にする
spa-reference が対象とするのは、対話的で、認証を伴い、状態を更新する業務 Web アプリケーションである。利用者がログインし、フォーム、一覧、詳細画面を行き来しながら、CRUD、業務状態遷移、認可、関係データの更新を継続的に行う種類のシステムを中心に置いている[2]。この適用範囲が、後続するアーキテクチャと技術選定について、何を比較対象にし、どの性質を優先して判断するかを決める入力条件になっている。
「Web アプリケーション」という分類だけでは、設計条件が広すぎる。検索流入を重視する公開コンテンツサイトと、認証後に利用者が業務状態を更新し続ける申請システムでは、同じブラウザ向けアプリケーションでも優先順位が異なる。前者では初期表示、検索エンジンによる索引化、静的配信などが主要な判断材料になり得る。後者では、誰がどのデータを変更できるか、複数の更新をどこまで一体として確定するか、同時操作が競合したときに何を返すか、変更履歴をどのように残すかという問題が中心になる。対象を後者へ限定することで、設計上比較すべき性質が具体化する。
具体的な題材には申請と承認を選んでいる。Requester が申請を作成し、DRAFT から SUBMITTED へ進め、Approver が APPROVED または REJECTED へ遷移させる。Administrator は申請状態と監査履歴を確認する。この業務モデルは小さいが、単純な CRUD より一段多くの設計条件を発生させる。申請は保存後も現在状態によって許可される操作が変わり、実行主体によって権限が変わり、更新結果に応じて監査や通知まで発生するからである。
たとえば SUBMITTED の申請を Approver が承認するとき、確認すべき対象は status に加えて複数ある。まず、その利用者が認証済みである必要がある。次に Approver として操作可能であることを確認する必要がある。そのうえで対象の申請が存在し、利用者にその操作権限があり、ブラウザが取得した後に別の利用者が更新していないことを確認し、現在状態が承認可能であることを判定する。承認が成立すれば、状態変更と監査記録は同じ業務結果として矛盾なく確定し、その後に通知という外部副作用を処理する必要がある。この一つの操作だけで、認証、認可、楽観的同時実行制御、状態機械、トランザクション、監査、外部配送という複数の責務が接続される。
| 要件上の性質 | 設計上発生する問い | 判断を誤った場合に起きること |
|---|---|---|
| 認証済み利用者が操作する | 本人確認とアプリケーション上の権限をどの境界で確定するかを決める必要がある。 | ブラウザが申告した role や未検証の identity を信用すると、画面上の制御とサーバー側の認可が乖離する。 |
| 状態遷移がある | どの状態からどの操作を許可し、複数の失敗条件が同時に成立したときにどの結果を返すかを固定する必要がある。 | 実装箇所ごとに判定順序が異なると、同じ入力でも API の応答や業務結果が変わる。 |
| 同じデータを複数利用者が更新する | 取得後に更新されたデータへの stale 更新をどのように検出するかを決める必要がある。 | 競合を検出しなければ、後から保存した操作が先行する正当な更新を黙って上書きできる。 |
| 複数の関係データを更新する | 状態変更、監査記録、配送 intent など、どこまでを一つのトランザクションとして確定するかを決める必要がある。 | 業務状態だけ更新され監査記録が欠落するなど、一つの操作から相互に矛盾した永続状態が生じる。 |
| 添付ファイルがある | 関係データとバイナリデータの保存責務を分けつつ、一覧、アップロード、ダウンロードの認可をどこで統一するかを決める必要がある。 | 保存先ごとに認可規則が分散すると、メタデータを閲覧できない利用者が実体だけ取得できる経路が生まれ得る。 |
| 通知がある | データベース更新とメールやイベント配送を、異なる失敗境界を持つ処理としてどう接続するかを決める必要がある。 | 業務データの commit と外部配送を直接連結すると、一方だけ成功する二重書き込みの不整合が発生する。 |
| 監査がある | 業務上残す履歴と、障害解析に使う運用ログを異なる情報としてどこへ記録するかを決める必要がある。 | 両者を同一視すると、業務証跡がログ retention に依存したり、運用ログへ不要な業務情報が蓄積したりする。 |
申請と承認という題材は、このような設計判断を最小限のドメインで実際に発生させるために置いている。任意のワークフローを設定できるエンジンまで実装すると、状態定義、管理画面、設定永続化、実行時解釈など別の複雑性が加わる。認可、状態遷移、競合、監査、通知の相互作用を同時に観測するため、DRAFT、SUBMITTED、APPROVED、REJECTED という固定された状態機械を使い、参照実装として確認したい設計問題が現れる範囲へ複雑性を限定している。
適用対象だけでなく、対象外の領域も要件として意味を持つ。検索エンジン向け公開コンテンツが中心のサイト、SSR や SSG を主要要件とするサイト、ネイティブモバイル、データ分析、ストリーム処理、IoT、機械学習基盤は、現在の参照対象の中心から外している[2]。これらを一つの設計で同時に満たそうとすれば、求められる実行モデル、データ特性、配信方式、障害モデルが大きく異なるため、基準となる判断軸そのものが曖昧になる。
将来のマイクロサービス化、Java によるバックエンド実装、複数クラウド対応についても、現在の要件と将来方向を分離している[2]。この区別によって、「将来あり得る」という情報と現在の実装要件を別の確度で扱える。マイクロサービス化を想定して機能境界は残し、現在の要件が一つのデプロイ可能で満たせる間は、サービス discovery や分散トランザクションを将来要件が具体化した段階の選択肢として保留する。将来方向は境界設計へ影響を与え、現在の実行時複雑性は現在要件から決める。
技術選定は、このように適用範囲を狭めた後で初めて比較可能になる。トランザクション中心の業務 SPA という条件が置かれると、SEO を主目的とした rendering strategy より、認証後の操作、関係データの一貫性、状態遷移、競合制御、監査可能性の比重が上がる。状態変更と複数の永続データを整合させる必要があるため関係データベーストランザクションが判断材料になり、ブラウザとバックエンドの責務を安定させるため API 契約が必要になり、外部通知を伴うためデータベーストランザクションと外部副作用の境界を設計する必要が生じる。
要件を深掘りするときは、どの種類の問題を解く参照実装なのかを限定し、その条件から発生する失敗、競合、責務、保証範囲まで明らかにする。spa-reference では、この限定によって後続の React、NestJS、PostgreSQL、OpenAPI、Cognito、transactional outbox などを、それぞれがどの要求を受け持つのかが明示された設計判断として評価できるようになっている。
3. Simplicity is robustness を複雑性予算として読む
spa-reference の最上位ポリシーは「Simplicity is robustness.」である。本稿では、この原則を複雑性予算として読む。この原則は、コード量よりも、システムが保持しなければならない概念、状態、依存関係、副作用、障害経路、運用上の前提の総量を評価対象にする。同じ要件を満たす二つの設計があるなら、制御フローが少なく、状態が少なく、依存関係が少なく、運用時に確認すべき条件が少ない構成を選ぶ。安全性、互換性、文書、検証は維持し、要件に必要な複雑性と将来可能性のために追加される複雑性を区別する[4]。
複雑性は実装時の理解負荷に加え、状態、依存関係、障害経路、運用手順へ波及する。状態を一つ増やせば、その状態へ入る条件、そこから出る条件、不正状態、永続化、復旧、テストが増える。外部依存を一つ増やせば、接続設定、認証情報、タイムアウト、再試行、障害時の扱い、監視、利用不可時の挙動を決める必要が生じる。ネットワーク境界を一つ増やせば、通常系の通信経路に加えて、遅延、切断、部分失敗、再送、重複処理といった distributed system 固有の状態が増える。局所的には小さな追加でも、設計、実装、運用、検証へ波及することで費用が増幅する。
| 追加する構造 | 直接増えるもの | 二次的に増えるもの | spa-reference での判断 |
|---|---|---|---|
| 新しい状態 | 状態値と遷移条件が増える。 | 不正遷移、復旧、永続化、表示、テストの組み合わせが増える。 | 現在の業務要件に必要な DRAFT、SUBMITTED、APPROVED、REJECTED に限定する。 |
| 新しい依存関係 | 設定と API 利用箇所が増える。 | バージョン管理、障害時処理、セキュリティレビュー、CI、運用手順が増える。 | 既存の platform や依存関係が責務を十分に満たす場合はそれを利用する。 |
| ネットワーク境界 | 通信プロトコルとエンドポイントが増える。 | タイムアウト、再試行、部分失敗、認証、観測、重複処理を設計する必要が生じる。 | 同一プロセス内の機能はプロセス内呼び出しとし、現在必要な箇所だけ HTTP 境界を持つ。 |
| 抽象化レイヤー | インターフェース、アダプター、マッピングが増える。 | 実装技術との対応関係を追う認知負荷と変更箇所が増える。 | 現在の責務境界を表す場合に導入する。 |
| 実行時設定 | 設定値と分岐が増える。 | 組み合わせごとの起動条件、誤設定、テストマトリクス、文書が増える。 | 現在必要な実行時モードと機能選択を固定して扱う。 |
複雑性は導入時だけでなく、稼働中も保有コストが続く。サービスレジストリーを追加すれば、導入作業が終わった後も registry 自体の可用性、登録状態、探索失敗を考え続ける必要がある。再試行機構を追加すれば、通信成功率だけでなく、再送による二重実行や backoff 中の遅延も仕様になる。抽象化を増やせば、元のフレームワークと独自レイヤーの双方を理解する必要が生じる。複雑性は実装箇所に閉じず、システムの意味論へ伝播する。
既稿「理解・設計・制度はなぜ単純化へ収束するのか」では、運用によって実際の利用頻度や失敗経路が観測されるほど、使われない分岐や過剰な選択肢が除去され、少数の安定経路へ構造が収束すると論じた[5]。spa-reference では、この考え方を保守後の整理と初期設計の双方に適用している。観測済みの現在要件から責務境界を固定し、その境界の内側に将来の置換余地を残す。
この区別は、将来性を責務境界として確保する考え方である。たとえば将来バックエンド機能をマイクロサービスとして分離する可能性があっても、現在の Requests、Approvals、Attachments、Audit は同じ NestJS プロセス内で動く。一方で、それぞれの責務と依存方向は分ける。現在は関数呼び出しで接続しながら、将来ネットワーク境界を置く余地はモジュール境界と契約に残す。将来変更可能性は、変更理由が異なるものを現在から分けておくことで確保している。
Herbert A. Simon は複雑なシステムについて、比較的自律した subsystem が階層的に結合する構造を分析した[6]。この見方では、複雑性を扱ううえで、内部の詳細を局所化できる単位を持つことが重要になる。David Parnas も、モジュール decomposition の基準を処理手順から変更され得る設計判断へ移し、モジュール内へ知識を隠すことで他のモジュールが持つ内部知識を減らす構造を示した[7]。
spa-reference に当てはめると、将来のマイクロサービス化に備えてサービス数を増やすことより、Requests が Attachments のストレージ実装を知る、Approvals が Cognito API を直接呼ぶ、ドメインオブジェクトが AWS SDK 型を保持するといった知識の漏出を防ぐ方が、変更可能性に直接効く。境界を越える知識が少なければ、後からデプロイ単位を分ける場合も変更範囲を局所化できる。逆に最初からサービスを分割しても、ドメイン知識とプロバイダー固有知識が相互に漏れていれば、ネットワーク境界の数だけ増えて変更容易性は得られない。
この考え方から、spa-reference は将来構想と現在実装を明確に分けている。マイクロサービス、Java、マルチクラウドという方向は requirements に残し、現時点では機能境界の保持に反映する。サービスレジストリー、分散トランザクション調停、動的ルーティング、複数プロバイダーを統一する移植性レイヤーは、実際の要件が具体化した段階で導入する。将来方向は現在の境界を保つ制約として働き、設計上の余地と実行時の複雑性を分けて管理する。
複雑性予算という見方をすると、この判断は費用対効果として説明できる。ネットワーク境界を今追加すれば、その費用は今日から発生する。一方、将来サービス分割が実際に必要になるかは未確定である。責務境界だけを現在作る場合、支払う費用はモジュール decomposition と契約の維持に限定される。将来本当に独立デプロイ、別チーム、個別スケーリング、障害隔離といった要求が生じた時点で、ネットワーク境界に伴う費用を支払えばよい。現在確定している要求と将来の可能性を同じ確度で扱わないことが、複雑性の前払いを防ぐ。
「Simplicity is robustness.」は、要件を満たすために必要な複雑性へ費用を払い、現在説明、検証、運用する状態空間を小さく保つ資源配分の原則である。将来要件への備えは責務境界に残し、状態や依存関係の追加は要件が具体化した時点で行う。spa-reference では、その結果として、一つのデプロイ可能バックエンド、明示された機能境界、固定されたルーティング、必要箇所だけのインフラストラクチャアダプターという構造が選ばれている。単純さは、現在説明し、検証し、運用しなければならない状態空間を小さく保つために使われている。
4. 論理境界を先に作り、物理分散は必要時に行う
現在のバックエンドは、一つの NestJS アプリケーションであり、一つのデプロイ可能単位である。その内部に BFF と Requests、Approvals、Attachments、Audit などの機能境界を置き、同じプロセス内にある機能は通常の関数呼び出しとして接続する。将来リモート実装へ変更できる境界を残しつつ、現在のローカル処理は同一プロセス内の関数呼び出しで接続する[8]。
この構成では、論理的な境界と物理的な分散を別の判断として扱う。Requests と Approvals の責務分離は、変更理由、依存方向、公開する操作を整理する設計判断である。別プロセスへの配置は、通信、配置、監視、障害隔離、独立デプロイまで含む運用上の判断である。責務境界を先に定義し、物理分散は独立運用の必要性が具体化した段階で判断する。
たとえば Approvals が Requests の状態を参照して承認処理を行う場合、同一プロセス内なら、型付きの関数呼び出しとして依存関係を表現できる。これを別サービス化すると、同じ責務境界に対してエンドポイント、リクエストスキーマ、レスポンススキーマ、認証、タイムアウト、再試行、障害時の fallback、可観測性が追加される。さらに、呼び出し先が処理を完了したのに応答だけ失われた場合、再試行による重複実行まで考える必要がある。論理境界を一つネットワーク境界へ変換するだけで、扱う状態空間は大きく増える。
Martin Fowler の Monolith First は、マイクロサービスが有効な状況でも運用上の追加費用があり、境界が明確になる前から分散させると、その費用を早期に負うと論じている。Fowler 自身もこれを経験則として位置付けている[9]。spa-reference も、現在の要件が一つのバックエンドで満たせることを基準に、一つのデプロイ可能単位を現在構成として採用している。
一つのデプロイ可能単位を採用すると、業務操作を同一プロセスと同一データベーストランザクションの中で扱える範囲が広い。Requests の状態更新、Audit の記録、outbox への配送予定登録を一つのトランザクションへ含めることで、サービス間通信や分散トランザクション調停を伴わずに処理を完結できる。直接的には処理系が単純になり、その結果として障害時に確認すべき場所、再試行条件、監視対象も減る。
デプロイ単位は一つでも、バックエンド/src 以下ではリクエスト、approval、添付ファイル、audit などを別の機能として扱い、外部プロバイダーへの接続もインフラストラクチャアダプターに閉じ込める。変更理由ごとの論理境界を先に作ることで、現在の運用費用を抑えながら将来の分離条件を観測できる。
ブラウザ側には BFF を一つ置く。Microsoft の Backends for Frontends pattern は、フロントエンド固有の要求をバックエンド境界に閉じ込められる一方、サービス追加による運用費用や追加ネットワーク通信も考慮すべきと整理している[10]。spa-reference では現在のクライアントが一つの Web SPA なので、BFF も一つで足りる。BFF はブラウザ向け HTTP 境界を担い、内部機能の配置をブラウザへ公開しない。
BFF はプロキシに加えて、ブラウザから見た認証、認可、入力検証、エラー形式、API 契約の境界を一か所に集める。現在 Requests が同じ NestJS プロセス内にあっても、将来別サービスへ移っても、ブラウザは同じ HTTP 契約を使える。BFF は、内部配置の変更に対してブラウザ契約を安定させる境界として機能する。
BFF と機能境界の間では、配置情報を内部に保持する。BFF は「この操作は Requests 機能が担当する」と知り、ブラウザには公開 API だけを示す。内部 route マッピングをデプロイ-time の構成として扱えば、公開 API を維持したまま配置を変更できる。将来性は、契約と配置の分離によって確保される。
| 境界 | 現在の実装 | 現在の実装方針 | 保持している将来余地 |
|---|---|---|---|
| ブラウザとバックエンド | BFF の HTTP API を唯一のブラウザ向け業務境界として明示する。 | ブラウザは BFF の公開契約だけを知る。 | 内部機能の配置を変更しても、ブラウザ向け契約を維持できる。 |
| BFF と内部機能 | ローカル機能は同一プロセス内で呼び出す。 | 同一プロセス呼び出しを使い、分散通信に伴う追加状態を実行時要件から外す。 | 明示された境界を保ったまま remote 実装へ置き換えられる。 |
| 機能間 | Requests、Approvals、Attachments、Audit ごとに責務と依存方向を分離する。 | 業務判断を Requests、Approvals、Attachments、Audit の各機能境界へ配置する。 | 変更理由、負荷特性、チーム、リリースサイクルが独立した時点でサービス分割を検討できる。 |
| データ一貫性 | 現在一つの PostgreSQL 互換の永続化とローカルトランザクションを使う。 | 現在の整合性はローカルトランザクションで扱う。 | 将来 remote 機能が必要になった場合に、整合性境界を再定義する位置が明確になる。 |
| AWS 固有実装 | インフラストラクチャアダプターの内部へ閉じ込める。 | Cognito、S3、SES、SNS の SDK 型はインフラストラクチャアダプター内部に留める。 | プロバイダーやローカル実装をアダプター単位で置き換えられる。 |
BFF と内部機能は現在同一プロセス内の関数呼び出しで接続し、障害モデルをプロセス内に保っている。ネットワーク境界を置くと、呼び出し先だけ停止する、通信だけ失敗する、処理は完了したがレスポンスが失われる、サービス discovery が古い、認証情報が期限切れになるといった状態が追加される。分散によって得る独立性が具体化するまでは、同一プロセスの境界が現在要件に対応する。
逆に、分散が必要になる条件は比較的明確である。機能ごとに独立したスケーリングが必要になる、障害隔離の必要性が高まる、別チームが独立したリリースサイクルを持つ、技術スタックやデータ所有権を分ける必要が生じる、といった要求が現れれば、ネットワーク境界へ移すことで得られる利益が具体化する。その時点で初めて、タイムアウト、再試行、idempotency、サービス認証、可観測性などの複雑性に費用を払う意味が生まれる。
この設計では、「後で分けられるようにする」と「今から分けておく」を意図的に区別している。前者に必要なのは、責務、契約、依存方向、データ所有の候補境界を明確にすることである。後者には、それに加えてネットワーク、デプロイ、操作、failure recovery の仕組みが必要になる。現在の spa-reference は前者までを実装し、後者は実際の要求が発生した時点で判断する。
この考え方は AWS 固有実装にも適用される。アプリケーションコードが S3 クライアントや Cognito token structure を直接扱うと、プロバイダー固有知識が機能内部へ広がる。そこでオブジェクトストレージ、identity、mail、イベント publication をインフラストラクチャアダプターの背後へ置く。AWS モードでは S3、Cognito、SES、SNS を使い、ローカルモードではローカル実装へ差し替える。ここでも将来性をプロバイダー抽象化の数で作るのではなく、現在変更され得る責務だけを境界として固定している。
分散可能性は、変更理由を異なる単位へ分け、その単位の公開契約と依存方向を明確にし、配置情報を外部契約から独立させることで保つ。spa-reference は、一つのバックエンドデプロイ可能の中に将来分離可能な境界を置き、現在の単純さと将来の変更可能性を同時に確保している。
5. トランザクション境界から PostgreSQL と outbox を選ぶ
詳細設計では、申請の状態遷移、監査記録、外部通知の意図、楽観的同時実行制御を個別の実装技法として並べていない。一つの業務操作が成功したとき、どの事実が同時に成立していなければならないかという意味から処理順序を定義している[11]。保存技術を選ぶ前に決めるのは、データをどこへ置くかではなく、どこまでを一つの成功または失敗として扱うかという整合性境界である。
たとえば Approver が申請を承認すると、少なくとも三つの事実が発生する。申請状態が APPROVED へ変わる。誰がいつ承認したかという監査記録が残る。承認されたという事実をメールやイベントとして後から配送する必要が生じる。このうち申請だけが APPROVED になり、監査記録が存在しない状態は業務上の矛盾である。反対に監査記録だけ存在して申請が SUBMITTED のままでも矛盾する。状態変更と監査記録は、同じ業務操作の結果として一緒に確定する必要がある。
spa-reference は、この整合性境界を PostgreSQL のローカルトランザクションへ対応させている。現在の詳細設計では isolation レベルに READ COMMITTED を使い、バージョンを伴う状態変更では、トランザクション内で対象の申請行をロックしてから、リソース固有の認可、バージョン比較、現在状態の検証、更新、バージョンの加算、updatedAt の更新、監査と outbox の記録を行い、最後にコミットする[11]。
READ COMMITTED を選んだだけでは同時更新の意味は決まらない。二人の利用者が同じ申請をバージョン 3 として読み、その後それぞれ承認と却下を実行した場合を考える。先にロックを取得した処理がバージョン 4 へ更新してコミットすると、後からロックを取得した処理は現在のバージョン 4 と自分が送ったバージョン 3 の不一致を検出する。これによって、後着の操作が先行操作を無言で上書きする代わりに CONCURRENCY_CONFLICT として観測される。
PostgreSQL のトランザクション isolation は、並行して実行されるトランザクションが互いの変更をどのように観測するかを規定する[12]。spa-reference では isolation レベルだけへ競合処理を委ねず、行ロックとアプリケーションレベルのバージョンを組み合わせて業務上の stale 更新を明示的に検出する。データベースが同時実行を制御することと、利用者へどの競合を通知するかは別の責務だからである。
バージョンの確認順序も公開される意味を持つ。詳細設計では、バージョンの比較を状態の検証より先に行う[11]。そのため、利用者が古いバージョンを送信し、その時点の最新状態では操作自体も不正になっている場合、返すのは REQUEST_INVALID_STATE ではなく CONCURRENCY_CONFLICT である。これは内部の if 文の並び方ではなく、「利用者が見ていた状態がすでに古い」という事実を先に通知する API 契約である。
バージョンは、変更と見なす操作で一律に増えるわけでもない。現在の設計では、draft 更新、submit、approve、reject で一つ増え、添付ファイルの追加ではリクエストバージョンを変更しない[11]。バージョンが表しているのは単なる更新回数ではなく、申請本体の状態や内容に対する楽観的同時実行制御の基準である。何を競合として扱うかを先に定義し、その意味に合わせてバージョンを更新している。
Prisma 7 は、複数の読み書きを一つのデータベーストランザクションとして実行し、途中で失敗すればロールバックする API を提供する[13]。spa-reference では、PostgreSQL が担うトランザクションとロックの意味を TypeScript のバックエンドから利用するための永続化実装として Prisma を置いている。Prisma を使うこと自体が設計の起点ではなく、「状態変更とその業務記録を同じ atomic 境界へ置く」という設計を実装する手段である。
この境界はデータベース内部までは強く定義できるが、SES や SNS まで同じトランザクションへ参加させることはできない。申請を APPROVED にしてコミットした後、その場で SES を呼び出す実装では、データベース更新だけ成功してメール送信が失敗する経路が残る。外部送信を先に実行すれば逆の問題が生じ、メールは届いたのにデータベースがロールバックして申請は承認されていない、という状態を作り得る。
この二つの書き込みを一つの処理に見せても、実際の原子性は得られない。データベースと外部 API は別々の失敗境界を持ち、一方の成功をもう一方のロールバックで取り消すことはできない。そこで spa-reference は、外部配送そのものではなく「この業務結果を配送する必要がある」という意図までをデータベーストランザクションへ含める。
AWS Prescriptive Guidance の transactional outbox pattern も、業務データと配送予定を同じトランザクションへ書き込み、そのコミット後に別の処理が外部システムへ配送することで二重書き込みの不整合を分離する[14]。spa-reference では REQUEST_SUBMITTED、REQUEST_APPROVED、REQUEST_REJECTED の各状態遷移について、同じデータベーストランザクション内に EMAIL と EVENT の二つの outbox 行を作る[11]。リクエスト creation、draft 更新、添付ファイル addition では通知要件がないため outbox 行を作らない。
| 処理 | 保証する範囲 | 同じ境界へ置く理由 | 失敗時の意味 |
|---|---|---|---|
| 申請状態更新 | DB トランザクション内で原子的に確定する。 | 業務操作そのものなので、監査や配送意図と食い違った状態を確定させないためである。 | トランザクションが失敗すれば状態変更も確定しない。 |
| 監査イベント | 申請状態更新と同じ DB トランザクションで確定する。 | 実際には発生していない操作の監査記録や、記録のない状態変更を残さないためである。 | 状態更新がロールバックすれば監査イベントもロールバックする。 |
| outbox の配送意図 | 業務状態と同じ DB トランザクションで確定する。 | 外部配送が一時的に失敗しても、配送すべき事実そのものを失わないためである。 | コミット後はワーカーが配送を再試行できる。 |
| SES と SNS への配送 | DB コミット後の別処理として実行する。 | 外部プロバイダーは PostgreSQL トランザクションへ参加せず、独立した失敗境界を持つためである。 | 失敗しても業務コミットは維持され、outbox の状態と再試行規則で回復する。 |
outbox を置くだけでは、配送処理の競合と障害回復は解決しない。複数のバックエンドタスクが同時にワーカーを実行する場合、同じ行を同時に取得すると二重配送が増える。そこで現在の詳細設計では PostgreSQL の FOR UPDATE SKIP LOCKED に相当する行ロックを使い、短い claim トランザクションの中で配送対象を PROCESSING へ移す。claim 時には attempt_count を増やし、ランダムな claim_token、claimed_at、60 秒後の claim_expires_at を記録してからトランザクションをコミットする[11]。
外部 API 呼び出しは、この claim トランザクションをコミットした後に行う。SES や SNS の応答を待っている間ずっとデータベース行をロックすると、外部サービスの遅延が DB トランザクションの長時間化へ直結するからである。現在のプロバイダー呼び出しタイムアウトは最大 15 秒で、claim lease はそれより長い 60 秒に設定されている[11]。短い DB トランザクションと長い外部 I/O を分離することで、関係データベースのロック時間をプロバイダーの応答時間へ従属させない。
配送成功後も、ワーカーは自分が取得した claim_token がまだ行に残っている場合だけ DELIVERED へ更新する。ワーカーが停止し、lease が切れ、別のワーカーが同じ行を再取得した後に古いワーカーが復帰した場合、古い処理が新しい claim の状態を上書きしないためである。この token による所有確認まで含めて、複数ワーカーと障害回復が同時に存在する場合の状態遷移を定義している。
失敗した配送は無制限には再試行しない。attempt_count が 5 未満なら PENDING へ戻して次回試行時刻を設定し、5 回目の失敗後は FAILED として自動再試行を停止する[11]。ここにも「再試行すればそのうち成功する」という曖昧な期待ではなく、システムが自動的に責任を持つ範囲を有限にする考え方が現れている。
それでも正確に一回 delivery は成立しない。プロバイダー呼び出しが成功した直後、outbox 行を DELIVERED へ更新する前にプロセスが停止すると、lease の失効後に同じ配送が再実行される可能性がある。詳細設計はこれを expected 少なくとも一回振る舞いとして明示している[11]。SNS イベントには eventId を重複排除 key として含めるため downstream consumer 側で重複排除できるが、email について正確に一回の保証は置いていない。
この保証範囲の限定は、transactional outbox を採用する理由そのものと一致する。目的は、データベースと外部プロバイダーを一つの分散トランザクションのように見せることではない。データベース内部では状態変更、監査、配送意図を原子的に確定し、その外側では少なくとも一回の配送と有限回の再試行を提供する。それぞれの基盤が実際に保証できる範囲へ責務を分けることで、障害時にも何が確定済みで、何が再実行され得るかを説明できる。
PostgreSQL、Prisma、楽観的同時実行制御、transactional outbox は、独立した流行技術として集められたものではない。申請の状態変更と監査を一つの業務結果として確定したい、同時更新による silent overwrite を防ぎたい、外部通知の失敗によって業務コミットを巻き戻したくない、配送要求そのものは失いたくないという要求を順に分解すると、それぞれの役割が現れる。技術名より先に整合性境界を定義し、その境界ごとに成立する保証を割り当てることが、spa-reference の永続化と通知設計の中心になっている。
6. 認証と API 契約は実装フレームワークから切り離す
spa-reference のブラウザ向け API は OpenAPI 3.1 を正本としている。OpenAPI Specification は HTTP API の path、method、parameter、リクエストボディ、レスポンス、セキュリティ scheme などを、特定の実装言語から独立した形で記述する仕様である[15]。この性質を利用すると、React と NestJS が同じ TypeScript を使っていても、両者の通信契約を TypeScript の内部型そのものへ依存させずに済む。
同じ言語を使うフロントエンドとバックエンドでは、TypeScript の型を直接共有する方法も取れる。短期的には記述量を減らしやすいが、共有型が何を意味するかを区別する必要がある。バックエンド内部のクラスは永続化やフレームワークの都合で変化する。フロントエンドの表示用 model も画面構成に応じて変化する。一方、HTTP で外部へ公開したリクエスト、レスポンス、status コード、error コードは、通信相手が依存する契約である。寿命と変更理由の異なる三種類の型を同じものとして扱うと、内部実装の変更が通信仕様へ波及しやすくなる。
spa-reference は、この境界を OpenAPI に置いている。HTTP の path、method、スキーマ、status コード、Problem Details、認証方式を OpenAPI へ定義し、フロントエンドが利用する API クライアントはその契約から生成する[8]。バックエンドが NestJS で実装されていることは現在の実装選択であり、ブラウザ向け契約の成立条件にはしない。この分離によって、バックエンドの一部を将来 Java や別のフレームワークへ移しても、同じ OpenAPI 契約を満たす限りブラウザ側から見た境界を維持できる。
| 境界 | 正本または判断主体 | 切り離しているもの | 得られる性質 |
|---|---|---|---|
| Browser とバックエンドの HTTP | OpenAPI 3.1 | React の表示型と NestJS の内部クラスを通信仕様から分離する。 | 実装言語やフレームワークを変更しても、公開 API の互換性を独立して判断できる。 |
| Frontend API クライアント | OpenAPI から生成するクライアント | 人手で重複定義したリクエストとレスポンス型を分離する。 | 契約変更とクライアント側の型を同じソースから同期できる。 |
| 利用者の本人確認 | AWS モードでは Cognito | password や認証プロトコルの実装を業務機能から分離する。 | アプリケーションは検証済み identity を受け取った後の業務判断へ集中できる。 |
| 業務上の認可 | Backend アプリケーション | identity プロバイダーのグループ情報と個々のリソース操作条件を分離する。 | 誰であるかと、そのリソースに何を実行できるかを別の規則として管理できる。 |
| AWS 固有の token 検証 | Identity アダプター | JWKS、Cognito グループ、プロバイダーエンドポイントを業務機能から分離する。 | Requests や Approvals はプロバイダー固有型ではなくアプリケーション上のアイデンティティと role を使える。 |
現在の OpenAPI 契約は、正常系の JSON スキーマだけを記述しているわけではない。Request の DRAFT、SUBMITTED、APPROVED、REJECTED、Requester、Approver、Administrator という role、主要な HTTP status、CONCURRENCY_CONFLICT、REQUEST_INVALID_STATE、FORBIDDEN、IDENTITY_PROVIDER_UNAVAILABLE などの安定したアプリケーション error コードも公開契約に含めている[11]。失敗もクライアントが観測するインターフェースである以上、成功時のレスポンスと同じように契約として扱う。
さらに、複数の失敗条件が同時に成立した場合の評価順序まで意味を持つ。現在の設計では、認証、粗い role 判定、構造検証、リソース検索、リソース単位の認可、バージョン、状態という順序を定めている[11]。たとえば caller が古いバージョンを送信し、現在のリクエスト状態もその操作を許さない場合、バージョン競合を先に評価することで CONCURRENCY_CONFLICT を返す。この順序が実装者ごとに変わると、同じリクエストに対する外部挙動が変わるため、内部処理順序の一部が公開意味論になる。
契約を OpenAPI へ独立させることには、検証上の効果もある。バックエンドが動作していても OpenAPI と異なるレスポンスを返せば契約違反であり、フロントエンドがコンパイルできても生成クライアントが古ければ正本との同期が崩れている。spa-reference の CI が OpenAPI 検証と API クライアント freshness を別々に検証するのは、この二つが TypeScript の型検査だけでは保証されないためである。言語内の型整合性と、HTTP 境界の契約整合性を別の検査対象として扱っている。
認証についても、ブラウザフレームワークから切り離したプロトコル境界を置いている。ブラウザ SPA はパブリッククライアントであり、第三者に知られてはならないクライアント secret を安全に保持する実行環境を持たない。RFC 7636 は認可コード interception への対策として PKCE を定義している[16]。OAuth 2.0 Security Best Current Practice である RFC 9700 も、パブリッククライアントが認可コード grant を利用する場合に PKCE を用いることを要求している[17]。Amazon Cognito は Authorization Code grant と PKCE を組み合わせる方式を提供している[18]。
AWS モードの SPA は Cognito へ Authorization Code + PKCE で認証を委ねる。ブラウザが業務 API を呼ぶときには bearer access token を送り、バックエンドがその token を検証する。ID token を API 認可の証拠として流用する構成にはしていない。access token の署名や issuer など、Cognito が発行した token として必要な条件をアイデンティティ境界で確認した後、Cognito グループから Requester、Approver、Administrator というアプリケーション上のロールを導く[11]。
ここで role をブラウザから申告させないことが認可境界を保つ。画面側では role に応じて navigation や button を表示できるが、その表示制御は利用者体験のためのものであり、業務操作を許可する最終判断にはならない。利用者が HTTP リクエストを直接作成すればフロントエンドの表示制御は通過できるため、バックエンドが毎回 verified identity とリソースの関係から認可を判定する必要がある。
認証と認可も同じ判断としてまとめていない。Cognito が証明するのは、token がどの identity に対して発行され、どのグループに属しているかという情報である。アプリケーションが判断するのは、その identity が現在のリクエストに対して何を実行できるかである。Requester role を持っていても、別の Requester が所有するリクエストを自由に編集できるわけではない。Approver role を持っていても、リクエストが DRAFT なら承認操作は成立しない。role は認可判断の入力の一つであり、リソース所有権、current 状態、操作などと組み合わせて最終的な許可を決める。
この分離によって、identity プロバイダー固有の情報が業務機能へ広がることも防いでいる。cognito:groups、JWKS の取得、Cognito エンドポイントとの通信といった処理はアイデンティティアダプターの責務に置かれる。Requests や Approvals が必要とするのは、検証済みの subject、アプリケーション上のロール、必要な場合の verified email といったアプリケーション上の identity である。業務コードが Cognito SDK や token claim の構造を直接解釈する必要はない。
ローカルモードが成立するのも、この境界があるためである。ローカルデモでは Cognito へ redirect せず、固定されたデモ token をローカルアイデンティティアダプターが Requester、Approver、Administrator へ対応付ける。一方、その後のバックエンド認可、HTTP 契約、リソース所有権、状態遷移は AWS モードと同じアプリケーション意味論を使う。認証プロバイダーを置き換えても業務規則を変えずに済むこと自体が、プロバイダー境界が実装上機能していることの確認になる。
OpenAPI とアイデンティティアダプターは異なる対象を扱っているが、設計原則は共通している。OpenAPI は HTTP 契約を React、NestJS、TypeScript から切り離し、アイデンティティアダプターは本人確認を Cognito 固有処理からアプリケーションロジックへ漏らさない。どちらも「現在使っているフレームワークやプロバイダー」を、そのまま長期契約へ昇格させないための境界である。
この構造によって、spa-reference の公開契約と業務規則は、現在の実装技術より長い寿命を持てる。React、NestJS、Cognito は現在の参照基準を構成する具体的な選択である。一方、どの HTTP リクエストが成立し、どの error が返り、誰がどのリソースを操作できるかという意味は、それらの製品名から独立して定義される。実装技術を選ぶことと、その技術へアプリケーション契約を従属させることを分ける点が、この認証と API 設計の中心にある。
7. ローカルファーストはアーキテクチャを検査する
spa-reference の最初の実行経路は AWS デプロイではなくローカルデモである。clone、リポジトリ依存関係の導入、ローカルデモの起動、ブラウザで代表的なワークフローを操作、成功確認という順序で進み、実行時には AWS アカウント、AWS 認証情報、Cognito、S3、SES、SNS、API key、手作業で用意した環境変数ファイルを要求しない[2]。最初に確認する対象をクラウド構築ではなくアプリケーションそのものへ置くことで、業務機能と AWS 固有構成を別々に評価できる。
ここでいうローカルは、完全なオフライン実行を意味しない。最初の git clone、npm ci、PostgreSQL コンテナイメージの取得にはネットワークが必要になる。これらは実行環境を取得するための依存関係取得であり、起動後の業務処理が外部サービスへ依存することとは性質が異なる。必要な artifact が揃った後は、一台の開発機上で SPA、バックエンド、PostgreSQL、添付ファイル保存、メール記録、イベント記録まで完結する。この区別によって、「ネットワークを一度使うこと」と「アプリケーション実行時が外部プロバイダーの可用性へ依存すること」を混同せずに済む。
ローカルモードと AWS モードの差は、外部インフラストラクチャアダプターへ限定されている。Cognito の代わりに固定されたデモ identity、S3 の代わりにローカルファイル保存、SES と SNS の代わりにローカル配送記録を使う。一方、ドメインとアプリケーションロジック、HTTP API、データベースモデル、マイグレーション、認可、transactional outbox は共通である[8]。APP_MODE はプロセス起動時にアダプター set を選び、その後の業務コードが処理ごとに AWS かローカルかを判断する構造にはしていない。
この構成では、ローカルモードがアーキテクチャ境界の検査として機能する。もし Requests 機能が Cognito SDK を直接呼び、Attachments が S3 クライアント型をドメインモデルに保持し、Approval 処理が SNS へ直接発行していれば、AWS を外した時点で業務ロジックそのものを書き換える必要が生じる。現在の実装ではプロバイダー固有処理をアダプターの内側へ閉じ込めているため、AWS アダプターをローカルアダプターへ交換しても、申請作成、提出、承認、却下、認可、監査、outbox という業務意味論を維持できる。
逆に、ローカルモードを成立させるために業務コードへ多数の条件分岐が必要になれば、それはローカル対応の問題というより、インフラストラクチャ境界がアプリケーション層へ漏れている兆候になる。たとえば業務サービス内に「APP_MODE がローカルならファイルへ書き、aws なら S3 へ送る」という分岐が現れれば、業務サービス自身がストレージプロバイダーを知っていることになる。アダプターの選択を bootstrap に閉じ込めれば、業務処理は「オブジェクトストレージへ保存する」という責務だけを知り、プロバイダーの違いを意識しなくて済む。
| 責務 | ローカルモード | AWS モード | 共通して維持する意味 |
|---|---|---|---|
| Identity | 固定デモ token をローカルアイデンティティアダプターが Requester、Approver、Administrator へ変換する。 | Cognito access token を検証し、グループからアプリケーション上のロールを導く。 | 業務機能は検証済み identity とロールを受け取り、リソース単位の認可をバックエンドで判定する。 |
| 関係データ | Docker Compose 上の PostgreSQL 17 を使う。 | Aurora PostgreSQL-compatible データベースを使う。 | スキーマ、マイグレーション、transaction、行ロック、楽観的同時実行制御、outbox の意味を共有する。 |
| 添付ファイル本体 | ローカルファイル型アダプターを使う。 | S3 アダプターを使う。 | 業務コードはオブジェクトストレージ境界を通じて保存し、一覧、アップロード、ダウンロードの認可規則を共有する。 |
| メール | 配送内容をローカルファイルへ記録する。 | SES へ配送する。 | outbox から commit 後に配送し、業務トランザクションと外部副作用を分離する。 |
| イベント公開 | イベントをローカルファイルへ記録する。 | SNS へ発行する。 | 同じ outbox イベントペイロードと配送ライフサイクルを使う。 |
| HTTP API | Vite が /api をローカルバックエンドへプロキシする。 | ALB が /api/* をバックエンドサービスへ振り分ける。 | ブラウザ向け OpenAPI 契約とアプリケーションエラーの意味論を共有する。 |
データベースだけはローカルモードでも PostgreSQL をそのまま使う。ここで SQLite やメモリ内 DB へ置き換えると、起動は軽くできても、参照実装として確認したい性質が変わる。spa-reference は READ COMMITTED、行ロック、楽観的同時実行制御、マイグレーション、transactional outbox といった PostgreSQL の関係データベースの意味論を設計に利用している[11]。ローカルモードでも PostgreSQL を使うことで、画面だけ動く簡易デモではなく、本番側と同じデータベースモデルと concurrency 意味論を実際に通せる。
PostgreSQL 17 は Docker Compose で起動する。Docker Compose は複数のサービス、ネットワーク、volume などを構成として定義し、同じ条件で起動する仕組みを提供する[19]。現在の compose.yaml は PostgreSQL 17 を使い、コンテナ側の 5432 をホストの 127.0.0.1:55432 へ公開し、永続 volume とヘルスチェックを定義している[20]。利用者の OS に PostgreSQL 本体、データベース、user、ポート、data directory を個別設定させる代わりに、ローカルデモが必要とするデータベース条件だけをリポジトリ側で固定している。
一方、React と NestJS まで Docker Compose の中へ入れて開発する構成にはしていない。フロントエンドとバックエンドはホスト上の Node.js プロセスとして起動し、外部依存である PostgreSQL だけをコンテナ化する。すべてをコンテナ化すればプロセス起動条件を統一できる反面、ソース change の反映、debug、ポートマッピング、volume、コンテナライフサイクルなど、通常の Node.js 開発には不要な層まで日常の開発経路へ入る。spa-reference では、再現性を得たい対象をデータベースに限定し、それ以外は通常の Node.js toolchain を直接使う。
この判断の目的は、固定すべき条件と、開発者が直接扱った方が単純な条件を分けることにある。PostgreSQL はバージョン、初期データベース、ポート、ヘルスチェックを揃える意味が大きい。フロントエンドとバックエンドは package-lock.json、Node.js バージョン、npm workspace によって依存関係を固定できるため、さらにコンテナ実行環境を挟む必然性が低い。同じ再現性という目的でも、責務ごとに必要な仕組みを選んでいる。
ローカルデモの起動自体もアーキテクチャ検証の一部になっている。npm run デモは Node.js、Docker CLI、Docker Compose v2、Docker daemon へのアクセス、利用する loopback ポートを事前に確認し、PostgreSQL が正常になるまで待ち、Prisma クライアントの生成とマイグレーションを実行し、バックエンドとフロントエンドを起動した後、それぞれの応答を確認してから ready message を出す。単にプロセスを spawn できた時点では成功とせず、ブラウザから操作を開始できる状態まで到達したことを起動成功の条件にしている。
アプリケーションの待受先もローカルデモでは loopback インターフェースに限定される。開発機上で試すための固定デモ identity と、認証を省略したローカルアダプターを持つ以上、LAN 上へ公開する必要はない。ローカルファーストは、一人の開発者が安全にアーキテクチャと代表的なワークフローを検証するための実行経路として設計されている。
クラウドエミュレーターを丸ごと導入していないことも同じ判断から説明できる。Cognito、S3、SES、SNS の API をローカルで模倣する環境を追加すれば、AWS モードと似た API 呼び出しを実行できる一方、その emulator 自体のバージョン、設定、対応 API、起動順序、永続状態を管理する必要が生じる。ローカルデモの目的が AWS API の互換性試験なら意味があるが、spa-reference が最初の実行経路で確認したいのは、業務ロジック、HTTP 契約、データベース意味論、認可、outbox がプロバイダーから独立して成立することである。
そのため、AWS の形をローカルへ複製するのではなく、アプリケーションが必要とするポートに対して最小のローカルアダプターを実装する。メールなら送信結果を記録し、イベントなら発行内容を記録し、添付ファイルならローカル data area へ保存する。この方法ではプロバイダー API の再現性は検証対象から外れるが、業務操作がどの副作用を要求したかは観測できる。ローカルモードが保証する範囲を明確にし、その範囲に必要な仕組みだけを持つ構成である。
ローカルファーストの価値は、起動が簡単という利用者体験と、境界が正しく切れているかという設計検査が同じ仕組みで成立する点にある。AWS アカウントを用意しなくても代表的な業務フローを最後まで実行でき、その過程で本番と同じ HTTP、認可、状態遷移、PostgreSQL transaction、マイグレーション、outbox を通る。クラウド固有の部分だけをアダプターとして交換できるなら、アプリケーションアーキテクチャがプロバイダーの具体的な API から独立していることを実行結果として確認できる。
この意味でローカルファーストは、どの処理がアプリケーションの本質で、どの処理がデプロイ環境に属するかを可視化する試験装置である。ローカルで置き換えられるものと、同じ意味を維持するために置き換えてはいけないものを分けることで、spa-reference の責務境界そのものを実行可能な形で検証している。
8. 技術スタックは要件と判断の対応表として読む
現在のリポジトリは Node.js 24 と npm workspaces を基盤にし、フロントエンド、バックエンド、再利用 UI、OpenAPI 生成クライアント、インフラストラクチャを一つのモノレポとして管理する[21]。フロントエンドは React 19.3 系、React Router 8.4 系、Vite 8.3 系、TypeScript 5.9.3、Vitest 5 系を宣言している[22]。バックエンドは NestJS 12.1 系、Prisma 7.10.0、PostgreSQL driver、AWS SDK v3、jose、pino、zod、Vitest 5 系などを宣言している[23]。インフラストラクチャは AWS CDK v2 と constructs を使う[24]。
この一覧だけなら、一般的なフルスタック Web アプリケーションの技術紹介で終わる。設計上見るべきなのは、各技術がどの要件を引き受け、その選択がどの程度まで要件から拘束されているかである。PostgreSQL のようにトランザクション、一貫性、行ロックという要求から強く方向付けられるものもあれば、React や NestJS のように複数の妥当な候補から参照実装の基準として一つを採用したものもある。さらに Vite、Vitest、Redocly CLI のように、アーキテクチャそのものより、そのアーキテクチャを実装、検証するための道具として選ばれた技術もある。
この三種類を区別すると、「このリポジトリで採用している」という事実を、その選択が成立する条件と結び付けて読める。spa-reference の reference という位置付けは、採用事実の一般化を抑え、要件から強く導かれる判断、複数候補から採用した基準実装、内部を具体化する実装技術を分ける。別案件で何を維持し、何を置き換えるかは、この区分から判断できる。
| 技術・方式 | 現在の役割 | 判断根拠 | 選択の性質 |
|---|---|---|---|
| Node.js 24 | workspaces 全体の実行基盤を統一する。 | フロントエンドの開発ツール、バックエンド、補助スクリプト、CDK を同じ JavaScript / TypeScript の道具立てで扱える。 | リポジトリ基準として固定した実装選択である。 |
| npm workspaces | フロントエンド、バックエンド、UI、API クライアント、infra を一つの依存関係グラフで管理する。 | 一つの参照アプリケーションを構成する複数成果物を、同一コミットと CI の中で整合させられる。 | モノレポ方針を具体化する実装選択である。 |
| React 19 | ブラウザ SPA の画面、コンポーネント、状態を実装する。 | 要件が対話的な SPA を指定している。React 自体は、その要件を満たせる複数候補の一つである。 | 参照基準として採用したフロントエンドフレームワークである。 |
| TypeScript 5.9 | フロントエンド、バックエンド、インフラストラクチャを型付きで実装する。 | 一つのリポジトリ内で型検査と開発環境を共通化できる一方、公開 HTTP 契約は OpenAPI へ分離できる。 | リポジトリ全体を貫く実装言語の選択である。 |
| Vite 8 | SPA の開発サーバーとビルドを担当する。 | フロントエンドビルドの責務を担い、アプリケーションアーキテクチャへ追加の独自フレームワークを導入しない。 | フロントエンドツールの実装選択である。 |
| NestJS 12 | BFF、controller、依存関係 injection、バックエンド composition を担当する。 | バックエンドフレームワークが持つ通常のモジュール、controller、DI を利用し、その上へ別の独自フレームワークを重ねない。 | 参照基準として採用したバックエンドフレームワークである。 |
| OpenAPI 3.1 | ブラウザ向け HTTP 契約の正本となる。 | フロントエンドとバックエンドの実装言語から公開契約を独立させ、生成クライアント、検証、契約テストの共通入力にする。 | アーキテクチャ境界を固定する選択である。 |
| PostgreSQL 17 | ローカルデモとテストで本番と同系統の関係データベース意味論を提供する。 | 状態遷移、監査、outbox、行ロック、楽観的同時実行制御、マイグレーションを同じ関係データベースの意味論で扱う必要がある。 | トランザクション要件から強く方向付けられた選択である。 |
| Prisma 7 | スキーマ、マイグレーション、データベースアクセスを TypeScript バックエンドから実装する。 | PostgreSQL スキーマとマイグレーションをリポジトリ管理下に置き、永続化コードをバックエンドの型付き実装へ接続する。 | 永続化層を具体化する実装選択である。 |
| Transactional outbox | 業務コミット後に必要な email とイベントの配送意図を保持する。 | データベース書き込みと外部配送の二重書き込みを、一つの原子操作であるかのように扱わず、保証境界を分離する。 | 整合性要件から導かれるアーキテクチャパターンである。 |
| Cognito + PKCE | AWS モードの本人確認とブラウザ認証を担う。 | SPA のパブリッククライアントの認証プロトコルを identity プロバイダーへ委ね、アプリケーションは検証済み identity を用いた認可へ集中する。 | セキュリティプロトコルと AWS アイデンティティアダプターの組み合わせである。 |
| S3 | 添付ファイル本体を保存する。 | 関係メタデータと binary オブジェクトの保存責務を分け、オブジェクトストレージ境界の内側へプロバイダー固有処理を閉じ込める。 | AWS インフラストラクチャアダプターの選択である。 |
| SES | email を配送する。 | outbox ワーカーから外部副作用として実行し、業務トランザクションと配送失敗を別の境界で扱う。 | AWS インフラストラクチャアダプターの選択である。 |
| SNS | 業務イベントを発行する。 | 業務コミット後のイベント配送をアプリケーションプロセスの外へ渡し、consumer 側へ重複排除 key を提供する。 | AWS インフラストラクチャアダプターの選択である。 |
| Docker Compose | ローカル PostgreSQL を既知の条件で起動する。 | OS ごとの PostgreSQL 導入差をコンテナに閉じ込め、アプリケーションプロセス自体はホスト上で直接動かす。 | ローカル開発経路を具体化する選択である。 |
| Vitest | TypeScript workspaces の自動テストを実行する。 | フロントエンド、バックエンド、ライブラリのテストランナーを既存の TypeScript 開発環境へ統合する。 | テスト実行系の実装選択である。 |
| React Testing Library | フロントエンドの利用者から観測できる振る舞いを検証する。 | コンポーネント内部構造より、画面操作、表示、状態変化をテスト対象へ置く。 | フロントエンドテストの観測方法を具体化する選択である。 |
| Supertest | NestJS の HTTP 境界を統合的に検証する。 | BFF のリクエスト/レスポンスを、controller 単体より実際の HTTP インターフェースに近い位置で確認する。 | バックエンド integration テストの実装選択である。 |
| Redocly CLI | OpenAPI ソースを CI で検証する。 | API 仕様を閲覧用文書だけで終わらせず、機械検証可能な成果物として扱う。 | 契約検証ツールの選択である。 |
| AWS CDK v2 | AWS インフラストラクチャを TypeScript で定義する。 | アプリケーションと同じリポジトリでインフラストラクチャ定義をレビュー、typecheck、synth の対象にする。 | Infrastructure as Code を具体化する実装選択である。 |
Node.js と TypeScript をリポジトリ全体へ通したことには、単なる言語統一以上の効果がある。フロントエンド、バックエンド、補助スクリプト、AWS CDK が同じ実行環境の系統に入るため、一つのプルリクエストで API 契約、生成クライアント、バックエンド、フロントエンド、インフラストラクチャを変更し、同じ CI から検証できる。複数成果物の変更を同じリポジトリ境界に閉じ込めることが、この構成の狙いである。
npm workspaces も、このモノレポの意味と対応している。フロントエンドが @spa-ref/api-クライアントと @spa-ref/ui を利用し、バックエンドと infra も同じルートのロックファイルの管理下に入る。OpenAPI を変更して生成クライアントが変わり、その結果フロントエンドの型検査が失敗するなら、その不整合を同じ変更単位の中で検出できる。リポジトリを複数に分割すると、契約変更にバージョン発行、依存更新、複数プルリクエストの順序制御が必要になる。現在その運用上の独立性を要求していないため、一つのモノレポの方が扱う状態数が少ない。
AWS デプロイは、通信経路、秘密情報、データプレーン、実行主体という複数の責務から構成される。現在の CDK スタックは、2 アベイラビリティーゾーンの VPC、パブリックサブネット、アプリケーション用プライベートサブネット、データベース用分離サブネット、Cognito user pool、Aurora PostgreSQL 17.4 Serverless v2、非公開 S3 bucket、SNS topic、フロントエンドとバックエンドの Fargate サービス、別タスクとしてのマイグレーション、Application Load Balancer、Secrets Manager、IAM タスク role を構成する[25]。この構成をトポロジーとして読むことで、各サービスが受け持つ境界を確認できる。
VPC のサブネット分割にも責務がある。ALB のように外部から到達する入口は public 側へ置き、アプリケーションタスクはプライベートサブネット、データベースは分離サブネットへ置く。バックエンドは Aurora、S3、SES、SNS と通信するが、ブラウザはそれらへ直接接続しない。ブラウザ向けオリジンとプロバイダー access をバックエンド境界で分けるという基本設計が、ネットワーク topology にまで反映されている。
Aurora PostgreSQL は、AWS 側でもローカル PostgreSQL と同じ関係モデルを維持する役割を持つ。現在の CDK スタックは Aurora PostgreSQL 17.4 の Serverless v2 を構成し、データベース認証情報は Secrets Manager からバックエンドタスクとマイグレーションタスクへ渡す[25]。認証情報をフロントエンドの実行時設定やソースコードに置かず、実行主体へ必要な時点で注入することで、ブラウザで安全に扱える設定とサーバー側シークレットを分離している。
マイグレーションを通常のバックエンド起動から分けている点も、技術選定に含まれる。CDK スタックはマイグレーション専用の Fargate タスクを別に持ち、通常のバックエンド起動とスキーママイグレーションを別の操作にしている[25]。複数のバックエンドタスクが同時起動する環境では、デプロイ時のスキーマ変更と通常起動を分けることで責務を明確にできる。マイグレーションは一回性の明示操作として扱い、通常のサービス再起動は既存スキーマを前提にする。
フロントエンドとバックエンドはそれぞれ Fargate タスクとして動き、ALB が /api/* をバックエンド、それ以外をフロントエンドへ振り分ける[25]。Amazon ECS はコンテナ化したアプリケーションを管理するサービスであり、Fargate はタスクを AWS 管理のサーバーレスインフラストラクチャ上で実行する[26]。spa-reference は現在の要件に対して Fargate を選び、コンテナ実行基盤の管理を AWS 側へ寄せている。
Fargate の妥当性は現在の条件に依存する。既存の Kubernetes 基盤が組織標準として存在する環境、EC2 上で特殊な実行時制約を持つ環境、別クラウドを対象とする環境では別の選択が成立する。spa-reference では AWS を現在唯一のデプロイ対象とし、コンテナ化したフロントエンドとバックエンドを比較的少ない運用概念で配置するという条件に対して Fargate を採用している。
AWS CDK の選定理由は、インフラストラクチャを TypeScript で定義できることに加え、CloudFormation template へ合成し、AWS リソースの構成を Infrastructure as Code として扱える点にある[27]。spa-reference ではスタック定義をアプリケーションと同じリポジトリに置くため、バックエンドが必要とする Cognito issuer、S3 bucket、SNS topic、データベースエンドポイント、IAM permission といった関係を、アプリケーション変更と同じレビュー単位で確認できる。
通常の CI は CDK synth までを検証範囲とし、実際の AWS リソースの作成、変更、削除はデプロイ操作へ分ける。この分離も技術選択の一部である。インフラストラクチャ定義が型検査でき、CloudFormation template へ合成できることはプルリクエスト上で確認できる。実環境への変更はアカウント、認証情報、環境固有設定、変更影響を伴うため、定義の妥当性確認と本番環境の変更を別の権限境界へ置いている。
テスト技術は、観測する故障面ごとに役割分担する。Vitest は TypeScript workspace のテストランナーとして使い、React Testing Library はブラウザ利用者から観測できるフロントエンドの振る舞いを確認し、Supertest は NestJS の HTTP 境界を確認する。Redocly CLI は OpenAPI 自体を検証し、Prisma CLI はスキーマとマイグレーションを検証し、CDK synth はインフラストラクチャ定義を検証する。同じ「テスト」という名前でも、各検査が受け持つ対象は異なる。
技術スタックを判断の強さで整理すると、少なくとも三層に分けられる。第一は、関係データの整合性境界、実装言語から独立した API 契約、業務コミットと外部副作用の分離といった要件やアーキテクチャから強く導かれる判断である。第二は、React、NestJS、AWS のように、要件を満たす複数の候補から参照基準として採用した選択である。第三は Vite、Vitest、Prisma、Redocly など、その基準実装を効率よく実装、検証するための具体的ツールである。
| 判断層 | 代表例 | 変更するときに確認するもの |
|---|---|---|
| 要件・アーキテクチャから強く拘束される選択 | 関係データの整合性境界、ブラウザ向け API 境界、実装言語から独立した HTTP 契約、業務コミットと外部副作用の分離、サーバー側認可 | 対象とする業務特性、整合性要求、公開契約、障害時保証そのものが変わったかを確認する。 |
| 参照基準として採用した実装方式 | React、NestJS、AWS、モノレポ | 同じ要件をより適切に満たす組織条件、既存基盤、運用条件があるかを確認する。 |
| 実装と検証を具体化する道具 | Vite、Prisma、Vitest、React Testing Library、Supertest、Redocly CLI、AWS CDK | 上位の契約と責務を維持したまま置換できるか、追加概念と保守費用が妥当かを確認する。 |
技術スタックの更新判断では、新しい版や流行より、現在の技術が担う契約を何が引き継ぐかを見る。React を別のフロントエンドフレームワークへ変更するなら、OpenAPI 契約とバックエンド認可を維持しながらブラウザアプリケーションの責務を引き継げるかを確認する。Prisma を別の永続化技術へ変更するなら、PostgreSQL transaction、行ロック、マイグレーション、楽観的同時実行制御、outbox の意味論を維持できるかを見る。Fargate を別の実行基盤へ変更するなら、コンテナ実行、ヘルスチェック、ネットワーク isolation、シークレット注入、デプロイ責任をどの仕組みが引き継ぐかを見る。
技術名を基準に置くと、変更は製品比較になる。要件と責務を基準に置くと、変更は「現在この技術が満たしている契約を、別の技術がどこまで引き継げるか」という比較になる。spa-reference の技術スタックを克明に見る意味は、各製品の背後にある要求と保証範囲を明らかにすることにある。
OpenAPI、transactional outbox、認可境界のような設計上の判断と、React、NestJS、Vite、Vitest のような具体的選択は、異なる強さの判断として扱う。参照実装が提供するのは、一定の要件から一つの整合した構成へ到達した実例である。別の要件からは別の技術スタックが導かれ得るが、要件から責務を分解し、境界を固定し、その境界を具体的な技術へ対応付けるという判断方法は再利用できる。
9. CI は実装と契約の対応関係を継続的に照合する
GitHub Actions は、リポジトリ上のイベントを契機にビルド、テスト、デプロイなどの workflow を自動実行できる CI/CD 基盤である[28]。spa-reference ではプルリクエストと master への push を検証対象とし、Node.js 24 と使い捨て PostgreSQL 17 を用意する。その上で npm ci、Prisma クライアント generation、生成 API クライアントの最新性、format、lint、typecheck、OpenAPI 検証、自動テスト、フロントエンドとバックエンドのビルド、Prisma スキーマ検証、マイグレーション検証、CDK synth、フロントエンドとバックエンドのコンテナビルドを実行する。
CI は、リポジトリ内にある複数の対応関係を別々の検査で確認する。package.json と package-lock.json、OpenAPI と生成 API クライアント、Prisma スキーマとマイグレーション、TypeScript ソースと型制約、CDK ソースと合成可能なインフラストラクチャ定義、Dockerfile とビルド可能なコンテナイメージは、それぞれ壊れ方が異なる。対応関係ごとに検査を分けることで、各不整合をその責務に近い位置で検出できる。
| 検査 | 照合しているもの | 検出したい不整合 | 隣接する検査範囲 |
|---|---|---|---|
| npm ci | package manifest、ロックファイル、実際に導入できる依存関係を照合する。 | ロックファイルと manifest の不整合や、固定された依存関係を導入できない状態を検出する。 | 業務動作は自動テストとビルドの検査範囲に属する。 |
| Format / lint | ソースとリポジトリ全体のコーディング規則を照合する。 | 書式逸脱、静的に検出できる危険な記述、不要な構文上の不整合を検出する。 | 業務要件への適合と実行時の振る舞いは自動テストとレビューの検査範囲に属する。 |
| Typecheck | TypeScript ソース内の値、インターフェース、呼び出し関係を照合する。 | 型の不一致、存在しないプロパティや不正な呼び出しをコンパイル前に検出する。 | HTTP 契約は OpenAPI 検証、データベーストランザクションの業務意味は自動テストと設計レビューの範囲に属する。 |
| OpenAPI 検証 | OpenAPI ソースと OpenAPI 3.1 の構造制約を照合する。 | スキーマ、reference、操作定義などの仕様上の不整合を検出する。 | バックエンドの実動作との一致は契約テストと自動テストの範囲に属する。 |
| API クライアント freshness | OpenAPI ソースと生成済みフロントエンドクライアントを照合する。 | OpenAPI を変更したのに生成クライアントが古いまま残る状態を検出する。 | 生成されたクライアントを使う画面の業務操作はフロントエンドテストの範囲に属する。 |
| Automated tests | 実装と、テストケースに明示された振る舞いを照合する。 | 状態遷移、認可、HTTP レスポンス、フロントエンド振る舞いなど、事前に定義した期待からの逸脱を検出する。 | 要件・設計の意味とテストケースの網羅性はレビューの範囲に属する。 |
| Prisma 検証 | Prisma スキーマと Prisma が要求する model 定義を照合する。 | スキーマ自体が解釈できない状態や、定義上の不整合を検出する。 | マイグレーション履歴からの到達可能性はマイグレーション検証の範囲に属する。 |
| Migration 検証 | マイグレーション適用後のデータベースと現在の Prisma スキーマを照合する。 | マイグレーション history を適用しても現在スキーマへ到達できない状態やスキーマ drift を検出する。 | 本番データ量、長時間 lock、実データ固有のマイグレーション risk はデプロイ計画の範囲に属する。 |
| CDK synth | TypeScript のインフラストラクチャ定義と CloudFormation へ合成可能な構造を照合する。 | 型や構成の誤りによりインフラストラクチャ定義を合成できない状態を検出する。 | 実際の AWS アカウントへのデプロイと変更影響はデプロイ操作の範囲に属する。 |
| Container ビルド | Dockerfile、リポジトリ content、ビルド context を照合する。 | フロントエンドまたはバックエンドのコンテナイメージを生成できない状態を検出する。 | 本番トラフィック下の振る舞いは実行環境の検証範囲に属する。 |
OpenAPI と生成 API クライアントの関係は、この CI の考え方を分かりやすく示す。spa-reference では OpenAPI がブラウザ向け HTTP 契約の正本であり、クライアントソースはそこから生成される。OpenAPI を変更した後に生成クライアントの更新を忘れると、リポジトリ内には互いに異なる二つの API 解釈が残る。CI の api:check はクライアントを再生成し、その結果として tracked ソースに差分が生じないことを確認する。これは「生成に成功するか」だけでなく、「正本から導かれる生成物がすでにリポジトリに反映されているか」を検査している。
Prisma マイグレーションも同じ構造を持つ。スキーマ.prisma が現在形として正しくても、過去のマイグレーションを順に適用して同じ状態へ到達できなければ、新しい環境を再構築できない。CI では使い捨て PostgreSQL を用意し、マイグレーションを実際に適用した後、マイグレーションから得られたデータベーススキーマと現在の Prisma スキーマの差分を検査する。現在のスキーマ一枚だけを見るのではなく、履歴から現在状態へ到達できることまで再現可能性の一部として扱っている。
この検査は、参照実装という性質と直接関係する。公開されたソースが動くだけなら、開発者の手元に残ったデータベースや生成物によって偶然成功している可能性がある。新しい PostgreSQL を CI 上に作り、マイグレーションを最初から適用し、生成ソースも正本から再生成できることを確認すれば、「既存環境を持っている開発者だけが動かせる」という暗黙依存を減らせる。参照実装では、作者の作業環境より第三者が再現できる状態の方が重要になる。
CDK synth と実際のデプロイを分けていることにも意味がある。通常の CI は infrastructure ソースが型検査を通り、CloudFormation template へ合成できるところまでを確認する。実際の AWS リソースの作成、変更、削除は別のデプロイ操作に置き、インフラストラクチャ定義の静的な整合性確認と実環境への変更を異なる権限境界で扱う。
コンテナビルドも verify job とは別の故障面を確認する。TypeScript ビルドが通っても Dockerfile の COPY 対象、ビルド context、production 依存関係、コンテナ内の path に問題があればイメージは生成できない。OpenAPI やマイグレーションの整合性は別の検査が担当するため、アプリケーションソース、データベース、API 契約、infrastructure、コンテナパッケージングという異なる成果物ごとに検査を分けている。
既稿「AI がテストを通しても、「正しい」とは限らない」では、テストを仕様全体そのものとして扱うのではなく、仕様のうち機械的に観測できる条件をテストケースへ写像したものとして整理した[29]。spa-reference の CI も同じ構造を持つ。自動テストが証明する範囲は、テストケースとして機械的に観測できる条件に対応する。
文書についても同様である。CI が直接照合できるのは、OpenAPI、生成クライアント、スキーマ、マイグレーション、ソース、ビルド成果物のように機械的な対応関係を定義できる部分が中心になる。README に書かれた説明の意味が設計思想と一致しているか、BASIC_DESIGN の責務説明が現在のソース構造を適切に抽象化しているか、といった意味論は人間のレビューが受け持つ。文書と CI は相補的であり、観測可能な対象が異なる。
green CI が示すのは、現在設定されている検査条件を満たしたという事実である。要件が不足していれば、その不足を忠実に実装したコードでも CI は通り得る。テストケースが扱う条件の範囲によって、検出できる defect の範囲も決まる。検証結果の保証範囲は、検査対象と観測方法に対応する。
この限定があるからこそ CI の役割が明確になる。人間が毎回 package-lock.json、生成クライアント、マイグレーション history、OpenAPI、CDK、Dockerfile の対応を手作業で確認すると、確認漏れが継続的に発生する。機械的に判定できる不変条件を CI へ移すことで、人間のレビューは「要求と設計の意味が正しいか」「この変更理由でこの境界を変えてよいか」といった、自動化しにくい判断へ集中できる。
spa-reference の CI は本番認証情報から独立し、通常のプルリクエストではリポジトリ検証に専念する。この制約も検証設計の一部である。PostgreSQL サービス、テスト double、CDK synth、コンテナビルドまでをプルリクエスト内で完結させ、本番環境の変更を別の操作へ分離することで、公開リポジトリの変更を第三者が同じ条件で検証しやすくしている。
参照実装は公開後も変化する。依存関係は更新され、ソースは変更され、OpenAPI は拡張され、データベーススキーマはマイグレーションを重ね、インフラストラクチャ定義も変化する。変更のたびに対応関係を人間の記憶だけで維持すると、時間の経過とともにソースと生成物、契約と実装、スキーマとマイグレーションのずれが蓄積する。CI は、その時間方向のずれを変更ごとに再検査する仕組みである。
この意味で CI は、spa-reference が「現在の master はこの構造を満たしている」と主張するための実行可能な証拠の一部になっている。文書が要件と設計判断を保持し、OpenAPI が通信契約を保持し、ソースがそれらを実装し、CI が機械的に観測できる対応関係を継続して検査する。異なる表現を相互に照合することで、参照実装を一度作った成果物から継続的に検証される基準点へ変えている。
10. reference は条件付きの基準点である
spa-reference を「React、NestJS、AWS を使ったアプリケーション」と要約すると、実装に現れた製品名だけが残る。しかし、ここまで見てきた構造を逆向きにたどると、技術スタックより上位にある判断の連鎖が見える。対象をトランザクション中心の業務 SPA に限定し、現在必要な責務だけを実装し、将来分離可能な境界を先に作る。ブラウザ向け HTTP 契約を OpenAPI に置き、認証プロバイダーと業務上の認可を分離する。PostgreSQL のトランザクション内で確定できる事実と、SES や SNS のような外部副作用を分ける。AWS 固有処理をインフラストラクチャアダプターへ閉じ込め、同じ業務意味論をローカルでも実行する。最後に、仕様、生成物、実装、マイグレーション、インフラストラクチャの対応関係を CI で継続的に検査する。この連鎖全体が spa-reference の参照対象である。
ここで reference という語が重要になる。template を使う主目的は、既知の初期構造を複製して開発開始までの時間を短縮することにある。reference は、それに加えて「なぜこの構造を選んだか」を比較対象として残す。採用先では、元の選択を成立させていた要件と制約を読み取り、自分の条件との差分から、維持すべき判断と変更すべき判断を識別する。この読み替えによって、同じ技術を採用する場合にも別の技術へ置き換える場合にも基準点として利用できる。
そのため、spa-reference に含まれる選択には固定度の差がある。たとえば、利用者の role を検証済み identity からバックエンドで導くこと、外部配送をデータベーストランザクション後の副作用として分けること、ブラウザ向け契約を内部フレームワークの型から分離することは、現在の要件と安全性から強く導かれる。一方、React、NestJS、Prisma、AWS CDK は、それらの責務を現在の参照基準で具体化した技術である。前者を変更するなら要件や保証範囲そのものを再検討する必要があり、後者を変更する場合は上位の契約を維持できるかが判断基準になる。
| 条件が変わる例 | 現在の基準点 | 再検討される判断 | 維持すべき問い |
|---|---|---|---|
| 検索流入と初期表示が主要要件になる | React のブラウザ SPA | SSR、SSG、hybrid rendering を含めたフロントエンドアーキテクチャを再検討する。 | 主要利用者がどの経路で到達し、どの時点でどの情報を表示できる必要があるかを確認する。 |
| 複数クライアントが大きく異なる要求を持つ | 一つの Web SPA に対する一つの BFF | クライアントごとの BFF 分割や API 集約の境界を再検討する。 | どの契約がクライアント固有で、どの機能が共通なのかを確認する。 |
| 機能ごとに独立チームとリリースサイクルが成立する | 一つのバックエンドデプロイ可能と内部機能境界 | 一部機能のサービス分割、ネットワーク契約、failure recovery を再検討する。 | 分散によって得る独立性が、タイムアウト、再試行、可観測性、運用費用を上回るかを確認する。 |
| AWS 以外の cloud が現在要件になる | AWS アダプターと AWS CDK | プロバイダー実装、IaC、デプロイトポロジーを再検討する。 | アプリケーション契約と domain 意味論のどこまでをプロバイダーから独立させる必要があるかを確認する。 |
| 強い非同期処理や大量イベント処理が中心になる | 同期 HTTP と関係データベーストランザクションを中心とする業務 SPA | message broker、イベント-driven processing、データ所有権、一貫性モデルを再検討する。 | どの業務事実を同期的に確定し、どこから eventual consistency を許容するかを確認する。 |
| 既存組織標準が React、NestJS、AWS 以外にある | 現在の技術スタック | フレームワーク、実行時、cloud の具体的実装を置き換える。 | 公開契約、認可、トランザクション境界、障害時保証を同等に維持できるかを確認する。 |
条件変更は製品名だけでなく責務境界にも及ぶ。公開コンテンツ中心のシステムでは、ブラウザセッション中の状態操作よりレンダリングとキャッシュの設計が前面に出る。独立したチームが機能境界ごとに存在するシステムでは、同一プロセスの単純さより、独立デプロイと障害隔離の価値が高くなる。大量の非同期イベントを扱うシステムでは、一つの関係データベーストランザクションへ集約する構成と eventual consistency を明示する構成を、業務要件から比較する。reference を使うとは、この条件差を判断することである。
再利用できるのは、最初に対象を限定し、何を同時に成立させる必要があるかを決め、利用者から観測される契約を定義し、外部依存との境界を置き、必要な複雑性だけを導入し、その判断を実装と検証へ対応付けるという順序である。React や AWS を置き換えても、この順序は設計判断を導く方法として残る。
spa-reference の「Simplicity is robustness.」は、reference を更新するための判断規則として働く。将来要件が増えたときは、新しい要件がどの境界を実際に変えるのかを確認し、既存の構造で満たせる部分は維持し、追加要件を受け持つ部分へ新しい状態、依存関係、ネットワーク境界、プロバイダーを配置する。変更範囲を要件差分へ対応させることで、reference の複雑性を制御する。
reference が条件付きであることと、現在の条件に対する具体性は両立する。spa-reference は現在の条件に対して、バックエンドは一つの NestJS デプロイ可能、データベースは PostgreSQL 互換の永続化、AWS モードの identity プロバイダーは Cognito、ブラウザ向け契約は OpenAPI 3.1、外部配送には transactional outbox、ローカルデモでは PostgreSQL 17 を Docker Compose で起動するという具体的な答えを持つ。その選択を成立させている条件も同時に残している。
reference は、抽象的な設計論と動くコードを接続する中間層として機能する。transaction、認可、outbox、マイグレーション、デプロイといった具体的な問題へ抽象原則を適用し、その結果を実際に動くコードへ写す。さらに、そのコードから要件と設計判断へ戻れるようにすることで、具体例と上位原則を往復して読める。
アーキテクチャは、要求に対してどの責務を一緒に確定し、どの責務を分離し、どの失敗を許容し、どの失敗を契約違反とし、どこまでを自動回復させるかという判断の蓄積である。PostgreSQL を選ぶことより先にトランザクション境界があり、Cognito を選ぶことより先に認証と認可の分離があり、Fargate を選ぶことより先に現在必要なデプロイ単位がある。技術は、その判断を現在の条件で実行可能にした具体物として位置付けられる。
spa-reference が公開しているのは、その具体物と判断の両方である。requirements が対象と保証範囲を定め、design が責務と処理意味を定め、policy が変更時の判断規則を定め、OpenAPI が外部契約を定め、ソースがそれらを実行し、tests と CI が機械的に観測できる対応関係を検査し、infrastructure が本番トポロジーへ具体化する。一つの判断が複数の成果物へ写されているため、読者は「何を作ったか」と「なぜそうなっているか」の両方を追跡できる。
この意味で reference は、現在条件に対する具体的な答えと、条件変化時の再判断点を持つ基準点である。spa-reference の中心にあるのは、要件から責務境界を導き、保証範囲を明示し、必要な複雑性だけを技術へ変換し、その判断を実装と検証へ一貫して残すという設計方法である。
参考文献
- id774, spa-reference README. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/README.md
- id774, Requirements: an SPA development reference application. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/doc/REQUIREMENTS.md
- Martin Fowler, Sensible Default(2026-09-29). https://martinfowler.com/bliki/SensibleDefault.html
- id774, Policy: implementation and maintenance rules. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/doc/POLICY.md
- id774, 理解・設計・制度はなぜ単純化へ収束するのか(2026-02-16). https://blog.id774.net/entry/2026/02/16/3660/
- Herbert A. Simon, The Architecture of Complexity(1962-12-12). https://www.jstor.org/stable/985254
- D. L. Parnas, On the Criteria To Be Used in Decomposing Systems into Modules(1972-12-01). https://doi.org/10.1145/361598.361623
- id774, Basic design: an SPA development reference application. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/doc/BASIC_DESIGN.md
- Martin Fowler, Monolith First(2015-06-03). https://martinfowler.com/bliki/MonolithFirst.html
- Microsoft, Backends for Frontends pattern. https://learn.microsoft.com/azure/architecture/patterns/backends-for-frontends
- id774, Detailed design: an SPA development reference application. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/doc/DETAILED_DESIGN.md
- PostgreSQL Global Development Group, Transaction Isolation. https://www.postgresql.org/docs/current/transaction-iso.html
- Prisma, Transactions and batch queries, Prisma ORM v7. https://docs.prisma.io/docs/orm/v7/prisma-client/queries/transactions
- Amazon Web Services, Transactional outbox pattern. https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html
- OpenAPI Initiative, OpenAPI Specification v3.1.0(2021-02-15). https://spec.openapis.org/oas/v3.1.0.html
- N. Sakimura, J. Bradley, N. Agarwal, RFC 7636: Proof Key for Code Exchange by OAuth Public Clients(2015-09). https://www.rfc-editor.org/rfc/rfc7636.html
- T. Lodderstedt, J. Bradley, A. Labunets, D. Fett, RFC 9700: Best Current Practice for OAuth 2.0 Security(2025-01). https://www.rfc-editor.org/rfc/rfc9700.html
- Amazon Web Services, Using PKCE in authorization code grants. https://docs.aws.amazon.com/cognito/latest/developerguide/using-pkce-in-authorization-code.html
- Docker, Docker Compose. https://docs.docker.com/compose/
- id774, spa-reference compose.yaml. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/compose.yaml
- id774, spa-reference package.json. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/package.json
- id774, spa-reference frontend/package.json. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/frontend/package.json
- id774, spa-reference backend/package.json. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/backend/package.json
- id774, spa-reference infra/package.json. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/infra/package.json
- id774, spa-reference AWS infrastructure stack. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/infra/lib/reference-stack.ts
- Amazon Web Services, What is Amazon Elastic Container Service?. https://docs.aws.amazon.com/AmazonECS/latest/developerguide/Welcome.html
- Amazon Web Services, What is the AWS CDK?. https://docs.aws.amazon.com/cdk/v2/guide/home.html
- GitHub, Understanding GitHub Actions. https://docs.github.com/en/actions/get-started/understanding-github-actions
- id774, AI がテストを通しても、「正しい」とは限らない(2026-09-01). https://blog.id774.net/entry/2026/09/01/5534/