AI駆動開発でMVCは必要か——本当に重要なのはパターンより「明文化」だった
AIコーディングツールの登場で、開発スピードが劇的に上がりました。Cursor、Claude Code、GitHub Copilot——これらのツールを使えば、かつては2〜3日かかっていた機能実装が半日で終わることも珍しくありません。
しかし同時に、「AIに任せたら設計がカオスになった」という声もよく聞きます。
UIコンポーネントの中にDBアクセスが書かれています。同じロジックが3か所に散在しています。テストが書きにくい構造になっています——。
この記事では、なぜAIが「読みにくいコード」を生成しがちなのか、そしてMVCのような設計パターンが本当に必要なのかを掘り下げます。
結論だけ先に言っておきます。「AIのためにコーディングがルール化されているか」——問うべきはこれだけです。
AIはルールなしでコードを書くと何をするか
典型例——UIロジック・ビジネスロジック・DB呼び出しが1ファイルに混在
まず、現実に起きている問題を具体的に見てみましょう。
「UIとDBが同じファイルに?フロントとバックが混在しているということ?」と思った方もいるかもしれません。従来のアーキテクチャ(React + Express など)では、ブラウザ側のコードとサーバー側のコードは物理的に分離されていたため、1ファイルに混在しようがありませんでした。
ところが Next.js の App Router では、page.tsx はサーバーサイドで動く React Server Component です。サーバー上で実行されるため、Prisma(DBクライアント)を直接 import して呼び出すことができます。これにより「APIエンドポイント」という強制的な境界が消え、DBアクセス・ビジネスロジック・UI描画をひとつのファイルに書けてしまう状況が生まれました。
次のコードは、「あるエンジニアがCursorに頼んで書いてもらった注文詳細ページ」の実物に近いパターンです(Next.js App Routerを使用)。
// app/orders/[id]/page.tsx — AIが「ひとまず動く」として生成したコード
import { prisma } from '@/lib/prisma';
export default async function OrderPage({ params }: { params: { id: string } }) {
// DBを直接呼ぶ
const order = await prisma.order.findUnique({
where: { id: params.id },
include: { items: true, user: true },
});
if (!order) return <div>注文が見つかりません</div>;
// ビジネスロジックもここに書く
const subtotal = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const discount = order.user.isPremium ? subtotal * 0.1 : 0;
const tax = (subtotal - discount) * 0.1;
const total = subtotal - discount + tax;
// さらにメール送信の副作用まで混入
if (order.status === 'pending' && !order.reminderSent) {
await fetch('/api/mail/send', {
method: 'POST',
body: JSON.stringify({ to: order.user.email, orderId: order.id }),
});
await prisma.order.update({
where: { id: order.id },
data: { reminderSent: true },
});
}
return (
<div>
<h1>注文 #{order.id}</h1>
<p>小計: {subtotal}円</p>
{discount > 0 && <p>割引: -{discount}円</p>}
<p>消費税: {tax}円</p>
<p>合計: {total}円</p>
</div>
);
}動きます。テストしてみると要件も満たしています。だが、このファイルは以下の4つの仕事を1か所でやってしまっています。
- DBアクセス(
prisma.order.findUnique) - ビジネスロジック(割引計算・消費税計算)
- 副作用(メール送信・DB更新)
- UI描画(JSXのreturn)
これが「1ファイルに何でも書く」問題です。初回はこれで動きます。しかし次の追加機能を依頼したとき、AIはこのファイルに同じスタイルで書き足します。気づけば責務が混在したコードが増え続け、同じ割引ロジックが別のページにも別の実装で書かれています。
なぜこうなるか——AIに「暗黙の了解」は通じない
エンジニア同士のチームなら、「pageコンポーネントにビジネスロジックを書かない」は暗黙の了解として機能します。コードレビューで指摘されれば直りますし、ベテランのコードを読めば「こう書くものだ」と学べます。
AIにはこの暗黙の了解が通じません。
AIは「現在のコンテキスト」から最も自然に見えるコードを生成します。もし既存のコードにすでにビジネスロジックがUIと混在していれば、AIはその「文体」に合わせて続きを書きます。もし何も制約がなければ、「その場で動く、最短のコード」を選択する傾向があります。
これはAIの欠陥ではありません。AIは指示されたことをやっているに過ぎません。問題は、「どこに何を書くか」というルールが指示として存在していないことです。
MVC / Clean Architecture / Vertical Slice——3つのパターンが共通して言っていること
それぞれの「境界線の引き方」を並べる
設計パターンの話になると、宗教論争のように白熱します。「MVCで十分だ」「いや、Clean Architectureが正解だ」「Vertical Sliceのほうが実用的だ」——それぞれの主張には根拠があります。
3つのパターンを横に並べて比較してみましょう。
MVC(Model-View-Controller)
Rails、Laravel、Djangoなどが採用する古典的なパターンです。
- View: テンプレート・画面描画
- Controller: リクエスト受付・Modelへの橋渡し
- Model: DB操作・ビジネスロジック(広義)
シンプルですが、Modelが肥大化する「Fat Model問題」が起きやすいです。
Clean Architecture(クリーンアーキテクチャ)
Uncle Bobが提唱しています。関心の分離を徹底するために4つのレイヤーを設けます。
- Entities: ビジネスルール(外部依存ゼロ)
- Use Cases: アプリケーション固有のビジネスロジック
- Interface Adapters: フレームワーク・DBとの変換
- Frameworks & Drivers: Next.js、Prismaなど
依存の方向を内側に向けること(Dependency Rule)が核心です。テスタビリティが高いですが、ボイラープレートが多くなります。
Vertical Slice Architecture(垂直スライス)
機能単位(ユースケース単位)で完全に縦に分割します。features/order/配下にUI・ロジック・DB処理を全部入れ、機能間の依存を最小化します。水平分割(レイヤー)より垂直分割を優先します。
共通の本質——「どこに何を書くか、誰が何を知ってよいか」が明文化されているか否か
3つのパターンは表面上まったく違います。しかし共通して言っていることは1つです。
「この責務はここに書く。この層はあの層のことを知ってはいけない」というルールが存在し、明文化されている。
MVCなら「ビジネスロジックはModelに書く。ViewはModelを直接更新しない」。Clean Architectureなら「Use CaseはFrameworkのことを知らない」。Vertical Sliceなら「features/order/は features/payment/の内部を直接importしない」。
どれも境界線と依存の方向を定めています。パターン名は関係ありません。ルールが存在するか否か——これだけが問題です。
現場の実態——ほとんどのプロジェクトはフレームワーク任せ
フレームワーク任せとは何か
Railsを使えばMVCのディレクトリ構造が最初からあります。Next.js App Routerを使えば app/、components/、lib/ という分割が自然に生まれます。
多くのプロジェクトはここで止まります。「app/にページを書き、components/にコンポーネントを書く。それ以上のルールは書かなくていい」——というスタンスです。
フレームワークが提供するのはディレクトリ構造です。「このディレクトリに何を入れてはいけないか」「依存の方向はどちらか」は、フレームワークは一切定義しません。
たとえばNext.jsは app/page.tsx から prisma を直接呼ぶことを技術的には禁止していません。components/ の中でAPIフェッチを書くことも禁止していません。
フレームワークが言っているのは「このディレクトリを使いなさい」であり、「ここには何を書いてはいけない」は言っていません。
人間同士ならそれで機能する。AIはうまく機能しない理由
経験のあるエンジニア5人のチームなら、フレームワーク任せでも問題なく動きます。
暗黙の了解があります。「pageコンポーネントはUIだけ」「DBアクセスはrepositoryに切り出す」「ビジネスロジックは純粋関数で書く」——口頭のコーディング規約や過去の議論から、チームメンバーはこれを知っています。コードレビューがフィルタリングします。
AIにはこれが通じません。
AIが参照するのはプロンプトと、コンテキストウィンドウ内の既存コードです。「うちのチームでは暗黙の了解でビジネスロジックはservice層に書く」という情報は、どこにも書いていなければAIには届きません。
結果として、AIは「ひとまず動く最短コード」か、「既存コードのパターンを踏襲したコード」を生成します。前者は設計を無視し、後者は既存の悪いパターンを増殖させます。
AIが増やすのは、すでにそこにあるものです。
良いルールが書いてあれば良いコードを増やします。ルールがなければ、アドホックなコードを増やします。
意識的な人がやっていること——コーディングをルール化する実例
AIが読めるルールファイルに書く
主要なAIコーディングツールには、プロジェクトのルールを読み込ませるファイルがあります。
| ツール | ルールファイル |
|---|---|
| Cursor | .cursor/rules/*.mdc(推奨)または .cursorrules(レガシー) |
| Claude Code | CLAUDE.md または .claude/ 配下の参照ファイル |
| GitHub Copilot | .github/copilot-instructions.md |
| ツール非依存 | ARCHITECTURE.md 等を作成し、各ツールのルールファイルから参照させる |
重要なのは「どのファイルに書くか」ではなく、「AIが読める場所に書いてあるか」です。CLAUDE.mdや.cursorrulesが肥大化するなら、別ファイル(ARCHITECTURE.md 等)に設計ルールを書き、ルールファイルからそのパスを参照させる運用でも構いません。
以下は「Next.js + Prisma」プロジェクト向けの設計ルール最小テンプレートです。どのツールのルールファイルにも同じ内容を書けます。
## アーキテクチャルール
### ディレクトリの責務(必ず守ること)
| パス | 書いてよいもの | 書いてはいけないもの |
|------|--------------|-------------------|
| app/ | ルーティング・ページ構成・認証ガード | DB直接アクセス・ビジネスロジック |
| lib/*/repository.ts | DB・外部APIとの入出力のみ | 計算ロジック・バリデーション |
| lib/*/service.ts | ユースケース(複数リポジトリを組み合わせる処理) | UI知識・Next.js固有の型 |
| lib/*/domain/*.ts | 純粋なビジネスロジック(副作用なし・外部依存なし) | import { NextRequest } 等 |
| components/ | 表示・インタラクション | prisma 直接使用・fetch(SWRを除く) |
### 依存の方向(逆転禁止)
app/ → lib/*/service.ts → lib/*/repository.ts → DB / 外部API
app/ → components/(UIのみ)
### 新しいファイルを作るときのチェック
1. 「このファイルは何をするファイルか」をひとことで言えるか?
2. 「UI描画 / データアクセス / ビジネスロジック」のどれか1つだけか?
3. 2つ以上の責務が混在するなら、ファイルを分割してから実装する
### 禁止パターン(コード生成時も守ること)
- app/ 配下のファイルに prisma.〇〇.findXxx() を直接書かない
- lib/*/service.ts に import { NextRequest } from 'next/server' を書かない
- lib/*/repository.ts に割引計算・税計算などのビジネスロジックを書かない
- components/ から直接 fetch('/api/...') を書かない(SWRか Server Component 経由にする)このルールをAIのルールファイルに書いておくだけで、AIが生成するコードは大きく変わります。
「ルールがある」と「ルールがない」でAIの出力がどう変わるか
同じ「注文詳細ページを実装してください」という依頼に対して、ルールなし・ルールありで出力がどう変わるかを見てみましょう。
After(ルールあり)
上記のアーキテクチャルールをルールファイルに書いた状態で同じ依頼をすると、次のようなコードに分割されます。
// lib/order/repository.ts — DBアクセスのみ
import { prisma } from '@/lib/prisma';
export async function getOrderById(id: string) {
return prisma.order.findUnique({
where: { id },
include: { items: true, user: true },
});
}
export async function markReminderSent(orderId: string) {
return prisma.order.update({
where: { id: orderId },
data: { reminderSent: true },
});
}// lib/order/domain/pricing.ts — 純粋なビジネスロジック(副作用なし)
import type { OrderItem, User } from '@prisma/client';
export function calculateOrderTotals(items: OrderItem[], user: User) {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const discount = user.isPremium ? subtotal * 0.1 : 0;
const tax = Math.floor((subtotal - discount) * 0.1);
const total = subtotal - discount + tax;
return { subtotal, discount, tax, total };
}// lib/order/service.ts — ユースケース(リポジトリ + ドメインを組み合わせる)
import { getOrderById, markReminderSent } from './repository';
import { calculateOrderTotals } from './domain/pricing';
export async function getOrderWithTotals(id: string) {
const order = await getOrderById(id);
if (!order) return null;
const totals = calculateOrderTotals(order.items, order.user);
// 副作用(メール送信)もUIではなくここで担う
if (order.status === 'pending' && !order.reminderSent) {
await fetch('/api/mail/send', {
method: 'POST',
body: JSON.stringify({ to: order.user.email, orderId: order.id }),
});
await markReminderSent(order.id);
}
return { order, totals };
}// app/orders/[id]/page.tsx — UIのみ(10行以下)
import { getOrderWithTotals } from '@/lib/order/service';
export default async function OrderPage({ params }: { params: { id: string } }) {
const result = await getOrderWithTotals(params.id);
if (!result) return <div>注文が見つかりません</div>;
const { order, totals } = result;
return (
<div>
<h1>注文 #{order.id}</h1>
<p>小計: {totals.subtotal}円</p>
{totals.discount > 0 && <p>割引: -{totals.discount}円</p>}
<p>消費税: {totals.tax}円</p>
<p>合計: {totals.total}円</p>
</div>
);
}calculateOrderTotals は純粋関数なので、import するだけでユニットテストが書けます。getOrderById をモックすればservice層のテストも書けます。page.tsxはUIだけなので、どのロジックが変わっても影響しません。
同じ要件、同じAI、違うのはルールファイルに書いたルールだけです。
よくある誤解と回答
Q: MVCじゃないとダメ?
ダメではありません。
この記事で一貫して言っていることは「MVCを採用せよ」ではなく「境界線を明文化せよ」です。Vertical Sliceでも、Clean Architectureでも、あなた独自の命名規則でも構いません。「UIはUI、DBはDB、ビジネスロジックはビジネスロジック」という分割の意図が文章として書かれていれば、AIはそれに従います。
「うちは独自の設計をしている」という場合も、その設計の境界線と禁止パターンをAIのルールファイルに書けばよいです。
Q: フレームワークのディレクトリ構造があれば十分では?
十分ではありません。
Next.jsの app/、components/、lib/ という分割は「どのディレクトリを使うか」を定めていますが、「そのディレクトリに何を書いてはいけないか」は定めていません。
AIが参照するのは制約です。「app/ にDBアクセスを書いても技術的には動く」状態で、AIはより短い(責務を分離しない)実装を選ぶことがあります。「禁止事項」を明示することが重要であり、ディレクトリ構造は「ここに書く」を示しますが「ここには書かない」を示しません。
Q: ルールが長くなると指示遵守率が落ちると聞いた
正しい観察です。ただし、問題はルールの長さではなく構造にあります。
現代のLLM(Claude Sonnetなど)は、適切に構造化されたルールであれば数百行でも遵守できます。問題になるのは「すべてが散文で書かれており、何が禁止で何が推奨かが区別できない」テキストです。
有効な対策は2つあります。
- 禁止事項を箇条書きで明示する(「〇〇を書かない」「〇〇を import しない」)
- 理由を1行添える(「なぜか」があるとAIはルールを文脈で解釈できる)
ルールが長くなってきたら、まず「本当に全プロジェクトで守るべきか」を見直します。汎用ルールと特定機能のルールを分けて管理するのも有効です。
結論
MVCである必要はありません。Clean Architectureである必要も、Vertical Sliceである必要もありません。
問うべきはただ1つです。
AIのためにコーディングがルール化されているか。
「コーディングのルール」とは、スタイルガイドや命名規則のことだけではありません。「どこに何を書くか」「誰が何を知ってよいか」「何を書いてはいけないか」——責務・依存関係・境界線といった設計全体に関わるルールのことです。
このルールが明文化されていれば、AIは良い設計のコードを生成します。明文化されていなければ、AIはその場で動く最短コードを生成し、既存の悪いパターンを増幅させます。
設計パターンの価値は、そのパターン名にあるのではありません。「境界線が定義され、ルールが書かれている」という状態にあります。AIとともに開発するチームにとって、これはかつてないほど重要になっています。
まとめ・次のアクション
今日からできる1つのアクションを提示します。
使っているAIツールのルールファイル(.cursorrules、CLAUDE.md、.github/copilot-instructions.md 等)に「アーキテクチャセクション」を追加しましょう。
最初は3行でよいです。「app/ にprismaを直接書かない」「ビジネスロジックはlib/配下の純粋関数に書く」「依存の方向はapp → lib → DB」——これだけ書いてAIに次のタスクを依頼してみてください。出力の変化がすぐに分かります。
設計とは、チームメンバーへの説明文です。AIがチームに加わった今、その説明文はAIにも読めるように書く必要があります。
