はじめに
AI に「注文一覧 API に件数も返して」と頼むと、API のハンドラからリポジトリを直接呼ぶコードが返ってきて、テストもちゃんと通ったりします。
「API からは services を通す」というルールは、README にも AI 向けの指示ファイルにも書いてあるのに、です。
TL;DR
- 設計のルールは、コードに埋め込まないと守られない
- 破ればテストが落ち、失敗メッセージに直し方を書く
- 機械で判定できるルールから埋め込む
設計図の矢印は、コードのどこにもない
README に書いたルールがコードの中で守られないのは、ルールの置き場所に原因がありました。
例: 注文 API の 3 層と、依存のルール
例として、EC サイトの注文まわりの Web API を考えます。
コードは 3 つの層に分かれています。
-
app/api: HTTP リクエストを受けて、レスポンスを返す -
app/services: 権限チェックと業務ルール -
app/repositories: DB アクセス
設計で決めたルールの 1 つに、「api は repositories を直接使わない」があります。
こういう、どの層がどの層を使ってよいか(import してよいか)の決まりを、この記事では 依存のルール と呼びます。
ここで許されているのは、api → services → repositories の一方向だけです。
権限チェックや業務ルールを services に集めているので、api から repositories を直接呼ぶと、そのチェックを素通りできてしまいます。
言語は、設計図の矢印を知らない
ただ、この矢印を知っているのは、設計図を描いた人間だけなんですよね。
Python は、app/api/orders.py に次の 1 行が足されても、何も言わずに動かします。
from app.repositories.order_repository import count_orders
全部のコードが 1 ファイルに詰め込まれていても、言語やフレームワークは文句を言いません。
設計のルールは、コードを動かす側にとっては何の意味も持っていないんです。
こういう 1 行は、1 本ずつ見れば小さなずれです。
でも毎日少しずつ積み上がると、実際の依存はいつの間にか設計図と似ても似つかなくなります。
「描いたパターンがコードの上で見えないなら、そのパターンは存在しないのと同じ」という一文を読んで、かなり刺さりました。
ルールの置き場所で、気づくタイミングが変わる
じゃあ、このルールは今どこにあるのか。
多くのチームでは、README や Wiki、AI 向けの指示ファイル(CLAUDE.md など)、それとコードレビューだと思います。
置き場所ごとに、破られたとき誰がいつ気づくかを比べてみました。
| 置き場所 | 破ったとき誰が気づくか | いつ気づくか |
|---|---|---|
| README/Wiki | 読んで覚えていた人 | 覚えていれば、書いているとき |
| AI 向けの指示ファイル | 指示を読んだ AI | 指示に従えば、書いているとき(従ったかの確認はない) |
| コードレビュー(人・AI) | レビューする人や AI | 差分の中で見つければ、PR を出したあと |
| テスト(手元と CI) | テストを走らせた誰でも | 走らせた時点で、毎回 |
上の 3 つは、どれも「そのとき覚えているか、気づくか」に頼っています。
AI がコードを書く量が増えると、レビューで見る差分も増えます。
1 日に何本も PR を見ていれば、import 1 行の違反を見落としても不思議じゃありません。
最後の行のテストだけが、誰も覚えていなくても毎回落ちます。
見える → 破ると落ちる → 直し方が分かる、の 3 段で埋め込む
なら、ルールをテストの側へ移せばいいわけです。
移し方を整理してみると、3 つの段がありました。
見える: 名前と置き場所を設計の言葉にそろえる
最初の段は、設計図で使っている言葉とディレクトリ名をそろえることでした。
設計で「services」と呼んでいるものが、コードでは helpers/ や manager.py だったりします。
これだと、読む人も AI も、どれが設計図のどの箱なのか結びつけられません。
# Before: 設計図の箱とディレクトリが対応しない
app/
├── handlers/ # API の受け口?
├── helpers/ # 業務ルールと DB アクセスが混在
├── utils/
└── models/
# After: 設計図と同じ名前で並ぶ
app/
├── api/ # HTTP の受け口
├── services/ # 権限チェックと業務ルール
└── repositories/ # DB アクセス
After のほうは、ディレクトリを見ただけで設計図の 3 つの箱が分かります。
しかも置き場所が設計の言葉でそろうと、ルールをそのままテストに書ける形になります。
「app/api のファイルは app.repositories を import しない」と書けばいいからです。
ただ、見えるだけでは、破ろうと思えば破れます。
破ると落ちる: 依存のルールをテストにする
依存のルールは、標準ライブラリの ast・pathlib と pytest だけでテストにできました。
やっていることは、app/api 配下のファイルの import 文を読んで、app.repositories が混じっていたら落とすだけです。
import ast
from pathlib import Path
def imported_modules(path):
package = ".".join(path.parent.parts) # app/api/orders.py -> "app.api"
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
yield alias.name
elif isinstance(node, ast.ImportFrom):
# 相対 import(from ..repositories import x)は絶対名に直す
base = package.rsplit(".", node.level - 1)[0] if node.level else ""
module = ".".join(p for p in (base, node.module) if p)
for alias in node.names:
yield f"{module}.{alias.name}"
def test_api_does_not_import_repositories():
# 対象が 0 件のまま黙って通らないように、先に場所を確かめる
assert Path("app/api").is_dir(), "app/api が見つかりません。リポジトリのルートで実行してください"
violations = [
f"{path}: {module}"
for path in Path("app/api").rglob("*.py")
for module in imported_modules(path)
if module == "app.repositories" or module.startswith("app.repositories.")
]
message = "api から repositories を直接 import しています: " + ", ".join(violations)
assert not violations, message
ast は Python のコードを構文木として読む標準ライブラリで、ファイルを実行せずに import 文だけを取り出せます。
手元の Pythonで試すと、さっきの 1 行はちゃんと落ちて、書き方を変えても結果は同じでした。
-
from app import repositories→ 落ちる -
..repositoriesからの相対 import → 落ちる -
from ..services import order_service→ 通る
見ているのは字面の import だけなので、importlib で文字列から読み込むような動的な import までは拾いません。
AI エージェントにも「変更したらテストを回す」よう指示しておけば、さっきの 1 行はその場で落ちます。
ルールが増えてきたら、言語ごとの既製ツールに移すのもアリだと思います。
Python なら import-linter、Java なら ArchUnit、JS・TS なら dependency-cruiser などです。
直し方が分かる: 落ちたときのメッセージに、理由と直し方を書く
落ちるようにはなったものの、さっきのテストが落ちたときに出るのは次の 1 行です(pytest の出力の抜粋)。
AssertionError: api から repositories を直接 import しています: app/api/orders.py: app.repositories.order_repository.count_orders
どこが違反かは分かっても、なんでダメか・どう直すかは書いてありません。
例外メッセージは「エラーが起きました」のような汎用の文より、破られた前提を書いたもののほうが役に立つ。
この考え方を読んで、テストの失敗メッセージにもそのまま使えると思いました。
テストの message の中身だけを、次のように差し替えます。
message = (
"[依存のルール違反] app.api から app.repositories を直接 import しています。\n"
"理由: 権限チェックと業務ルールを app.services に集めているためです。\n"
"直し方: app.services に関数を足して、api からはそれを呼んでください。\n"
"このテストを緩めたり skip したりして通さないでください。\n"
"違反箇所:\n" + "\n".join(violations)
)
差し替えたあとに同じ違反で落とすと、こう出ます(同じく抜粋)。
AssertionError: [依存のルール違反] app.api から app.repositories を直接 import しています。
理由: 権限チェックと業務ルールを app.services に集めているためです。
直し方: app.services に関数を足して、api からはそれを呼んでください。
このテストを緩めたり skip したりして通さないでください。
違反箇所:
app/api/orders.py: app.repositories.order_repository.count_orders
これなら PR を出した人も AI も、メッセージを読んだだけで services 経由に直せます。
「緩めない」の 1 行は、落ちたテストのほうを書き換えて通そうとする AI がいるので入れました。
手元で回し忘れても、PR を出した時点で CI が同じテストで止めます。
レビューで「ここは services を通してください」とコメントしていたことを、テストが先に言ってくれます。
全部は埋め込めない。機械で判定できるルールから
1 本のルールがテストになると、ほかのルールも全部テストにしたくなります。
ただ、チームでよく出てきそうなルールを並べてみると、テストにできないものもけっこうありました。
判定できるルールと、できないルール
分かれ目は、コードの字面だけで判定できるかどうかでした。
| 設計のルール | 機械で判定できるか | どこで守るか |
|---|---|---|
| api は repositories を import しない | ○ import 文で分かる | テスト |
| 層の間で循環して import しない | ○ 逆向き(下の層から上の層)の import を禁止すれば防げる | テスト |
| ORM のモジュールは repositories の中でだけ import する | ○ import 文で分かる | テスト |
| services に業務の判断を集める | × 何が業務の判断かは、読まないと分からない | レビュー |
| 1 つの service を大きくしすぎない | △ 行数は数えられるが、どこからが大きすぎるかは判断が要る | レビュー |
上の 3 行は import の向きと場所の話なので、さっきと同じ形のテストで書けます。
下の 2 行は、コードの意味を読んで判断しないと決まりません。
設計とコードの隙間はテストで縮められても、ゼロにはならないんですよね。
あと、テストにしたルールにも 1 つ穴が残ります。
テストファイル自体を書き換えれば、違反は通せます。
なので、依存のテストへの変更は人が見るようにしておきます。
GitHub なら、CODEOWNERS でテストファイルに担当者を付けます。
そのうえでブランチ保護の設定で「Code Owners のレビューを必須」にしておけば、テストを緩める PR はその人の承認なしではマージできなくなります。
テストに渡した分だけ、レビューは判断に使える
全部のルールを一度にテストにしなくても大丈夫です。
レビューで同じ指摘が 2 回目になったルールや、AI が繰り返し破るルールが、テストに移す候補になります。
テストに移したルールの分だけ、レビューで import の向きを目で追う時間が減ります。
その分を、「この処理は本当に services に置くべきか」のような、読んで判断しないと決まらないところに使えます。
レビューがいらなくなるわけじゃなくて、見る場所が変わる、というのが自分には一番しっくりきました。
おわりに
まず、今週のレビューコメントを見返して、同じ指摘が 2 回目になっているものを 1 つ選んでみます。
選んだら、さっきのテストをコピーして、禁止する import と失敗メッセージの 2 か所だけ書き換えます。
次に AI が同じ import を書いたときは、レビューより先にテストが止めて、直し方まで伝えてくれるはずです。


