綴Photo の勉強ノートに戻る
技術ブログ2026.07.11

既存の違反は責めず、新しい違反だけ止める — アーキテクチャ検査を一方向ラチェットにする

DartFlutterアーキテクチャテスト綴Photo

綴Photo に「どの層がどの層を import してよいか」を検査するテストを入れたときの、既存の違反をどう扱うか決めた判断メモです。

文書だけのルールは、自分でも破る

アーキテクチャのレビューで、層の依存方向がところどころ逆流しているのが見えた。UI を描く層が DB を直接触っていたり、「純粋なルールだけ置く」はずの層が状態管理ライブラリ(Riverpod)や写真取得のライブラリを import していたり。数えたら34箇所あった。core(共通置き場)が個別機能のエラー型を知っている、という逆流も混じっていた。

こういうルールは、CLAUDE.md みたいな文書に「domain は Flutter を import しない」と書くことはできる。でも書くだけだと守られない。特に自分の開発はセッションをまたぐと文脈が飛ぶ ── 前に決めたルールを、次の日には自分(と AI)があっさり破る。

しかも、書いた瞬間はどれも正しく見える。さっきの core への逆流(共通の通知クラスが個別機能のエラー型を import している)にしても、着地するまでの道筋は一手ずつ自然だ。通知は全画面で出すものだから共通置き場に置く。次に「保存に失敗したときの文言も、この通知から出したい」となる。文言を出し分けるには、保存の失敗が何だったか(エラーの種類)を見る必要がある。だから機能側のエラー型を import する。

一手ずつは全部まっとうで、「層をまたいだぞ」という自覚が出る瞬間がどこにも無い。文書のルールが効かないのは、読み飛ばされるからというより、破っている自覚が無いまま通過できてしまうからだった。だから、通過するタイミングで機械に止めてもらう必要があった。

flutter test で回るテストとして、違反を書いたら CI が赤くなる形にする。

引っかかったのはここからで、34箇所を今すぐ全部直すという選択が現実的じゃなかった。層の逆流を直すには、ファイルを別の層へ移したり、複数の場所で共有していたデータの形(型)を切り出して置き直したりする作業がいる。一気にやると、アプリの機能は1つも変わっていないのにコードの広い範囲が動く ── 回帰(前まで動いていた所が巻き添えで壊れること)のリスクがでかい。しかもこの種の「動きは変えずに中身だけ整理する作業」(リファクタ)は、実機で確かめにくい。どの画面を見ても前と同じに見えるのが正しい状態なので、検証が一番しんどい種類の変更になる。

全部直すか、諦めるかの二択じゃなかった

理想を全面適用する(34箇所を今すぐ直す)か、ルールを緩める(検査しない)か。最初はこの二択に見えた。

そこを分けたのが「一方向ラチェット」の発想だった。既存の34箇所は既知として記録しておいて検査を通す。新しく増えた違反だけを止める。そして一度直したものは、黙って元に戻せないようにする。

やることは2段構えになった。既存違反を記録した allowlist ── 「この34箇所は既に知っている違反なので、今回は見逃す」と書いた一覧 ── を1個持って、テストがそれと突き合わせる。突き合わせ方に「増やさない」と「戻さない」の両方を仕込む。

突き合わせと stale 検出

検査の本体は拍子抜けするくらい素直で、アプリを1回も動かさない。lib/(Flutter アプリの自作コードが全部入るフォルダ)以下の .dart ファイル(自動生成物は除く)を1枚ずつ開いて、先頭の import / export 行だけを読む。関数の中身も、画面の挙動も見ない。読み取るのは「このファイルが、どのファイルを指しているか」という線の向きだけで、その向きがルールに反していたら(domain が DB を触る、UI が DB を触る、など)違反として1件数える。

例: 画面のファイルが drift(DB のライブラリ)を直接 import していた場合。photo_screen.dart(presentation 層)▼ 検査が読むのはここだけimport 'package:flutter/material.dart';import 'package:drift/drift.dart';← 違反import '../domain/photo.dart';▽ ここから下は開かないclass PhotoScreen extends ... { Widget build(...) { ... }}その行が指す先を、層の順番と突き合わせる✕ DB を直接 importpresentation(画面)domain(ルール)data / DBこの向きはOK✕ 逆流検査が見るのは「どのファイルが、どのファイルを指しているか」だけ。関数の中身も、画面の挙動も、一度も動かさない ── 見ているのは構造だけで、挙動は守っていない。

行を正規表現で見るだけで、Dart のコードを本格的に構文解析するところまではやっていない ── dart format を通したコードなら import は1行に収まるので、それで足りると割り切った。実装は AI に書いてもらっている。自分が決めたのは、集めた違反を allowlist とどう突き合わせるか ── その一点だけだ。

dart
// used = 「allowlist の記録を実際に使った」印。fresh = 新規違反の置き場
for (final v in violations) {               // 見つけた違反を1件ずつ見ていく
  // ?? は「左が空なら右を使う」。そのルールの記録が無ければ空っぽの集合として扱う
  final allowed = importAllowlist[v.rule] ?? const <String>{};
  if (allowed.contains(v.file)) {
    used.putIfAbsent(v.rule, () => <String>{}).add(v.file); // 既知=スルー、ただし「使った」と記録
  } else {
    fresh.putIfAbsent(v.rule, () => []).add(v);             // allowlist に無い=新規違反
  }
}

