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?

クリーンアーキテクチャはコーディングエージェント(Claude Code)に不利か、同一仕様の2実装で64ラン測った

0
Posted at

業務ではクリーンアーキテクチャを使っています。
コード補完との相性は良いと感じていて、その感触はエージェントに変えても再現するはずだ、と思っていました。
それを確かめたくて、外部挙動が完全に同じアプリを層で分けたものと機能で分けたもので2つ書き、同じ変更タスクを投げています。

先に賭けの結果を書くと、半分当たって半分外れました。
ドメインのルールを変える作業と保存先を差し替える作業では相性の良さが再現し、機能を横断して足す作業では再現せず1.6倍かかりました。

既に層で分かれたコードベースを持っていて、エージェントの利用料が気になっている人向けの数字です。
成功率はどちらの構造でも動かなかったので、通るか通らないかを心配している人が読んでも得るものはありません。

先行研究が固定していた軸

エージェント向けの設計論はここ半年で一気に増えました。
「1つの機能を追うのにcontrollerからrepositoryまで横断させる構造はLLMに不利だ」という主張は、日本語でも英語でも読めます。
ただ、数字が付いているものを見つけられませんでした。

いちばん厳密なのは、コードの綺麗さがエージェントに効くかを660試行で調べた研究です(arXiv:2605.20049、2026-05-19)。
静的解析の違反と認知的複雑度だけを変えた対をつくり、トークンが7〜8%減り、ファイルの再訪が34%減り、成功率は変わらなかったと報告しています。
対を組むときに一致させた条件のなかに「アーキテクチャ」が入っていて、いちばん気になる軸が意図的に止めてあります。
その止められた軸を動かして同じことを測る、というのが今回やったことです。

同じ外部挙動のものを2つ書く

会議室の予約APIを題材にしました。
部屋の一覧、予約の作成、一覧、取り消しの4エンドポイント。
重複した時間帯は409、最長4時間を超えたら400、といったルールを持たせています。

層で分けたほうはdomainにエンティティとルールを置き、usecaseがリポジトリのインターフェースを持ち、adapterにHTTPとインメモリ実装を置く、教科書どおりの並びにしました。

package main

import (
	"errors"
	"fmt"
	"time"
)

const MaxDuration = 4 * time.Hour // 1件の予約が取れる最長時間

var ErrTooLong = errors.New("reservation is too long")

// Reservation は予約1件です。
type Reservation struct {
	RoomID  string
	StartAt time.Time
	EndAt   time.Time
}

// Validate は予約単体で判定できる不変条件を検査します。
func (r Reservation) Validate() error {
	if r.EndAt.Sub(r.StartAt) > MaxDuration { // 最長時間を超えていないか
		return ErrTooLong
	}
	return nil
}

// Create は usecase 層。判断は domain に委ね、自分では持たない。
func Create(r Reservation) (Reservation, error) {
	if err := r.Validate(); err != nil {
		return Reservation{}, err
	}
	return r, nil
}

func main() {
	base := time.Date(2030, 1, 1, 9, 0, 0, 0, time.UTC)
	for _, h := range []int{1, 5} { // 1時間と5時間で試す
		_, err := Create(Reservation{RoomID: "R1", StartAt: base, EndAt: base.Add(time.Duration(h) * time.Hour)})
		fmt.Printf("%dh -> %v\n", h, err)
	}
}

機能で分けたほうは受け口も検証も保存もひとつのファイルに畳んであり、同じルールがこうなります。

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"
)

const MaxDuration = 4 * time.Hour // 1件の予約が取れる最長時間

// create は受け口・検証・保存をこのファイルの中で完結させます。
func create(w http.ResponseWriter, r *http.Request) {
	start, _ := time.Parse(time.RFC3339, r.URL.Query().Get("start")) // 開始時刻の解釈
	end, _ := time.Parse(time.RFC3339, r.URL.Query().Get("end"))     // 終了時刻の解釈
	if end.Sub(start) > MaxDuration {                                // 最長時間の判断もここ
		http.Error(w, "reservation is too long", http.StatusBadRequest)
		return
	}
	w.WriteHeader(http.StatusCreated)
}

