0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「ここから DB を直接触らないで」をレビューで言い続けない — 設計のルールをコードに埋め込んで、AI にも守らせる

0
Posted at

はじめに

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 ファイルに詰め込まれていても、言語やフレームワークは文句を言いません。
設計のルールは、コードを動かす側にとっては何の意味も持っていないんです。

image.png

こういう 1 行は、1 本ずつ見れば小さなずれです。
でも毎日少しずつ積み上がると、実際の依存はいつの間にか設計図と似ても似つかなくなります。
「描いたパターンがコードの上で見えないなら、そのパターンは存在しないのと同じ」という一文を読んで、かなり刺さりました。

ルールの置き場所で、気づくタイミングが変わる

じゃあ、このルールは今どこにあるのか。
多くのチームでは、README や Wiki、AI 向けの指示ファイル(CLAUDE.md など)、それとコードレビューだと思います。
置き場所ごとに、破られたとき誰がいつ気づくかを比べてみました。

置き場所 破ったとき誰が気づくか いつ気づくか
README/Wiki 読んで覚えていた人 覚えていれば、書いているとき
AI 向けの指示ファイル 指示を読んだ AI 指示に従えば、書いているとき(従ったかの確認はない)
コードレビュー(人・AI) レビューする人や AI 差分の中で見つければ、PR を出したあと
テスト(手元と CI) テストを走らせた誰でも 走らせた時点で、毎回

上の 3 つは、どれも「そのとき覚えているか、気づくか」に頼っています。
AI がコードを書く量が増えると、レビューで見る差分も増えます。
1 日に何本も PR を見ていれば、import 1 行の違反を見落としても不思議じゃありません。
最後の行のテストだけが、誰も覚えていなくても毎回落ちます。

見える → 破ると落ちる → 直し方が分かる、の 3 段で埋め込む

なら、ルールをテストの側へ移せばいいわけです。
移し方を整理してみると、3 つの段がありました。

image.png

見える: 名前と置き場所を設計の言葉にそろえる

最初の段は、設計図で使っている言葉とディレクトリ名をそろえることでした。
設計で「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 が混じっていたら落とすだけです。

tests/test_layer_rules.py
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 が繰り返し破るルールが、テストに移す候補になります。

image.png

テストに移したルールの分だけ、レビューで import の向きを目で追う時間が減ります。
その分を、「この処理は本当に services に置くべきか」のような、読んで判断しないと決まらないところに使えます。
レビューがいらなくなるわけじゃなくて、見る場所が変わる、というのが自分には一番しっくりきました。

おわりに

まず、今週のレビューコメントを見返して、同じ指摘が 2 回目になっているものを 1 つ選んでみます。
選んだら、さっきのテストをコピーして、禁止する import と失敗メッセージの 2 か所だけ書き換えます。
次に AI が同じ import を書いたときは、レビューより先にテストが止めて、直し方まで伝えてくれるはずです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?