「階層型スペック・マージ」によるSSOTの極致:疎結合とパフォーマンスの両立

はじめに
YAML の1行を変えるだけで UI・バリデーション・テストが一括更新されるアーキテクチャがあります。
設定値が複数ファイルに分散していると「修正漏れ」が常態化しますが、3段階の階層型マージで解決できます。
仕様変更のたびに全方位を修正する、そんなことありませんか?
サイトのカテゴリを一つ追加しようとしただけで、UIのラベルを書き換え、バリデーションを修正し、さらにテストコードまで手を入れる。
しかし、これら情報の散在は、必ずどこかで「修正漏れ」を引き起こし、本番環境での不整合という致命的なバグを生みます。
場当たり的なハードコードで回避しても、それは将来の自分(またはAI)の足を引っ張る、保守不可能な「テキスト負債」の積み上げに過ぎません。
この記事をお勧めしない人
- プロジェクトの設定値が複数のファイルに散らばっていても、全く問題ない人。
- クライアントサイドのバンドルサイズより、開発の単純さだけが大切だと考える人。
- 10年後も「死なない」プロダクトを作ることに、自分には関係ないと思う人。
もし一つでも当てはまらないなら、読み進める価値があるかもしれません。
設定値が散在したままだと
- 仕様・実装・テストの定義がバラバラになり、どこを変えれば全体が揃うか誰にも分からなくなる。
- ラベル一つ変えただけで「なぜか壊れたE2Eテスト」の調査に時間が溶け、本来の開発が止まる。
- AI に修正を頼んでも、散在した定義の全箇所を把握できないため修正漏れが出やすくなる。
階層型スペック・マージという明るい未来
- この記事を読めば、YAMLファイルを1行変えるだけで全体が安全かつ瞬時に同期される「SSOT」の設計思想が手に入ります。
- 具体的には、3段階の継承構造とビルド時の差分抽出により、疎結合な開発と厳格な一貫性を両立させる設計図を手に入れられます。
- この方法は、本番環境のブログで Lighthouse 100点を維持しながら実証済みです。
このブログもそうでした
このブログ自身が、カテゴリ名・UIラベル・バリデーション定義が各ファイルに散らばっている状態から始まりました。generate-specs.js で YAML から差分のみを抽出し、ランタイムで deepMerge する構造に移行することで、設定の一元管理を実現しました。Cloudflare Workers のバンドルサイズ制約があるため、「全データ埋め込み」ではなく「差分だけ埋め込む」という設計が必要でした。
開発の進捗
- Before: サービス全体の設定(カテゴリ、セッション、UI文言)が各ドメインにハードコードされ、整合性の維持が困難だった。
- Current: 階層型スペック定義(Shared > Domain > Section)と差分抽出によるESモジュール生成が完了し、一貫した管理を実現。
- Next: 他のドメイン(アカウント管理、決済等)への完全適用を進め、全方位での情報のSingle Source of Truth(SSOT)を確立する。
具体的なタスク
- Before: 各ファイルに散在していたカテゴリ名やリンクパスを
spec.yamlに集約。 - Current: ビルド時にYAMLを解析し、差分のみを抽出してESモジュールを生成する
generate-specs.jsを実装。 - Next: 生成されたスペックファイルを
deepMergeしてランタイムで再構築する共通ユーティリティを整備。
課題と解決策
サービスが成長するにつれ、単一の設定ファイルでは管理が限界を迎え、一方でファイル分割はドメイン間の結合やバンドルサイズの肥大化を招くというジレンマに直面しました。
工夫したこと
「3段階の階層型マージ」という戦略を採用しました。全社共通の Shared、ドメインごとの Common、そしてページ個別の Section という階層を持たせ、下位の定義が上位を「継承・上書き」する構造にしました。これにより、一箇所を変更すれば全体に波及する「SSOT」と、必要な情報だけを読み込む「疎結合」を両立させました。
ぶつかった壁
単純にファイルをマージしてESモジュールを生成すると、上位階層の重複データが下位のファイルにも書き出され、クライアントサイドのバンドルサイズを無駄に増やしてしまうという問題がありました。特にエッジ環境(Cloudflare Workers)では、スクリプトサイズが実行速度や制限に直結するため、この冗長性は致命的でした。
解決方法
ビルド時に基底スペックと現在のスペックを比較し、変更(上書き)されたプロパティのみを抽出する「差分抽出ロジック(getDifference)」を開発しました。この抽出された「差分」だけをESモジュールとして書き出し、ランタイムで deepMerge することで、最小限の転送量で完全なスペックオブジェクトを再構築することに成功しました。
コード抜粋
実際にスペックを生成する generate-specs.js のコアロジックと、ランタイムでの再構築手法を紹介します。
// scripts/prebuild/generate-specs.js
// 基底(base)と対象(target)を比較し、差分のみを抽出する
function getDifference(target, base) {
if (typeof target !== 'object' || target === null || Array.isArray(target)) {
return target;
}
const diff = {};
for (const key in target) {
if (base && key in base) {
if (typeof target[key] === 'object' && target[key] !== null && !Array.isArray(target[key])) {
const subDiff = getDifference(target[key], base[key]);
if (Object.keys(subDiff).length > 0) {
diff[key] = subDiff;
}
} else if (target[key] !== base[key]) {
diff[key] = target[key];
}
} else {
diff[key] = target[key];
}
}
return diff;
}ランタイム側では、以下のようにマージして利用します。
// app/generated/specs/blog/posts.ts
import { data as base } from './common';
import { deepMerge } from '~/lib/utils/deepMerge';
// 差分だけが埋め込まれ、実行時に完全なオブジェクトになる
export const data = deepMerge(base, {
"posts_config": {
"page_title": "Latest Update"
}
}) as any;今回の学びと感想
「情報の置き場所」を設計することは、コードを書くこと以上にプロダクトの寿命に直結すると痛感しました。SSOTは単なる「集約」ではなく、情報の「継承」と「抽出」のプロセスをシステム化することで、初めてAIエージェントとも協調可能な強靭な基盤になります。
この spec.yaml を唯一の情報源とする設計思想は、コードだけでなくコンテンツにも適用できます。ブログメタデータの品質を spec.yaml の許可値で自動検証するリントシステムも、この階層設計があるからこそ「カテゴリの追加はspec.yamlだけ変えればいい」という一貫性を保てています。また、E2EテストのセレクタIDも同じ spec.yaml から動的に取得する extractTestId パターンを採用することで、UIの文言変更がテストを壊さない構造を実現しています。