func main() {
	for _, h := range []string{"10:00:00Z", "14:00:00Z"} { // 1時間と5時間で試す
		req := httptest.NewRequest("POST", "/?start=2030-01-01T09:00:00Z&end=2030-01-01T"+h, nil)
		rec := httptest.NewRecorder()
		create(rec, req)
		fmt.Printf("%s -> %d\n", h, rec.Code)
	}
}

規模は層分割が526行13ファイル、機能分割が284行7ファイルになりました。
1.85倍の差ですが、これは揃えるべき条件ではなく測っている変数そのものなので、そのまま進めます。

同じHTTPを呼ぶe2eテストを8本書き、バイト単位で同じものを両方に置いて、両方が通ることを確認してから始めました。

01-structure.png

青が「参加人数を追加」で書き換わったファイルです。
層分割では3つのディレクトリにまたがり、機能分割では1つに収まります。

4つのタスクと、賭けた内容

変更の性質を分けて4種類にしました。
参加人数をAPIまで通す横断的な追加、時間変更のエンドポイント追加、最長時間を4時間から2時間にするルール変更、保存先をメモリからJSONファイルにする差し替え。

タスクごとに受け入れ確認のテストを別に用意し、エージェントには見せずに、作業が終わったあとで入れて実行しました。
8通り全てが変更前のコードで落ちることを先に確かめてあります。

投げ方は両実装で1文字も変えていません。

claude -p "$PROMPT" \
  --output-format stream-json --verbose \
  --model claude-sonnet-5 \
  --dangerously-skip-permissions \
  --strict-mcp-config --mcp-config '{"mcpServers":{}}' \
  > run.jsonl

2実装×4タスク×5試行で40ラン。
ログから往復数とトークンを取り出します。

import json, sys

turns = None          # エージェントが何往復したか
total_input = 0       # 入力トークン(キャッシュ読み書きを含む)
reads = set()         # 実際に開いたファイル

for line in open(sys.argv[1], encoding="utf-8", errors="replace"):
    line = line.strip()
    if not line:
        continue
    ev = json.loads(line)
    if ev.get("type") == "assistant":                       # モデルの応答1件
        for b in ev.get("message", {}).get("content", []) or []:
            if b.get("type") == "tool_use" and b.get("name") == "Read":
                reads.add(b["input"]["file_path"])          # 開いたファイルを集める
    elif ev.get("type") == "result":                        # 最後に1回だけ出る
        u = ev["usage"]
        turns = ev["num_turns"]
        total_input = u["input_tokens"] + u["cache_read_input_tokens"] + u["cache_creation_input_tokens"]

print(f"turns={turns} input={total_input:,} files_read={len(reads)} per_turn={total_input // turns:,}")

賭けたのは「補完で感じている相性の良さは、エージェントでも再現する」という一点です。

半分当たって、半分外れた

以下はいずれも5ランの中央値です。

タスク 層分割 機能分割
参加人数を追加(横断) 923,274 tok / 22往復 574,205 / 14 1.61倍
時間変更エンドポイント追加 949,870 / 27 584,321 / 16 1.63倍
最長時間を4時間から2時間へ 382,794 / 11 419,421 / 12 0.91倍
保存先をJSONファイルへ 1,598,320 / 36 1,485,272 / 30 1.08倍

隠しテストは40ラン全てで通りました。
アーキテクチャを変えても成功率は動かず、値段だけが動いています。
綺麗さの研究が「成功率は変わらず効率だけ変わる」と結論していたのと同じ形が出ています。

賭けは、下2行では当たり、上2行では外れました。
ルールの変更と保存先の差し替えでは相性の良さが再現し、機能を足す作業では1.6倍を払うことになりました。

ただし中央値の裏にある5本を並べると、そんなに綺麗な話ではありません。

t1/層分割    604,190   821,275   923,274   962,166  1,016,213
t1/機能分割  337,861   443,714   574,205   834,877    872,819

層分割の最小値が機能分割の最大値より下にあります。
1回きりの実行ではどちらが安いか決まらず、5本ずつ取ってようやく中央値が1.6倍に分かれました。

