技術ブログ

Claude Codeのスキルを再設計した話


この記事は以前の記事の大幅アップデートだ。けれど読まなくてもいい。面倒だろうし、この記事から読んでも理解できるように前提から整理する。

Claude Codeにこう指示したことがある。「Polymorphism over Switch。switch文ではなくポリモーフィズムを使え」と。

結果はどうだったか。AIは相変わらずswitch文を書いてきた。

なぜか。「ポリモーフィズムを使え」という指示が抽象的すぎるからだ。人間のシニアエンジニアでも、この一文だけでは「どういう場面で」「どういうパターンで」ポリモーフィズムを適用すべきかわからない。AIならなおさらだ。

ぼくは気づいた。AIに良いコードを書かせたければ、「良いコードとは何か」を徹底的に言語化するしかない。抽象的なルールは、人間にもAIにも機能しない。


500行のスキルが機能しなかった理由

ぼくは約500行のClaude Codeスキルを運用していた。「単一責任原則を守れ」「小さいクラスを作れ」「副作用を分離しろ」。どれも正しい。正しいが、役に立たなかった。

たとえば「単一責任原則」。これは「クラスは一つの理由でのみ変更されるべき」という原則だ。けれども、何が「一つの理由」なのかは、文脈によって変わる。ユーザー管理クラスは「ユーザーに関することを担当する」という意味では単一責任かもしれない。しかし「認証」「プロフィール更新」「権限管理」を全部持っていたら、それは単一責任だろうか。

AIはこの曖昧さの中で迷う。そして迷った結果、最も「安全」な選択肢を取る。つまり、学習データに多く含まれている手続き的なコードを生成する。


Command/Query/ReadModelという分類

ぼくがたどり着いた解決策の一つが、操作の3層分類だ。

  • Command: 状態を変更し、何も返さない。

  • Query: 状態を変更せず、値を返す。

  • ReadModel: 複数の集約をまたぐ、読み取り専用のビュー。

// Command: 状態を変更し、voidを返す
interface ApproveExpenseCommand {
  execute(expenseId: ExpenseId): Promise<void>
}

// Query: 状態を変更せず、値を返す
interface GetExpenseQuery {
  execute(expenseId: ExpenseId): Promise<ExpenseDto>
}

// ReadModel: 複数集約をまたぐ読み取り専用ビュー
interface ExpenseReportReadModel {
  getMonthlyReport(userId: UserId, month: YearMonth): Promise<MonthlyExpenseReport>
}

この分類があれば、AIは「この操作はCommandかQueryかReadModelか」と判断できる。「単一責任」という抽象的な概念より、はるかに明確だ。


Pending Object Patternで状態遷移を型にする

もう一つの発見は、状態遷移の扱い方だった。

経費精算を例に考えよう。経費には「下書き」「申請中」「承認済み」「却下」といった状態がある。これをswitch文で管理すると、こうなる。

// 悪い例: switch文による状態管理
function processExpense(expense: Expense, action: string): Expense {
  switch (expense.status) {
    case 'draft':
      if (action === 'submit') return { ...expense, status: 'pending' }
      break
    case 'pending':
      if (action === 'approve') return { ...expense, status: 'approved' }
      if (action === 'reject') return { ...expense, status: 'rejected' }
      break
  }
  throw new Error('Invalid transition')
}

この何が問題か。状態が増えるたびにcaseを追加する必要がある。そして、「下書きから直接承認」のような不正な遷移は、ランタイムエラーになる。コンパイル時にはわからない。

Pending Object Patternは、この問題を型で解決する。

// Pending Object Pattern: 型による状態遷移の表現
class DraftExpense {
  constructor(
    readonly id: ExpenseId,
    readonly amount: Money,
    readonly description: string
  ) {}
  
  submit(submittedBy: UserId, submittedAt: Date): PendingExpense {
    return new PendingExpense(
      this.id,
      this.amount,
      this.description,
      submittedBy,
      submittedAt
    )
  }
}

class PendingExpense {
  constructor(
    readonly id: ExpenseId,
    readonly amount: Money,
    readonly description: string,
    readonly submittedBy: UserId,
    readonly submittedAt: Date
  ) {}
  
  approve(approvedBy: UserId, approvedAt: Date): ApprovedExpense {
    return new ApprovedExpense(
      this.id,
      this.amount,
      this.description,
      this.submittedBy,
      this.submittedAt,
      approvedBy,
      approvedAt
    )
  }
  
