はじめに
ここ1年ほどでAIコーディングエージェントに、コードを書いてもらう機会がぐっと増えた。というかほとんどのコードを書いてもらっている。使っていて感じたのが、Controller・Service・Repositoryのように技術的な層でディレクトリを分けるいわゆるPackage By Layer(技術単位でディレクトリを分ける構成)は、AIにとってあまり相性が良くないということ。
一方で、Package By Feature(機能単位でディレクトリを分ける構成)にすると、AIも人間も扱いやすくなる場面が多かった。この記事ではその理由と、実際にどんな構成にしたかをサンプルリポジトリを交えて書いていく。
レイヤードアーキテクチャで感じていた問題
Laravelでよくある構成は以下のような感じ。
app/
├── Http/Controllers/TaskController.php
├── Services/TaskService.php
├── Repositories/TaskRepository.php
└── Http/Resources/TaskResource.php
Taskという1つの機能に手を入れるだけなのに、ファイルは4つのディレクトリにまたがる。人間はプロジェクトの土地勘があるのでさほど苦にならないが、AIにコードを書いてもらう時はこの「あちこち探しに行く」コストがそのまま効いてくる。
具体的には、
- 関連ファイルを探すために複数ディレクトリをgrepしたり読みに行く回数が増える。コンテキストを余計に消費する
- Serviceがどこまで責務を持つか、Repositoryはどこまで薄くするかといった暗黙のルールをAIは知らない。毎回微妙に違う設計で書かれる
- 影響範囲の見積もりを誤って、関係ないファイルまで触ってしまう
といったことが起きやすかった。
Package By Featureとは
技術的な層ではなく、機能(Feature)を第一の分割単位にする考え方。1機能 = 1ディレクトリとし、その機能に関するコードは原則そのディレクトリの中に閉じ込める。
app/Features/Task/
├── TaskController.php
├── Requests/
├── Inputs/
├── UseCases/
├── Resources/
└── Services/
Controller、UseCase(ビジネスロジック)、Resourceといった役割自体はこれまで通り分かれているが、それらは「Task」というディレクトリの中に閉じている。何かを直すときはこの1ディレクトリだけ見ればよい。
なぜAIと相性がいいのか
理由は単純で、1機能の変更に必要な情報が1ディレクトリに収まっているから。AIに「Taskに◯◯という項目を追加して」と頼むと、app/Features/Task/ 以下だけを見て完結できる。他のFeatureのServiceやRepositoryを気にする必要がない。
新しいFeatureを作るときも、既存のFeatureディレクトリを丸ごとコピーしてリネームするだけで雛形になる。これはAIに指示する上でも都合が良くて、「Taskディレクトリと同じ構成でOrderという機能を作って」と頼むだけで、これまでのルールに沿った実装が返ってきやすい。
レイヤードな構成だと「Serviceの責務」「Repositoryの粒度」のような、コードだけでは読み取りにくい暗黙のルールがAIとの間でズレやすい。Package By Featureはその手のルールをディレクトリ構造自体で強制できるので、AIへの指示コストが下がる。
Feature内部はController/UseCase/Resourceのように技術層で分けているが、これはAIが探しに行く範囲が"1ディレクトリ内"に収まっていれば問題にならない。Feature"間"を技術層でまたがせないことが重要と考える。
でも、人間にも読めないと意味がない
ここまでAIとの相性の話をしてきたが、コーディングをAIに全部任せられるなら人間が読みやすいかどうかはどうでもいい、という話にはならないと思っている。
AIは使い放題ではない。個人の契約プランにも企業の利用にも予算の上限があって、よほど予算が潤沢な組織でない限り、月の途中で上限に達したり、コスト意識からAIに頼らず自分で書く場面は普通に出てくる。つまり人間がコードを直接読んだり書いたりする可能性は、AI時代になっても(※現時点では)十分残っている。
だから「AI向けに最適化されているが人間には読みにくい」構成では困る。幸い、Package By Featureは技術層を横断しなくていい分、人間にとっても認知負荷が低い。「Taskの挙動を直したいならapp/Features/Task/を見ればいい」というのは、AIにとってもレビューする人間にとっても同じように成り立つ。AIと人間で別々の最適化をする必要がなく、両方に効くというのが、この構成を推す一番の理由である。
サンプル実装で見る構成
実際に手を動かして試したものをlaravel-feature-templateとしてリポジトリに置いた。特定プロジェクトのビジネスロジックは含まれておらず、Taskという汎用的なCRUDだけをサンプルとして同梱している。
ディレクトリ構成
src/app/
├── Features/
│ └── Task/ # 1機能 = 1ディレクトリ
│ ├── TaskController.php
│ ├── Requests/ # FormRequest。バリデーション + Input DTOへの変換
│ ├── Inputs/ # UseCaseの入力DTO(spatie/laravel-data)
│ ├── UseCases/ # ビジネスロジック本体
│ └── Resources/ # JsonResource。レスポンスの形を決める
├── Http/
│ ├── Controllers/Controller.php # 空の基底クラス
│ └── Middleware/
├── Models/
└── Shared/ # 2つ以上のFeatureから使われて初めて置く共通コード
リクエストは Router → Middleware → Controller → FormRequest → Input DTO → UseCase → Model → Resource という一本道で流れる。Taskの作成(store)処理を例に見てみる。
Controllerは薄く、1メソッド1アクションになっている。
public function store(StoreTaskRequest $request, StoreTaskAction $action): JsonResponse
{
return (new TaskResource($action->execute($request->toInput())))
->response()
->setStatusCode(JsonResponse::HTTP_CREATED);
}
FormRequestはバリデーションをした上で、Input DTOに変換するところまでを担う。
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'dueDate' => ['nullable', 'date'],
];
}
public function toInput(): StoreTaskInput
{
return StoreTaskInput::from($this->validated());
}
UseCaseはビジネスロジック本体。execute()を1つだけ持つ。
final class StoreTaskAction
{
public function execute(StoreTaskInput $input): Task
{
return Task::query()->create($input->toArray());
}
}
さいごに
Package By Featureは銀の弾丸ではない。Feature同士がまたがる処理が増えてきたプロジェクトではSharedの置き場所に悩む場面も出てくるはずだし、小さいアプリでは正直Featureに分けるまでもないこともある。
それでも、AIと一緒にコードを書く機会が増えてきた今、「1つの変更が1つのディレクトリで完結する」という性質は、AIのコンテキスト消費を減らす面でも、人間が読む面でも効いてくる実感がある。まだ試している最中ではあるけど、同じようなことを考えている人の参考になれば。
ちなみに中~大規模ではFeatures/StoreTask/のようにアクションごとにディレクトリを切る方法(Vertical Slice Architecture?)もよいかもしれないと考えている。