AIレビューの指摘の中で、コード自体は健全なのに直す価値があった、という記録です。
このプロジェクトには、ファイル同士の依存の向きについてルールがあります。「純粋な層は Flutter を知らない」「画面から DB を直接触らない」といったやつ。
守り方は、テストで見張る形にしてあります。
// ルール名 → そのルールに違反している既知のファイル一覧
// 中身が空=今は既知の違反ゼロ
const Map<String, Set<String>> importAllowlist = {
'domain-purity': {}, // 純粋な層が Flutter や DB を知らない
'presentation-db': {}, // 画面から DB を直接触らない
'core-to-features': {}, // 共通の土台から個別機能を見にいかない
'cross-feature-internal': {}, // 他の機能の中身を直接指さない(窓口ファイル経由だけ可)
};lib/ の全ファイルを走査して import を集め、ルールに違反していたらテストが落ちます。既知の違反は上のリストに載せておきます。違反は増やせないし、減った分は元に戻せません。数は減る方向にしか動きません=一方向のラチェット(逆回しができない歯車)。
この仕組みを入れたときの話は「既存の違反は責めず、新しい違反だけ止める — アーキテクチャ検査を一方向ラチェットにする」に書いた。増やさない側と戻せない側をどう作ったかはそっち。上のリストが全部空なのは、そのとき34件あった既知の違反が、その後ゼロになったから。
これで守っているルールは4本。ところが、ルールは全部で5本あります。
5本目だけ、テストの外にあった
5本目はこれ。AI への指示書(CLAUDE.md)にこう書いてあります。
ルーター (
app/router/) はscreens/しか import しない。widgets/を直接ルートに指すのはレイヤ違反。
太字で、断定で、いちばん強い言い方をしています。人間にも AI にも、これがルールだと分かるように書いた。
後から見ると、形が違っていた。他の4本は「これを import してはいけない」という禁止の形をしている。禁止先を1つ挙げれば検査になる。5本目だけが「これしか import しない」という許可の形で、許していい先を全部決めてからでないと1行も書けない。
チェックを書いたら、知らない違反が2件出た
5本目として router-scope を足した。ルーターの中から features/ を指す import を拾って、presentation/screens/ 以外なら違反、という形にした。
走らせたら落ちた。落ちたときテストが出したメッセージがこれ。// の右が、実際に指していた import 先。
新しい依存ルール違反があります。直すのが原則。
'router-scope': {
'app/router/app_router.dart', // ../../features/onboarding/application/providers/onboarding_provider.dart
},
1件目。ルーターがオンボーディングの provider(「オンボーディング済みか」の状態を持って配る係)を読んでいた。「オンボーディング済みかどうか」で最初の行き先を決めるため。
これはレビューの指摘に挙がっていたので想定内だった。想定外だったのは、その1件を許した後にもう1回落ちたこと。
'router-scope': {
'app/router/route_payloads.dart', // ../../features/tsuzuru/domain/models/tsuzuru_args.dart
},
2件目。ルートに渡す引数の型が、機能側の純粋な層に置いてあった。
レビューが挙げていたのは1件だけで、2件目は検査を書いて走らせるまで誰も知らなかった。自分も知らなかった。
コードは健全で、ルール文のほうが間違っていた
2件とも、コードとしては妥当だった。
- ルーターは composition root(アプリ全体を組み立てる唯一の場所)なので、行き先を決めるために状態を読むのは役割どおり
- ルートの引数に機能側の型を使うのも自然。ルートの語彙そのものなので
しかも、ルールの正本ドキュメントは一つの文書の中で矛盾していた。上のほうに依存の図があって、そこには「app(router/di)は全層を組み立ててよい(唯一の composition root)」と書いてあります。その数行下に「router は screens/ だけ」と書いてあった。
打ち手は3つあった。
| 打ち手 | 効くこと | 払うもの |
|---|---|---|
| コードを直す(ルーターから provider 参照を外す) | ルール文をそのまま守れる | 行き先の判定を別の場所へ追い出すことになる。composition root の役割としては不自然 |
| 許可リストに2件載せる | その場は緑。作業が止まらない | 「既知の違反」が居座る。ルール文が実態と食い違ったまま固定される |
| ルール文の線を引き直す | 正本とコードが一致する。今後の判定が効く | 線をどこに引くか自分で決める必要がある。決め方を間違うと検査ごと嘘になる |
なので、直したのはコードではなくルール文のほう。
線をこう引き直した。
if (file.startsWith('app/router/') && // ルーターの中のファイルで
resolved != null &&
resolved.startsWith('features/')) { // 機能側を指す import なら
final allowed = RegExp(
// 許す行き先を並べたリスト。[^/]+ の部分は「機能名は何でもいい」の意味
r'^features/[^/]+/(presentation/screens|application/providers|domain)/',
).hasMatch(resolved);
if (!allowed) {
yield Violation('router-scope', file, uri); // 違反として報告=テストが落ちる
}
}許すものを数える書き方(肯定リスト)にしてあります。「これはダメ」を並べる形にすると、機能側に新しい層が生まれた日に素通しします。
ルールの正本と CLAUDE.md の両方を同時に直した。片方だけ直すと、また食い違いが残ります。
チェックが「落ちるべき時に落ちるか」を確かめた
新しい検査は、通ることを確かめただけでは意味がありません。守りたい形を実際に壊して、落ちるかを見た。
ルーターにわざと部品の import を1行足して走らせたら、ちゃんと落ちた。確認したら消した。
これをやる理由は前にも痛い目を見ていて、CI に入れた別の検査が、入れた直後から一度も機能していなかったことがある(git diff で判定していたが、守りたい変化が「ファイルの追加」だったので見えていなかった)。導入の次にスキーマを変えた時から、15日間ずっと緑のままだった。緑は「通った」の意味かもしれませんし「届いていない」の意味かもしれません。
なぜ急いだか
次にルーターを触るのが、アプリのリンク方式の移行(App Links)だと分かっているから。
外部から特定の画面へ飛ばす仕組みを入れる作業なので、ルーターを大きく書き換える。その時に部品を直接ルートに指しても、検査が無ければ緑のまま通ってしまいます。触る直前に閉じておきたかった。
学び
- ルールを検査に載せる作業自体が、実態調査になる。載せる前に「実態は把握している」と思わないほうがいいです。今回は把握できていなかった
- 検査が落ちたら、まずコードとルール文のどちらが間違っているかを見る。今回はコードが正しくてルール文が間違っていた。ルール文が正本の中で自己矛盾していたのも、載せるまで気づかなかった
- 肯定リストは、正当な新しい層が生まれた日にも落ちる。それは失敗ではなく、線を引き直す判断をそのたび迫る仕組みのほう
指示書でいちばん強く書いた1本が、いちばん長く実態と離れていた。書き方の強さは、実態を把握していることを意味しません。
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。