  reject(rejectedBy: UserId, rejectedAt: Date, reason: string): RejectedExpense {
    return new RejectedExpense(
      this.id,
      this.amount,
      this.description,
      this.submittedBy,
      this.submittedAt,
      rejectedBy,
      rejectedAt,
      reason
    )
  }
}

DraftExpenseにはapprove()メソッドがない。つまり、下書きから直接承認しようとすると、コンパイルエラーになる。不正な状態遷移が、型システムによって防がれる。

AIにこのパターンを見せれば、同じ構造のコードを生成できる。「ポリモーフィズムを使え」という抽象的な指示より、はるかに明確だ。


憲法と施行細則

ここでひとつ問題がある。Pending Object PatternやCommand/Query分離は普遍的な原則だが、プロジェクトごとに異なる設定もある。使用するフレームワーク、ディレクトリ構造、命名規則。

ぼくは2層構造で解決した。

/strict-refactoringは「憲法」だ。プロジェクトを問わず適用される普遍的な原則を定義する。Command/Query分離、Pending Object Pattern、完全コンストラクタ。これらはどんなプロジェクトでも有効な設計原則だ。

CLAUDE.mdは「施行細則」だ。プロジェクト固有の設定を記述する。「このプロジェクトではNext.jsを使う」「ディレクトリ構造はこう」「このライブラリのこの関数を使え」。

そして/generate-claude-mdは、施行細則を自動生成するツールだ。プロジェクトのREADMEや仕様書を読み取り、CLAUDE.mdを生成する。

/strict-refactoring(憲法)
        ↑ 参照
CLAUDE.md(施行細則)
        ↑ 自動生成
/generate-claude-md(ツール)

この構造により、普遍的な原則は一度書けば済む。プロジェクト固有の設定だけを、プロジェクトごとに生成すればいい。


日本語ドメインという壁

もう一つ、日本語圏特有の問題がある。業務システムでは「稟議」「経費精算」「勤怠」といった日本語の概念が頻出する。これをどう英語のクラス名にマッピングするか。

AIに任せると、毎回違う命名になる。ある時はRingi、ある時はApprovalRequest、また別の時はApprovalFlow。一貫性がない。

ぼくはスキルに明示的なマッピング表を追加した。

日本語英語クラス名説明
稟議Approval / ApprovalRequest承認フロー
経費精算ExpenseReport / Reimbursement立替経費の精算
勤怠Attendance / TimeRecord出退勤記録
有給休暇PaidLeave / AnnualLeave年次有給休暇

これでAIは「稟議」を見たとき、即座にApprovalRequestにマッピングできる。他の日本語が来ても命名の一貫性が保証される可能性が高い。少なくともあまりにも変な命名規則は提案されない。


長すぎないか

1,755行のスキルは長すぎないか。そう思われるかもしれない。

けれども、長さではなく構造が重要だ。スキルは明確なセクションに分かれており、AIは必要な部分だけを参照できる。むしろ、500行の曖昧なルールより、1,755行の具体的なルールのほうがAIにとって扱いやすい。

これは人間のドキュメントと同じだ。「短くて曖昧」より「長くて明確」のほうが実用的なのだ。


言語化という仕事

AIに良いコードを書かせることは、「良いコードとは何か」を言語化する仕事だ。

ぼくたちは普段、暗黙知に頼っている。「なんとなくこう書くべき」「経験的にこのパターンがいい」。けれどもAIには暗黙知がない。すべてを明示的に教える必要がある。

その過程で気づいたことがある。言語化できないルールは、実は自分でも理解していないのだ。「ポリモーフィズムを使え」と言いながら、どういう場面でどう使うかを説明できなければ、それは理解ではなく信仰だ。

AIに教えることは、自分の理解を試すことでもある。500行が1,755行になったのは、ぼく自身の理解が深まった証拠でもある。

スキルの設計は続く。まだ言語化できていないパターンがある。まだAIに伝わっていない意図がある。けれども、その作業自体が、ソフトウェア設計への理解を深めてくれる。AIとの協働は、ぼくたちに言語化を強いる。それは面倒であると同時に、貴重な学びの機会でもある。


改修したスキルはGitHubで公開している: https://github.com/NAM-MAN/strict-refactoring-plugin