AIエージェントにSOLID原則を叩き込んでやろうじゃないか

こんにちは!カヤックボンドの鈴木です。こちらは面白法人グループAdvent Calendar 202524日目の記事です。

サッカーを見ることが好きなのですが、僕の愛する柏レイソルが優勝を逃してしまい、悔しい、歯痒い気持ちを抱えながら記事を書きます。

今回はバイブコーディングにおけるプログラミング原則を徹底させることの影響について書いていきたいと思います。

はじめに

言うまでもないですが、近年AIが見せている凄まじい成長は、常に我々を驚かせ、そして脅かしています。

AIを利用して生産性を向上させる取り組みも業界に限らず見られ、一種の社会現象とも言えるでしょう。

直近だと、我々エンジニアという仕事に最も影響を与えているサービスは、AIによるコーディングエージェントだと思います。

プロンプトを投げるだけでそれに沿ってタスクを進めてくれる技術は、エンジニアが1日で終えることのできる仕事量を大幅に増やしました。

そんな中、僕のような新卒1年目のエンジニアはこうも思うわけです。

「んー、俺がいる意味ってなくね?」

仕様通りに機能が実装されるようにプロンプトを投げて実装してもらい、ある程度動作確認を済ませ、ソースコードを自らレビューし、PRを作成して先輩にレビューしてもらう。

この流れで僕が行なっていると言えるのは「AIの出力をレビューすること」です。

  • ただAIの出力に二重でレビューしているだけ
  • 僕の後にレビューしてもらう先輩は僕の知識や考えのほとんどをカバーしているため、僕が確認する意味がない

こういった感想を抱きました。将来完全に淘汰されるであろうエンジニアの典型ですね。

こんな風に下地のないジュニアが技術の進歩に甘えてAIに依存していると、生産性は向上するけど自分自身は何も変わらないだろうな、と感じました。

そこで、「ソフトウェアエンジニアをやっている限りどんな場面でも基本的に活かすことができるだろう設計思想を学ぼう」という考えに至りました。

自分も学びつつ、アウトプットの質も上げてもらいたいということで、これからエージェントに叩き込んでいこうと思います。

原則を遵守させるとどう変わるか

弊社の開発業務で利用しているClaude Codeを使いながら、試しにSOLID原則を叩き込んでいこうと思います。

SOLID原則についての詳しい説明は省きますが、一言で表すなら

「オブジェクト指向プログラミングにおいて、保守性・拡張性・再利用性の高いソフトウェア設計をするための、5つの基本原則」

です。以下の記事がとても分かりやすいです。

マリオで学ぶSOLID原則

本記事では、ラーメン屋の営業を例に、SOLID原則をClaudeに遵守させてみようと思います。

まずはClaudeに叩き込んでいきましょう。

読み込ませるコンテキスト

SOLID原則に対応して5つのファイルにそれぞれの原則について記しました。

S (Single Responsibility):単一責任の原則

SingleResponsibility.md
# Single Responsibility Principle (SRP) - 厳守ガイドライン

## 基本原則

各コンポーネントは1つの明確な責任のみを持ち、その責任を完全に果たすことに集中する。
複数の責任を持つコードは、必ず分割すること。

## 実装時の厳守ルール

### 1. クラス設計の原則
- 1クラス = 1つの責任
- クラス名は責任を明確に表現する
- 「そして」「また」「さらに」という説明が必要なクラスは分割対象

### 2. 関数・メソッドの原則
- 1関数 = 1つのタスク
- 関数名が動詞+目的語の形で明確に表現できること
- 5-10行を目安とし、最大でも20行以内

### 3. 違反を検知する警告サイン
- クラスに複数の public メソッドグループが存在する
- 異なる理由で変更される可能性がある
- テストが複雑になる
- 「Manager」「Handler」「Processor」などの汎用的な名前

O (Open-Closed):オープン・クローズドの原則

OpenClosed.md
# Open-Closed Principle (OCP) - 厳守ガイドライン

## 基本原則
**ソフトウェアのエンティティ(クラス、モジュール、関数)は拡張に対して開いており(Open)、
修正に対して閉じている(Closed)べきである。**

新機能を追加する際は、既存のコードを変更するのではなく、新しいコードを追加することで実現する。

## 実装時の厳守ルール