実行順による偏りも確かめました。
層分割20本を先に、機能分割20本を後に実行しているので、キャッシュが後半に有利に働きます。
1往復あたりの入力で前半と後半を比べると、層分割が-7.4%、機能分割が-7.7%。
同じ方向に同じだけ落ちていたので、比較そのものは歪んでいないと判断しました。

入力を往復数で割る

1.6倍が何なのかを分けたくて、入力トークンを往復数で割りました。

タスク 層分割 機能分割
参加人数を追加 41,967 41,015
時間変更エンドポイント追加 35,180 36,520
最長時間を4時間から2時間へ 34,799 34,952
保存先をJSONファイルへ 44,398 49,509

1往復あたりの入力が、アーキテクチャでほとんど動いていません。
同じタスクなら35,000だったり44,000だったりと、値はタスクの重さで決まっています。
差は往復の回数だけでできていました。

層を横断すると読むファイルが増えて1回の文脈が大きくなる、という絵を描いていたので、ここは予想と違いました。
増えていたのは1回の大きさではなく回数です。
実際、参加人数の追加で開いたファイルは層分割が9、機能分割が6で1.5倍。
一方で往復は22対14で1.57倍あり、トークンの比1.61倍とほぼ重なりました。

理屈は単純で、エージェントは往復のたびにそれまでの文脈を丸ごと送り直します。
だから請求額は送り直した回数で決まり、その回数は書き換えるファイルの数に引きずられます。
参加人数の追加で書き換えたファイルは層分割が4、機能分割が2でした。

増えた往復の中身も見ておきます。
道具の呼び出しを種類で分けると、参加人数の追加では層分割が読み取り9・編集6・シェル6、機能分割が読み取り6・編集5・シェル4でした。
時間変更の追加では11・6・7に対して7・3・6。
読み取りに加えて、編集した箇所を確かめるビルドとテストの実行も増えています。
書き換える場所が4つに散れば、通し直しの回数もそのぶん積まれます。

02-mechanism.png

図の1段が1往復ぶんの再送で、縦軸はそこまでに送った入力の累積です。
段の高さは両実装でほぼ同じなので、到達点の差はそのまま往復数の差になります。

綺麗さの研究が出した7〜8%と並べると位置がはっきりします。
アーキテクチャを固定したうえで綺麗さを動かすと7〜8%、綺麗さを揃えたうえでアーキテクチャを動かすと横断的な変更で61%。
同じ数え方で比べて一桁違いました。

なお、この61%が測っているのは請求額だけで、レビューのしやすさも設計の良し悪しも含みません。

触らずに済んだ層

保存先の差し替えで何が変わらなかったかを5ランぶん並べたときが、この計測でいちばん納得した場面でした。
既存ファイルの顔ぶれが層分割の5ランで揃っていて、新規ファイルの名前だけが割れています。

--- 層分割(5ラン中の出現回数)---
      5 server/server.go
      5 internal/adapter/repository/memory.go
      5 e2e/api_test.go
  [新規]  4 internal/adapter/repository/file.go
  [新規]  1 internal/adapter/repository/jsonfile.go
  [新規]  1 server/persist_manualcheck_test.go

--- 機能分割 ---
      5 internal/reservation/store.go
      5 internal/reservation/create.go
      5 internal/reservation/cancel.go
      5 e2e/api_test.go
      1 server/server.go
  [新規]  1 internal/reservation/manual_verify_test.go
  [新規]  1 internal/reservation/persist_verify_test.go
  [新規]  1 tmp_verify/persist_test.go

e2e/api_test.goは隠しテストではなく最初から置いてある8本のほうで、両実装ともエージェントがnewServerを書き換え、テストごとに別の保存先を向かせています。
出現回数1のテストファイルは、ランごとにばらけた確認用です。

層分割ではdomainusecaseが5ラン全てで1行も変わっていません。
保存の方式を丸ごと入れ替えたのに、予約のルールを書いた場所には誰も触っていない。
インターフェースの向きを逆にしておくと外側の実装が差し替え可能になる、という話は何度も読みましたが、既存の実装で触られたのが5回とも同じ2ファイルに収まるところまで見たのは初めてでした。

