build · Stage 2
変更理由でモジュール境界を設計しADRで更新する
同時に変わる知識を隠蔽し、依存方向と複数viewを検証可能なADRへ記録する。
到達目標
fileやserviceの個数ではなく、変更理由と隠蔽するdecisionからmodule境界を説明できる
- 変更理由、依存方向、cycle検査、選択肢、正負の帰結、確認方法を含むmodule図とADR
- module分割数とmodularityが同義ではない理由を説明する5分間の解説
module graphのcycleと依存方向違反を検出し、reverse transitive closureで変更影響を再計算できる
- 変更理由、依存方向、cycle検査、選択肢、正負の帰結、確認方法を含むmodule図とADR
- 変更結合、reverse impact、scale anchor、optionごとの観測証拠から加重scoreと境界を再計算できる判断記録
stakeholder concern、1〜5の全anchorを定義したscale、選択肢のevidence、正負の帰結、確認方法をADRへ残し、高変更moduleへ判断を移せる
- 変更結合、reverse impact、scale anchor、optionごとの観測証拠から加重scoreと境界を再計算できる判断記録
- 変更頻度が高い料金計算moduleの依存方向を再設計し、観測結果でADRを更新した報告
能力の進行
recognize
module、interface、dependency、view、ADRの役割を識別できる
証拠: 変更理由、依存方向、cycle検査、選択肢、正負の帰結、確認方法を含むmodule図とADR
explain
information hidingと変更理由が境界安定性へ与える影響を具体例で説明できる
証拠: module分割数とmodularityが同義ではない理由を説明する5分間の解説
apply
依存graphからcycleと方向違反を検出し、外側からdomainへ向く構成へ直せる
証拠: 変更理由、依存方向、cycle検査、選択肢、正負の帰結、確認方法を含むmodule図とADR
diagnose
変更履歴と影響範囲を用いて、境界漏洩と不適切な依存を反証できる
証拠: 変更結合、reverse impact、scale anchor、optionごとの観測証拠から加重scoreと境界を再計算できる判断記録
lead
複数stakeholderのconcernとviewを統合し、ADRを廃止・置換するreviewを主導できる
証拠: 変更頻度が高い料金計算moduleの依存方向を再設計し、観測結果でADRを更新した報告
なぜ重要か
architectureの価値は箱を増やすことではなく、一つの変更理由が無関係な領域へ波及する範囲を制御することにある。file、package、serviceを増やしても、同じdecisionが各所へ漏れ、相互依存していれば変更費用は下がらない。
図とADRは判断を共有する道具であり、判断そのものの正しさを保証しない。誰のconcernを扱うviewか、何を代替案と比較したか、どの観測でdecisionを見直すかまで残して初めて更新可能になる。
メンタルモデル
moduleは関連する変更を内側へまとめ、変わりやすいdecisionを安定したinterfaceの背後へ隠す。runtimeでdomainがadapterを呼ぶ場合でも、source dependencyはdomainが定義したportへ外側が従うようにできる。
| 案 | 変更局所性 | 移行費 | 加重score | 主なrisk |
|---|---|---|---|---|
| domain境界を維持 | 5: 週次規則をdomainへ局所化 | 3: 二重経路を段階移行 | 4.2 | 古いadapterとの二重経路 |
| 一時facade | 3: 一部の漏洩が残る | 5: 既存consumerを維持 | 3.6 | 暫定層の恒久化 |
| service分離 | 4: process境界へ隔離 | 1: data移行を伴う | 2.4 | network・運用・data整合性 |
各criteriaは1〜5すべてのanchorとweightを先に固定する。optionごとのratingには観測可能なevidenceを添え、加重平均をcodeで再計算するため、好みをscoreへ偽装しにくい。
fixtureの由来: この例は lesson-defined synthetic scenario であり、学習用に固定したmoduleとedgeからなる。実repositoryの依存解析、変更履歴、運用計測ではないため、scoreやimpactを現実のarchitecture判断へそのまま転用できない。
注記
図を読む際の補足情報です。
- この注記は旧図の読み順を保持する補助です。
- pricing-domain: 料金規則と不変条件。UIやDB形式を知らない。
- pricing-application: use caseを順序付け、domainのportを使う。
- pricing-adapters: HTTP、DB、batch形式をdomainの語彙へ変換する。
- reporting: 料金計算の変更対象外となる独立module。impact計算のfalse positiveを検出する基準にする。
- source dependencyはadaptersからapplication、applicationからdomainへ向ける。
- 運用viewでは逆向きのrequest flowを別の矢印として記述し、意味を混ぜない。
変更理由を守る source dependency と運用時の request flow をどう区別するか。
- source dependency view
- source dependencyはadaptersからapplication、applicationからdomainへ向ける。
- runtime request flow view
- 運用viewでは逆向きのrequest flowを別の矢印として記述し、意味を混ぜない。
- 変更対象外のreporting境界
- pricingの依存graphから隔離し、impact計算のfalse positiveを検出する基準にする。
- pricing-domain
- 料金規則と不変条件。UIやDB形式を知らない。
- component
- source dependency view
- pricing-application
- use caseを順序付け、domainのportを使う。
- component
- source dependency view
- pricing-adapters
- HTTP、DB、batch形式をdomainの語彙へ変換する。
- component
- source dependency view
- pricing-domain
- runtime viewでdomain portから外側の実装を呼び出す起点。
- component
- runtime request flow view
- pricing-application
- runtime viewでuse caseとdomain portの呼出しを中継する。
- component
- runtime request flow view
- pricing-adapters
- runtime viewでHTTP、DB、batchのadapter実装を実行する。
- component
- runtime request flow view
- reporting
- 料金計算の変更対象外となる独立module。impact計算のfalse positiveを検出する基準にする。
- component
- 変更対象外のreporting境界
- pricing-adapters → pricing-application: source dependency: adaptersからapplicationへ向ける
- pricing-application → pricing-domain: source dependency: applicationからdomain portへ向ける
- pricing-domain → pricing-application: runtime request flow: domain portからapplicationへ戻る
- pricing-application → pricing-adapters: runtime request flow: applicationからadapter実装を呼ぶ
source dependencyはadapterからdomainへ、runtime request flowはdomain portからadapter実装へ進む逆向きのviewとして区別し、独立したreporting境界も指し示せる。
動く例で考える
cycleを除き、変更impactをADRで確認する
- 前提
- 料金規則、use case、DB変換の三moduleと、変更対象外のreportingがある。内側から外側へのsource dependencyは禁止し、runtime callはportを介する。
- 入力
- 変更前はdomain→adapters→application→domainの3-edge cycleを持つ。選択肢は境界維持、一時facade、service分離の三つで、4 criteriaのscale anchorとevidenceを使う。
- 操作
- graphを深さ優先探索し、cycleと層rankに反するedgeを抽出する。domainからadapterへのedgeを外し、外側から内側へ二edgeを残す。変更targetからedgeを逆向きにたどるtransitive closureでimpactを計算する。
- 観測
- 変更前はcycleと方向違反が各1件、変更後は各0件。料金規則変更はdomain、application、adaptersへ伝播し、reportingは含まれない。edge削除・target変更mutationではimpactの差を検出する。
- 結論
- graphのgreenだけで完了せず、選択肢、負の帰結、confirmationをADRへ残し、実際の変更履歴で再評価する。
次のPython 3.13 fixtureは固定されたmoduleとedgeだけを解析する。repository、network、環境秘密情報へアクセスしない。
python3.13 - <<'PY'
import json
HARNESS = "architecture_lab_v1"
MODULES = [
"pricing-domain",
"pricing-application",
"pricing-adapters",
"reporting",
]
RANK = {
"pricing-domain": 0,
"pricing-application": 1,
"pricing-adapters": 2,
"reporting": 2,
}
BEFORE_EDGES = [
("pricing-domain", "pricing-adapters"),
("pricing-adapters", "pricing-application"),
("pricing-application", "pricing-domain"),
]
AFTER_EDGES = [
("pricing-adapters", "pricing-application"),
("pricing-application", "pricing-domain"),
]
EXPECTED_IMPACT = {
"pricing-domain",
"pricing-application",
"pricing-adapters",
}
def cycles(edges):
graph = {module: [] for module in MODULES}
for source, target in edges:
graph[source].append(target)
found = set()
def visit(node, path):
if node in path:
cycle = path[path.index(node):] + [node]
# Rotation-normalization prevents one cycle being counted from
# every possible start node.
body = cycle[:-1]
rotations = [tuple(body[index:] + body[:index]) for index in range(len(body))]
found.add(min(rotations))
return
for target in graph[node]:
visit(target, path + [node])
for module in MODULES:
visit(module, [])
return [list(cycle) + [cycle[0]] for cycle in sorted(found)]
def direction_violations(edges):
return [
{"source": source, "target": target}
for source, target in edges
if RANK[source] <= RANK[target]
]
def snapshot(edges):
return {
"modules": MODULES,
"edges": [
{"source": source, "target": target}
for source, target in edges
],
"cycles": cycles(edges),
"direction_violations": direction_violations(edges),
}
def reverse_transitive_impact(edges, target):
reverse_graph = {module: [] for module in MODULES}
for source, dependency in edges:
reverse_graph[dependency].append(source)
impacted = {target}
pending = [target]
while pending:
dependency = pending.pop()
for dependent in reverse_graph[dependency]:
if dependent not in impacted:
impacted.add(dependent)
pending.append(dependent)
return sorted(impacted)
def impact_snapshot(edges, target):
actual = set(reverse_transitive_impact(edges, target))
unexpected = actual - EXPECTED_IMPACT
missing = EXPECTED_IMPACT - actual
return {
"target": target,
"actual_impacted_modules": sorted(actual),
"expected_impacted_modules": sorted(EXPECTED_IMPACT),
"unexpected_impacted_modules": sorted(unexpected),
"missing_expected_modules": sorted(missing),
"unexpected_count": len(unexpected),
"missing_count": len(missing),
}
criteria = {
"change_locality": {
"weight": 0.4,
"scale": {
"1": "weekly pricing changes reach every module",
"2": "pricing changes still reach most adapters",
"3": "most pricing changes stay behind one interface",
"4": "pricing changes stay local with one known exception",
"5": "pricing changes stay inside the domain boundary",
},
},
"migration_cost": {
"weight": 0.2,
"scale": {
"1": "requires a coordinated data and consumer migration",
"2": "requires multiple coordinated migration windows",
"3": "requires a bounded dual-path transition",
"4": "requires one reversible compatibility step",
"5": "preserves consumers during an incremental migration",
},
},
"operability": {
"weight": 0.2,
"scale": {
"1": "adds unowned runtime failure modes",
"2": "adds owned failure modes without automated recovery",
"3": "adds monitored failure modes with manual recovery",
"4": "uses owned alerts with rehearsed manual recovery",
"5": "uses existing ownership and automated recovery",
},
},
"reversibility": {
"weight": 0.2,
"scale": {
"1": "rollback requires data reconstruction",
"2": "rollback requires dual writes and reconciliation",
"3": "rollback needs a planned compatibility window",
"4": "rollback is isolated but not yet rehearsed",
"5": "rollback is isolated and rehearsed",
},
},
}
option_inputs = [
{
"id": "preserve-domain-boundary",
"ratings": {
"change_locality": {
"rating": 5,
"evidence_kind": "synthetic-fixture-observation",
"evidence": "six recent pricing changes touched only domain tests",
},
"migration_cost": {
"rating": 3,
"evidence_kind": "synthetic-fixture-observation",
"evidence": "two adapters require a thirty-day dual path",
},
"operability": {
"rating": 4,
"evidence_kind": "synthetic-fixture-observation",
"evidence": "existing service ownership and alerts remain valid",
},
"reversibility": {
"rating": 4,
"evidence_kind": "synthetic-fixture-observation",
"evidence": "the old adapter stays readable during migration",
},
},
"risks": ["dual-path-drift"],
},
{
"id": "temporary-facade",
"ratings": {
"change_locality": {
"rating": 3,
"evidence_kind": "synthetic-fixture-observation",
"evidence": "facade localizes calls but duplicates two rules",
},
"migration_cost": {
"rating": 5,
"evidence_kind": "synthetic-fixture-observation",
"evidence": "all current consumers keep their contract",
},
"operability": {
"rating": 4,
"evidence_kind": "synthetic-fixture-observation",
"evidence": "facade uses existing dashboards and ownership",
},
"reversibility": {
"rating": 3,
"evidence_kind": "synthetic-fixture-observation",
"evidence": "rollback is possible while both routes remain",
},
},
"risks": ["temporary-layer-becomes-permanent"],
},
{
"id": "extract-service",
"ratings": {
"change_locality": {
"rating": 4,
"evidence_kind": "synthetic-fixture-observation",
"evidence": "process boundary isolates code but not shared data",
},
"migration_cost": {
"rating": 1,
"evidence_kind": "synthetic-fixture-observation",
"evidence": "requires data ownership and consumer migration",
},
"operability": {
"rating": 1,
"evidence_kind": "synthetic-fixture-observation",
"evidence": "introduces an unowned network dependency",
},
"reversibility": {
"rating": 2,
"evidence_kind": "synthetic-fixture-observation",
"evidence": "rollback needs dual writes and data reconciliation",
},
},
"risks": ["distributed-consistency", "network-failure"],
},
]
def score_option(option):
numerator = sum(
rating["rating"] * criteria[criterion_id]["weight"]
for criterion_id, rating in option["ratings"].items()
)
denominator = sum(
criteria[criterion_id]["weight"]
for criterion_id in option["ratings"]
)
return numerator / denominator
options = [
{**option, "score": score_option(option)}
for option in option_inputs
]
selected_option = max(options, key=lambda option: option["score"])["id"]
change_impact = impact_snapshot(AFTER_EDGES, "pricing-domain")
impact_mutations = {
"edge_removed": impact_snapshot(
[
edge
for edge in AFTER_EDGES
if edge != ("pricing-application", "pricing-domain")
],
"pricing-domain",
),
"target_changed": impact_snapshot(AFTER_EDGES, "reporting"),
}
assert abs(sum(item["weight"] for item in criteria.values()) - 1.0) < 1e-9
assert set(change_impact["actual_impacted_modules"]) == EXPECTED_IMPACT
assert change_impact["unexpected_impacted_modules"] == []
assert change_impact["missing_expected_modules"] == []
assert impact_mutations["edge_removed"]["unexpected_impacted_modules"] == []
assert set(impact_mutations["edge_removed"]["missing_expected_modules"]) == {
"pricing-application",
"pricing-adapters",
}
assert impact_mutations["target_changed"]["unexpected_impacted_modules"] == [
"reporting"
]
assert set(
impact_mutations["target_changed"]["missing_expected_modules"]
) == EXPECTED_IMPACT
report = {
"harness": HARNESS,
"fixture": "pricing-change-hotspot-v1",
"fixture_metadata": {
"kind": "synthetic",
"purpose": "teach deterministic dependency and decision analysis",
"provenance": "lesson-defined synthetic scenario",
"limitations": [
"not extracted from a repository",
"not supported by production change-history measurements",
"scores are teaching inputs rather than organizational evidence",
],
},
"before": snapshot(BEFORE_EDGES),
"after": snapshot(AFTER_EDGES),
"criteria": criteria,
"options": options,
"selected_option": selected_option,
"adr": {
"status": "accepted",
"context": "pricing rules change weekly and leaked into three adapters",
"decision": selected_option,
"alternatives": [
option["id"]
for option in options
if option["id"] != selected_option
],
"positive_consequences": ["localized rule changes", "testable domain"],
"negative_consequences": ["temporary dual path", "port maintenance"],
"confirmation": "dependency gate plus monthly change-impact review",
},
"change_impact": change_impact,
"impact_mutations": impact_mutations,
"external_network_used": False,
}
print(json.dumps(report, ensure_ascii=False, sort_keys=True))
PY
トレードオフと失敗モード
- 誤診: file、package、microserviceが多いほどmodularityは高い。 反証: 同じdecisionが複製され、変更が同時に波及するなら物理分割は境界を作っていない。
- 誤診: ISOの要素を満たす図とaccepted ADRがあればarchitecture品質は保証される。 反証: 記述はstakeholderの判断を支えるが、観測、代替案、反証条件が誤っていれば判断も誤る。
- 分散化: 独立deployは有益だが、network、data ownership、障害対応、version互換性の費用を追加する。
- 過剰な抽象: 仮説上の将来変化だけでinterfaceを増やすと、実際の変更経路が見えにくくなる。
知識チェック
- runtimeでadapterを呼ぶdomainから、source dependencyを外向きにしない方法を説明せよ。
- 同じ三moduleでも、どの変更履歴なら境界を分け直すか。
- cycle検出が0でもmodularityが悪い例を一つ挙げよ。
- architecture viewの矢印へ最低限どの意味を付けるべきか。
- ADRのnegative consequenceをconfirmationへどう接続するか。
出典と次の学習
Parnasのmodule分解論は処理手順ではなくdesign decisionの隠蔽を中心に置く。ISO/IEC/IEEE 42010とViews and Beyondはstakeholder、concern、viewpoint、viewの記述を整理する。MADRはcontext、decision、alternatives、consequencesを更新可能な記録にする。
次はcore09で、module境界の期待を実行可能なtestへ落とす。変更ごとにimpactを記録し、30日reviewで予測と実測がずれたADRを更新する。
実践ラボ
料金計算hotspotの依存を反転しADRへ残す
提出成果物: 変更理由と依存方向を説明するモジュール図とADR
- 料金規則、use case、外部adapterのmoduleと依存edgeを固定fixtureとして列挙する
- 変更前graphのcycleとdomainから外側へ向く方向違反を検出する
- 境界維持、一時的facade、service分離の三案を、weightと1〜5の全anchorを固定したcriteria、rating evidence、riskで比較する
- 選択案で依存を外側から内側へ直し、変更targetからreverse transitive closureを求め、edge削除・target変更mutationとの差を再計算する
- context、decision、alternatives、正負の帰結、confirmationをADRへ記録する
説明して理解を確かめる
5分で、module数を増やすだけではmodularityが改善しない理由、依存方向とruntime call方向の違い、ADRがarchitecture品質を保証しない理由を説明する。
アセスメント
問い: 料金規則の変更でUI、DB、batchの三moduleが毎回変わる。どの証拠を集め、何を隠蔽するか。
期待する証拠: 変更履歴、知識の重複、interface、依存edge、consumer影響を基にした境界仮説と反証
問い: ADRはacceptedだが運用障害が増えた。記録があることと判断が妥当であることをどう区別するか。
期待する証拠: confirmation、負の帰結、観測指標、置換条件、stakeholder concernを用いた再評価
別問題へ転用する
変更頻度が高い料金計算モジュールの依存方向を再設計し、ADRで判断を更新する
復習スケジュール
- 1日後
変更理由とruntime call方向が一致しない例を一つ示す
- 7日後
accepted ADRを置換するconfirmationと反証条件を挙げる
- 30日後
module graphのcycleが常に悪いと断定できない理由を説明する
- 90日後
変更理由とruntime call方向が一致しない例を一つ示す
評価ルーブリック
| 観点 | 未達 | 発展途上 | 熟達 | 卓越 |
|---|---|---|---|---|
| technical-correctness | directoryやserviceの個数だけをmodule境界の根拠にする | 変更理由を挙げるが、依存方向またはcycleを検査しない | 隠蔽するdecision、interface、依存方向、cycle、複数viewを整合させる | build-timeとruntimeの関係、移行edge、観測可能なfitness条件まで表現する |
| judgment | 選択肢なしに分割またはservice化を結論とする | 複数案はあるが、criteriaと負の帰結が揃わない | 変更結合、独立性、運用費、可逆性を同じ尺度で比較する | 移行期間、組織境界、将来の不確実性を反証条件へ変換する |
| evidence | 理想図だけを示し、現状edgeや変更履歴がない | graphはあるが、cycle検査または変更影響の比較が再現できない | 変更前後のedge、cycle、方向違反、impactを同じfixtureで出力する | repositoryのdependency検査と運用指標をADR confirmationへ自動接続する |
| communication | 箱と矢印だけで、矢印の意味と対象stakeholderが不明である | 一つのviewは説明するが、異なるconcernを混在させる | viewごとの目的、element、関係、decision、帰結を明示する | 新規参加者が図とADRだけで変更経路と再評価条件を追跡できる |
出典
以下の外部資料は利用者が選択したときだけ開きます。
- On the Criteria To Be Used in Decomposing Systems into Modules (primary)
- ISO/IEC/IEEE 42010:2022 - Software, systems and enterprise — Architecture description (standard)
- Documenting Software Architectures: Views and Beyond, Second Edition (primary)
- MADR 4.0.0 — The Markdown Architectural Decision Records (primary)