### 1. 絶対に避けるべきパターン
- switch文やif-elseチェーンでの型判定
- 既存クラスへの新メソッド追加による機能拡張
- 条件分岐の追加による振る舞いの変更
- 既存メソッドの内部ロジック修正

### 2. 推奨する実装パターン
- 抽象化(インターフェース/抽象クラス)の利用
- 継承よりコンポジション
- Strategy、Template Method、Decoratorパターンの活用
- プラグインアーキテクチャの採用

### 3. 違反を検知する警告サイン
- 新機能追加時に既存ファイルを編集している
- 型やenumでの分岐が増えている
- "もう一つケースを追加"というコメント
- テストケースが既存のものを修正する必要がある

L (Liskov Substitution): リスコフの置換原則

LiskovSubstitution.md
# Liskov Substitution Principle (LSP) - 厳守ガイドライン

## 基本原則
**派生クラスは、その基底クラスと置換可能でなければならない。**

サブタイプ(派生クラス)のオブジェクトは、プログラムの正しさを損なうことなく、
その基底タイプ(親クラス)のオブジェクトと置き換えられるべきである。


## 実装時の厳守ルール

### 1. 契約の保持
- **事前条件を強化してはいけない**(派生クラスは基底クラスより厳しい入力制約を課してはならない)
- **事後条件を弱めてはいけない**(派生クラスは基底クラスが保証する出力を保証しなければならない)
- **不変条件を維持する**(基底クラスの不変条件は派生クラスでも保持される)

### 2. 禁止事項
- 派生クラスで例外を投げる条件を追加しない
- 基底クラスのメソッドを空実装やNotImplementedErrorで上書きしない
- 基底クラスが期待する振る舞いを変更しない
- 基底クラスのメソッドの意味を変えない

### 3. 違反を検知する警告サイン
- 派生クラスで基底クラスのメソッドを使わない/使えない
- instanceof や type() での型チェックが必要
- 派生クラスで「このメソッドは使用しないでください」というコメント
- オーバーライドしたメソッドが全く異なる振る舞いをする

I (Interface Segregation):インターフェイス分離の原則

InterfaceSegregation.md
# Interface Segregation Principle (ISP) - 厳守ガイドライン

## 基本原則
**クライアントは、自分が使用しないメソッドに依存することを強制されるべきではない。**

大きな汎用インターフェースよりも、小さな特定目的のインターフェースを複数作る。
「太った」インターフェースは「痩せた」複数のインターフェースに分割すべきである。

- インターフェースは、実装者の都合ではなく、**使用者(クライアント)の視点**で設計する
- 1つのインターフェース = 1つの役割/責任
- 必要なメソッドだけを公開し、不要な依存を作らない

## 実装時の厳守ルール

### 1. インターフェース設計の原則
- **役割ベースの分離**: 各インターフェースは明確な1つの役割を表現
- **クライアント特化**: 使用者が必要とするメソッドのみを含む
- **高凝集性**: インターフェース内のメソッドは密接に関連
- **依存の最小化**: 不要なメソッドへの依存を排除

### 2. 禁止事項
- 「なんでもできる」万能インターフェースの作成
- 実装を強制するだけの空メソッド
- 異なる責任を持つメソッドの混在
- NotImplementedErrorやUnsupportedOperationExceptionの使用

### 3. 違反を検知する警告サイン
- インターフェースに5つ以上のメソッドがある
- 実装クラスで空実装やエラーを投げるメソッドがある
- 「〜Manager」「〜Handler」などの汎用的な名前
- クライアントが一部のメソッドしか使わない
- メソッド間の関連性が低い

D (Dependency Inversion) 依存性逆転の原則

DependencyInversion.md
# Dependency Inversion Principle (DIP) - 厳守ガイドライン

## 基本原則
**1. 上位モジュールは下位モジュールに依存してはならない。どちらも抽象に依存すべきである。**
**2. 抽象は詳細に依存してはならない。詳細が抽象に依存すべきである。**

依存関係の方向を逆転させ、具象実装ではなく抽象(インターフェース)に依存することで、疎結合で柔軟なシステムを構築する。

- **従来の依存**: ビジネスロジック → データベース(具象に直接依存)❌
- **逆転した依存**: ビジネスロジック → Repository(抽象)← データベース実装 ✅
- 依存の方向は「安定」に向かうべき(抽象は安定、具象は不安定)

## 実装時の厳守ルール