機能分割のほうは、保存の呼び出しが作成と取り消しのハンドラに直接書いてあるため、そこも一緒に書き換わります。
これで編集ファイル数が逆転し、中央値で層分割4、機能分割5になりました。
トークンでは1.08倍で互角ですが、触ったファイルの数は層分割のほうが少なくなっています。

同じ集計を横断タスクでやると、向きが反対になります。
参加人数の追加では、層分割がdomainusecasedtohandlerの4ファイル、機能分割がcreatestoreの2ファイル。
どちらも5ラン全てで実装ファイルの顔ぶれが同じで、変更が届く範囲が構造で決まっています。

03-blast-radius.png

図の横幅が、そのタスクで書き換わったファイルの数です。
上2つでは層分割が広く、いちばん下だけ向きが逆になります。

探索なしで測り直す

補完で感じる相性を測りたかったので、探索の手段を取り上げる条件を足しました。
そのタスクで触る必要のあるファイルの中身だけを本文に貼って渡し、編集以外の道具は使わせません。
渡すファイルは、さきほどの5ラン一致の結果から決めました。
3試行ずつの結果です。

タスク 層分割の通過 層分割 機能分割の通過 機能分割
参加人数を追加 3/3 614,349 3/3 453,108
時間変更エンドポイント追加 1/3 396,335 3/3 249,346
最長時間を4時間から2時間へ 3/3 141,442 3/3 138,348
保存先をJSONファイルへ 0/3 0/3

探索させないと差は1.61倍から1.36倍に縮みましたが、層分割が上回る場面は出ませんでした。
渡すべきファイルの中身そのものが層分割のほうが大きく、参加人数の追加で8,807バイト対5,028バイト。
先に開いておくべき量が多いという差は、探索を止めても残ります。
最長時間の変更だけは逆で2,139バイト対2,534バイトになり、ルールが1箇所に集まっているぶん層分割のほうが渡す量が少なくて済みます。

保存先の差し替えは両方とも0/3で、比較になりませんでした。
落ち方を見ると、ファイル永続化にしたことで同一プロセス内に立てた2つ目のサーバが1つ目の予約を読み込んでしまい、既存のテストが409で落ちています。
テストを実行できる条件では40ラン全部がこれを捕まえていて、実行できない条件ではどちらの構造でも捕まりませんでした。
この0/3は私が作った条件の欠陥で、アーキテクチャとは関係がありません。
渡すファイルを私が選ぶ以上、選び方の粗さがそのまま結果に出ます。

自分の測定を疑うべきところを疑わなかった箇所も、もうひとつあります。
時間変更のタスクで2ランがビルドに失敗していて、最初はそれを構造の差として読みかけていました。
中身を見るとpatch redeclared in this blockで、隠しテストに書いた補助関数の名前が、エージェントの足したテストとぶつかっているだけです。
名前を変えて40ラン全部を検証し直したら通過率は両実装とも5/5に揃い、測る側の都合が結果を作っていたことが分かりました。
ビルドが通らないという分かりやすい失敗でも、原因を確かめるまでは構造の差に見えます。

検証環境

  • Ubuntu 24.04.4 LTS on WSL2(kernel 6.6.87.2-microsoft-standard-WSL2)
  • go1.23.4 linux/amd64
  • Claude Code 2.1.220 / モデルはclaude-sonnet-5
  • 計64ラン(探索あり40、探索なし24)

所感

予想は半分外れました。
外れ方が一様でなかったのが、いちばん持ち帰る価値のあるところだと思っています。

層を分けると「変更が届く範囲を狭める」ことに代金を払う形になり、その効き目は変更の種類でまるごと符号が変わりました。
保存先を入れ替えてもルールを書いた場所が5回とも無傷だった一方で、フィールドを1つ足すだけで4ファイルに触ることになる。
どちらも同じ構造から出ています。

なので平均で語れる話ではなくて、自分の仕事で横断的な追加とルール変更の比率がどれくらいかを知らないと損得は決まりません。
横断が8割なら1.6倍を払い続けることになり、差し替えや局所的なルール変更が主なら払わずに済みます。

最後に測れなかったことを書いておきます。
業務ではGitHub Copilotを使っていますが、今回測ったのはClaude Codeです。
補完が次の行をどれだけ当てるかは、この計測では扱えていません…

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?