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?

LLM生成コードと5年付き合うための3つのアーキテクチャ戦略

0
Posted at

TL;DR

  • LLM が書いたコードは「動く」が「理解しやすい」とは限らない
  • 保守可能性を維持するには 意図の外在化・境界の明確化・テスト可読性の確保 の 3 軸が必要
  • チーム開発においては「コードを読む人間がいる前提」の設計習慣を LLM 使用後も崩さない

背景: LLM コーディング時代に浮上した「理解ギャップ」

GitHub Copilot、Cursor、Claude Code など、LLM を使ったコーディング支援ツールが普及し、個人開発者からエンタープライズチームまで日常的に AI 生成コードをリポジトリにマージするようになった。

生産性は上がった。しかし新たな問題が浮上している。

「自分で書いていないコードを、自分がどれだけ理解しているか?」

Hacker News や Aha! Engineering Blog などで繰り返し話題になるこの問いは、2026 年時点でまだ明確な答えが出ていない実践的課題だ。LLM が生成するコードは以下の特性を持ちやすい。

  • 局所的に正しいが全体文脈から浮いている: 関数単体は動くが、既存の命名規則・エラー処理パターンと乖離している
  • なぜそう実装したかのコメントがない: LLM はコメントを省略しがちで、意図が見えない
  • テストが「通るだけ」のものを生成しがち: assertion が甘く、境界値・エラー系が薄い

本記事では、この「理解ギャップ」を埋めるための 3 つのアーキテクチャ的・運用的戦略 を解説する。


戦略 1: 意図を外在化する — ADR (Architecture Decision Record) × LLM プロンプトログ

なぜ意図の外在化が重要か

LLM に「この関数を書いて」と頼んだとき、あなたの頭の中には「なぜその実装が必要か」という文脈がある。しかし生成されたコードにはその文脈が存在しない。

3 ヶ月後に別のメンバーがそのコードを見たとき、意図がわからなければリファクタリングも拡張も怖くなる。

実践: プロンプトを docs/adr/ に残す

docs/
  adr/
    0001-use-exponential-backoff-for-stripe-retry.md
    0002-paginate-user-list-with-cursor.md

ADR の形式は Michael Nygard の提案 が広く使われている。

# ADR-0002: ユーザー一覧のページネーションにカーソル方式を採用

## ステータス: 承認済み (2026-07-17)

## コンテキスト
ユーザー数が 10 万件を超えた場合、オフセットページネーションは
OFFSET N のスキャンコストが線形増加する。

## 決定
cursor-based pagination (keyset pagination) を採用する。
実装は LLM (Claude Code) で生成し、以下のプロンプトを使用した:

> 「PostgreSQL + Prisma で cursor-based pagination を実装して。
>  cursor は createdAt + id の複合キー。next_cursor を
>  opaque な base64 文字列で返すこと」

## 影響
- フロントエンド側は page_number 廃止・next_cursor に対応が必要
- ソート条件変更時は cursor の再設計が必要

ポイント: プロンプトを ADR に残すと「なぜこのコードになったか」が追跡できる。LLM が特定の実装パターンを選んだ理由も言語化される。


戦略 2: 境界を明確化する — モジュール分割と型による「意図の表明」

LLM 生成コードが「境界を侵食する」問題

LLM に大きなタスクを投げると、既存モジュールの責務を無視して「動くコード」を生成することがある。

例えば「注文確認メールを送る処理を追加して」と頼むと:

// LLM が生成しがちなコード (悪例)
async function createOrder(userId: string, items: Item[]) {
  // DB 保存
  const order = await db.order.create({ data: { userId, items } });
  
  // ここで突然メール送信ロジックが混入
  const user = await db.user.findUnique({ where: { id: userId } });
  await fetch("https://api.sendgrid.com/v3/mail/send", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.SENDGRID_KEY}` },
    body: JSON.stringify({ to: user.email, subject: "注文確認" }),
  });
  
  return order;
}

createOrder がメール送信の詳細を知ってしまっている。テストも難しくなる。

実践: 事前に境界を型と interface で宣言してから LLM に渡す

// 先にインターフェースを人間が定義する
interface OrderService {
  createOrder(userId: string, items: Item[]): Promise<Order>;
}

interface NotificationService {
  sendOrderConfirmation(order: Order): Promise<void>;
}

この型定義を LLM への指示に含める:

「以下の interface に従って createOrder を実装して。
メール送信は NotificationService に移譲すること。
直接 sendgrid を呼ばないこと。」

// LLM が生成した実装 (境界が守られた版)
class OrderServiceImpl implements OrderService {
  constructor(private notificationService: NotificationService) {}

  async createOrder(userId: string, items: Item[]): Promise<Order> {
    const order = await db.order.create({ data: { userId, items } });
    await this.notificationService.sendOrderConfirmation(order);
    return order;
  }
}

インターフェースファーストで LLM に渡すと、生成コードが既存設計に収まりやすくなる。

Rust の場合: trait で表明する

// trait を先に定義 → LLM に「この trait を実装して」と渡す
pub trait NotificationPort {
    async fn send_order_confirmation(&self, order: &Order) -> Result<(), NotificationError>;
}

pub struct OrderService<N: NotificationPort> {
    db: Arc<Database>,
    notifier: N,
}

Rust の型システムは LLM 生成コードの「境界侵食」を コンパイル時に検出できる という強みがある。型が合わなければコンパイルエラーになるため、レビューコストを下げられる。


戦略 3: テストの可読性を「仕様書」として維持する

LLM が生成するテストの典型的な問題

// LLM が生成しがちなテスト (悪例)
it("works", async () => {
  const result = await createOrder("user1", [{ id: "item1", qty: 1 }]);
  expect(result).toBeTruthy();
});

このテストは:

  • 何を検証しているか不明 (toBeTruthy は何でも通る)
  • テスト名が仕様を表していない (works は意味がない)
  • 失敗時にどの仕様が壊れたかわからない

実践: Given-When-Then 形式を LLM へのプロンプトに組み込む

LLM に次のように依頼する:

「以下の仕様をカバーするテストを Given-When-Then 形式で書いて。
テスト名は '〇〇のとき、△△すると、××になる' の形式にすること。」

仕様:

  1. 在庫が 0 の商品を注文しようとした場合、InsufficientStockError が発生する
  2. 注文が成功した場合、NotificationService.sendOrderConfirmation が 1 回呼ばれる
// LLM が生成したテスト (可読性を指定した場合)
describe("OrderService.createOrder", () => {
  describe("在庫が 0 の商品を注文しようとしたとき", () => {
    it("InsufficientStockError を throw する", async () => {
      // Given
      const item = { id: "item-out-of-stock", qty: 1 };
      await db.inventory.update({ where: { itemId: item.id }, data: { stock: 0 } });

      // When / Then
      await expect(
        orderService.createOrder("user1", [item])
      ).rejects.toThrow(InsufficientStockError);
    });
  });

  describe("注文が成功したとき", () => {
    it("NotificationService.sendOrderConfirmation が 1 回呼ばれる", async () => {
      // Given
      const mockNotifier = { sendOrderConfirmation: jest.fn() };
      const service = new OrderServiceImpl(mockNotifier);

      // When
      await service.createOrder("user1", [{ id: "item1", qty: 1 }]);

      // Then
      expect(mockNotifier.sendOrderConfirmation).toHaveBeenCalledTimes(1);
    });
  });
});

テストが「仕様書」として機能する状態を保つことで、LLM 生成コードを人間がレビューする際のコストを大幅に下げられる。


ボーナス: チームでの運用ルール例

LLM コーディングをチームで運用する際に効果的だったプラクティスをまとめる。

コードレビュー時の追加確認事項

確認項目 理由
LLM 生成コードのプロンプトが ADR / PR 説明に記載されているか 意図の追跡可能性
既存モジュールの責務境界を侵食していないか 設計一貫性
テスト名が仕様を自然言語で表現しているか 仕様書としての機能
エラーハンドリングが既存パターンと統一されているか LLM は独自のエラー処理を混入させやすい
命名規則が既存コードベースと一致しているか LLM は命名スタイルにブレが出やすい

.cursor/rules や GitHub Copilot Instructions でプロジェクトルールを明示する

Cursor では .cursor/rules、GitHub Copilot では .github/copilot-instructions.md にプロジェクト固有のルールを書くことで、LLM が生成するコードの品質を事前に制御できる。

# .cursor/rules (例)

## エラーハンドリング
- ドメインエラーは `src/errors/` に定義されたクラスを使用すること
- `console.error` を直接呼ばず、`logger.error` を使うこと

## 依存性注入
- クラスは constructor injection を使うこと
- 直接 import したサービスのインスタンスを生成しないこと

## テスト
- テスト名は 「〇〇のとき、△△すると、××になる」形式にすること
- `toBeTruthy` / `toBeFalsy` は使わず、具体的な値を検証すること

まとめ

LLM 生成コードとの長期的な付き合いには、以下の 3 つが核心となる。

  1. 意図を外在化する: ADR とプロンプトログで「なぜこの実装か」を残す
  2. 境界を型で宣言する: インターフェース・trait を先に定義してから LLM に実装させる
  3. テストを仕様書として維持する: Given-When-Then と意味のあるテスト名を LLM への指示に組み込む

「LLM が書いたから仕方ない」では技術的負債が蓄積するだけだ。LLM をチームの新人メンバーとして扱い、既存の設計思想・命名規則・テスト文化に従わせる習慣を作ること が、コードベースの長期健全性を守るカギになる。


参考リンク


✍️ 本記事の著者: 合同会社ジモラボ (@locallab_jp)

ジモラボは、八王子を拠点に AI を活用した SaaS を多数開発しています。本記事の技術検証もそうした開発過程の副産物です。

興味を持っていただけたら、ぜひ各 SNS のフォローもお願いします!

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?