### 1. 依存関係の原則
- **具象クラスを直接インスタンス化しない**(newやコンストラクタ呼び出しを避ける)
- **抽象(インターフェース/抽象クラス)を通じて依存を注入**
- **依存は外部から注入される**(コンストラクタ注入推奨)
- **フレームワーク/ライブラリへの直接依存を避ける**

### 2. 禁止事項
- ビジネスロジック内でのnew演算子使用
- 具象クラスの型を引数や戻り値に使用
- 静的メソッドへの直接呼び出し
- グローバル変数やシングルトンへの直接アクセス
- 外部ライブラリの直接import(ラッパーなしで)

### 3. 違反を検知する警告サイン
- importが具象クラスを指している
- コンストラクタ内でnewを使用している
- テストでモックが困難
- 実装を変更するとビジネスロジックも変更が必要
- 循環依存が発生している

少し長くなってしまいましたが、Claudeに与えるコンテキストにこれら5ファイルを準備しました。 記事に載せる都合もあり、なるべくシンプルにしています。また、それぞれの原則への解釈について、正確ではない表現も含まれていると思いますが、ご容赦ください🙇🏾

CLAUDE.mdにこれら5ファイルを読み込むよう指示を出しています。

タスク依頼時に投げるプロンプトは以下にしました。

# 言語
TypeScript

# 依頼したいタスク
ラーメン屋の営業をオブジェクト指向プログラミングで表現して欲しいです。
客が入店してから、食べ終わって店を出るまでの考えられるユースケースを実装してください。

# 補足
srcディレクトリ内に実装を進めてください。
クラス設計は任せます。

コンテキストによる違いをよりはっきりさせるために、プロンプトに情報をあまり入れていません。

特に遵守させない場合

まずは、コンテキストは何も用意せず、このプロンプトだけを投げてみました。

すると、以下のようなディレクトリで出力してくれました。

src/
├── RamenItem.ts (ラーメンメニュー項目クラス - 名前、価格、説明を管理)
├── Menu.ts (メニュー管理クラス - メニュー項目の追加・検索・表示)
├── Table.ts (テーブル/席管理クラス - 空席確認・着席・退席処理)
├── Customer.ts (顧客クラス - 所持金管理・支払い・食事処理)
├── Order.ts (注文管理クラス - 注文項目・状態管理・合計金額計算)
└── RamenShop.ts (ラーメン屋メインクラス - 店舗全体の営業フロー管理)

適当なプロンプトしか投げていないのだから当然なのですが、いくつか問題点が分かってきました。

まずは単一責任の原則についてです。例えばRamenShopクラスですが、様々な振る舞いを持たされています。お店側が主語となるユースケースを全てこのRamenShopで表現できるようロジックが集約されていました。

RamenShop.ts

setStatus(status: OrderStatus): void {
    // 遷移ルールをここに追加
    if (this.status === OrderStatus.COOKING && status === OrderStatus.CANCELLED) {
        throw new Error('調理中はキャンセルできません');
    }
    if (this.status === OrderStatus.READY && status === OrderStatus.ORDERED) {
        throw new Error('この遷移は不正です');
    }
    // ... 他にも条件が増えていく
    this.status = status;
}

他にも、Orderクラスにある注文状態を設定するsetStatusメソッドを見てみると、条件が増えるたびにifによるチェックが増えていく、OpenClosedの原則に反したものも見られました。

全体的に、「レビューで指摘するほどじゃないけど、ここはこうしてほしいな...」というコードが多く見られました。

リスコフの置換原則、インターフェース分離の原則については、インターフェースもなく継承なども特にしていないため、これらに反するところはないように見えました。

実装の規模からしても、正直なところ原則をしっかりと守る必要はありません。ですが今回は学ぶことが目的なので、原則を含めたコンテキストを読み込ませるとどうなるか、下で見ていきます。

しっかりと遵守させる場合

以下のようなディレクトリ構造で出力してくれました。(ここまでする必要はないというご意見はご容赦ください)