fresh(allowlist に載っていない違反)が1個でもあればテストを落とす。これで「増やさない」は効く。新しく逆流を書いたら CI が赤くなる。

自分はここで「もう終わりだろう」と思っていた。既存を見逃して、新しいのを止める。それで十分に見える。肝はもう片方だった。allowlist に載っているのに、実際にはもう違反していないファイルを検出する部分(stale =「古くなって実態と合っていない」記録、の意味でこう呼んでいる)。

dart
// allowlist に書いてあるのに、今回の走査で一度も「使われ」なかったエントリ=もう直っている
importAllowlist.forEach((rule, files) { // allowlist の各ルールについて登録ファイル一覧を順に見る
  for (final f in files) { // そのルールに登録されているファイルを1つずつ見る
    // ← さっきのループで付けた「使った」印を見る。印が無い=今回どこにも違反が無かった
    if (!(used[rule]?.contains(f) ?? false)) { // true=一度も「使った」印が付いていない
      stale.add("  '$rule': '$f'");   // 消し忘れエントリとして溜める
    }
  }
});
 
// stale が空でなければテストを落とす(reason が赤い画面に出る催促の文言)
expect(stale, isEmpty,                // expect(調べる値,期待する値)。空かどうかを確認
    reason: '違反が解消済みです。allowlist から該当行を削除してください(戻さない)'); // reason=落ちた時に出る一言

allowlist が「まだ直っていない場所」の集合、fresh が「新しく増えた違反」、stale が「直したのに記録が残っている場所」。この3つで、違反の数は減る方向にしか動かなくなる。

1回走らせるたびに、違反は3つの行き先に分かれる。① いま走査して見つかった違反lib/ の .dart を全部開いて集めた、今日の実測② allowlist に書いてある記録先に1回走らせて数えた34件を、そのまま書き写したfresh記録に無い=新しく書いた赤 ── 増やせない既知まだ直していない(初日は34件が全部ここ)緑 ── 素通りstale直したのに記録が残っている赤 ── 戻せない左が出たら「増やした」、右が出たら「直したのに記録を消し忘れた」── どちらも赤になる。だから違反の数は減る方向にしか動かない。これがラチェットの爪。

厳密に言うと減らないのは「違反しているファイルの数」で、違反の行数ではない。照合はファイル単位なので、既に allowlist に載っているファイルに同じ種類の import をもう1行足しても、テストは黙って通る。塞げてはいないけれど、粗いまま今日から回るほうを取った。

その場で1個減らして、爪を1コマ送った

仕組みを入れた同じ作業の中で、core への逆流(共通の通知クラスが個別機能のエラー型を知っている問題)を1個直した。エラーから文言への変換を機能側へ移して、core は確定した文字列を受け取るだけにした。

直したので、allowlist から core-to-features のエントリを削除する。

dart
// テストが読みにいく allowlist の定義側。ここから1行消す
// before
'core-to-features': {
  'core/presentation/app_feedback.dart',
},
// after
'core-to-features': {},

もし削除を忘れていたら、さっきの stale 検出がテストを落として「消えたエントリを消せ」と催促してくる。34 が 33 になった。この1箇所は allowlist から締め出された。ゼロにする期日はこの時点では決めていない。触った機能のついでに1個ずつ減っていく仕組みだけを、まず残した。

学んだこと

  • 規約は「今すぐ全面」でなく「悪化を止めて漸進」で入れられる。34箇所を一括で直す回帰リスクを負わずに、規律だけ今日から効かせられた。理想の状態と、そこへ至る速度は別々に決めていい ── ここに気づくまで、自分は「全部直す or 諦める」の二択で止まっていた。
  • AI 協働だと文書ルールはあまり効かない。読み飛ばすし、セッションをまたぐと忘れる(自分も AI も)。CI で赤くするのが、跨いでも消えない唯一の伝え方だった。
  • ラチェットの肝は allowlist じゃなく stale 検出のほう。「既存を見逃す」だけなら allowlist で足りるけど、それだと直した場所が静かに元へ戻れてしまう。前進を巻き戻せなくする爪 ── 消えた違反を fail にする一手 ── が入って初めて一方向になる。

アーキテクチャの決定そのものは、この日2つに割ってあった。規約(依存方向のルールと、そのまわりの書き方の決まり)は安くて確実なので全面適用で即確定。層構造(use case のクラスを立てるか、DB のライブラリが作る型を保存の層に閉じ込めるか ── 「全面的に層を切り直す」ほう)は、1つの機能で試した実測を見てから決める、として止めていた。

この記事の検査が受け持つのは、前者だけだ。層構造がどっちに転んでも依存方向の規約は残るから、こっちは先に置けた。

依存の向きのルールはもう1本あって(「ルーターは画面のファイルしか import しない」)、それはこの時点では検査に載っていなかった。載せたのは7月末で、そのときの記録は「いちばん強く書いていたルールが、唯一チェックされていなくて、すでに破られていた」に書いた。

この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。