src/
├── domain/ (ドメイン層 - ビジネスロジックの中核)
│   ├── exceptions/ (ドメイン例外 - ビジネスルール違反を表現)
│   ├── models/ (ドメインモデル - エンティティと値オブジェクト)
│   │   └── values/ (値オブジェクト - 不変の値を表現)
│   ├── repositories/ (リポジトリインターフェース - 永続化の抽象)
│   └── services/ (ドメインサービス - 複数エンティティにまたがるロジック)
│       ├── implementations/ (ドメインサービス実装 - ビジネスロジックの実装)
│       └── interfaces/ (ドメインサービスインターフェース - サービスの抽象)
│
├── application/ (アプリケーション層 - ユースケースの実行)
│   ├── dto/ (データ転送オブジェクト - リクエスト/レスポンスの型定義)
│   └── usecases/ (ユースケース - ユーザーの操作フロー)
│       ├── implementations/ (ユースケース実装 - 具体的なフロー処理)
│       └── interfaces/ (ユースケースインターフェース - ユースケースの抽象)
│
└── infrastructure/ (インフラ層 - 技術的な実装詳細)
    ├── factories/ (ファクトリ - オブジェクト生成の責務)
    └── repositories/ (リポジトリ実装 - データ永続化の具体実装)

ぱっと見ですが、めちゃめちゃしっかりしているように見えます。

では原則が徹底されているかどうか、一部を取り上げてみます。

context-EnterStoreUserCase.ts

客が入店するユースケースを表現したクラスについて取り上げます。このクラスは以下の点において、単一責任の原則(Single Responsibility)を果たしています。

  • 単一の責任: 「入店から座席割り当てまで」という1つの明確な責任のみ
  • 変更理由は1つ: 入店フローの仕様変更時のみ変更される
  • 他の責任は委譲:

    • 顧客の生成 → customerFactory
    • データ永続化 → customerRepository
    • 座席割り当てロジック → seatAllocationService
  • 関数は1タスク: execute() メソッドは「入店ユースケースの実行」のみ

context-PaymentService.ts

次に支払いを表現したサービスクラスを取り上げます。このクラスは以下の点において、OpenClosedの原則を果たしています。

  • 拡張に開いている: 新しい支払い方式(クレジットカード決済、電子マネーなど)を追加する場合、IPaymentServiceを実装した新しいクラスを作るだけ
  • 修正に閉じている: 既存のPaymentServiceを変更せずに機能拡張可能
  • switch文を避ける: 支払い方法による分岐は不要。DIコンテナで注入する実装を切り替えるだけ

また、リスコフの置換原則(Liskov Substitution)においても、以下の点で原則を果たしていると言えます。

  • PaymentService の任意の実装と置き換え可能
  • 入力: OrderId, Money → 出力: Payment または例外

    • 例外や事前条件、事後条件についてはインターフェースで契約していないが、守られてはいる
  • 支払い方法が変わっても(現金、クレジットカードなど)、この契約は変わらない

context-ProcessPaumentUseCase.ts

ProcessPaymentUseCaseは、シンプルながらインターフェイス分離の原則と依存性逆転の原則を果たしています。

  • インターフェース分離の原則

    • たった1メソッドの極めて小さなインターフェースに依存
    • 使用しない機能への依存が一切ない
    • クライアント(ProcessPaymentUseCase)の視点で設計されたインターフェース
  • 依存性逆転の原則

    • 具象クラスを直接インスタンス化せず、抽象(IPaymentService)に依存
    • コンストラクタインジェクションで依存を外部から注入
    • 実装の差し替えが容易で、テストが簡単

まとめ

コンテキストにSOLID原則について含めるだけで、コードの品質はだいぶ良くなったと思います。他にもClaude Codeにはagent skillsというコンテキストを圧迫しない方法もあるため、多くの情報を組み込みたい場合に有効です。

途中から参画した案件のコードベースを変えることは難しいかもしれませんが、新規開発が始まってまもない案件、そこまで大きくない案件などでは、自分から既存のコードベースを変える提案、またそれを実行するという意味でも役立つのではないのでしょうか。

コーディングをAIに任せすぎると技術負債が溜まっていくことは危惧されていることではありますが、プロンプトやコンテキストの設計をめんどくさがらずに取り組むことで、ある程度は負債が溜まるリスクを軽減できるでしょう。

また、本記事前半で「自分の成長に繋げたい」と述べました。僕は現在の現場で実際に、上述のコンテキストを用いて実装を進めています。現場のソースコードをベースに、好ましい場合やアンチパターン、またClaudeの出力が本当に正しいかどうかを考えることも含めて、学びに非常に役立っていると実感しています。

kayac.bond