経緯・背景
学習目的で AI エージェントを自作した際、LLM プロバイダー切り替えや、リクエスト・レスポンスのメッセージ変換、及びエラーハンドリングの各処理において、ストラテジーパターンが大いに役立ったため、リファクタリング方法や得られる利点などを備忘録として書き残しておこうと思います。
自作した AI エージェント「 Pico-de-CLI 」は、
クラウド LLM だけでなくローカル LLM にも対応しており、カスタムツール実行 (Function Calling) が可能な、軽量の CLI エージェントです。
MVP (Minimum Viable Product : 実用最小限の製品) として開発したので、AI エージェントの仕組みや動作原理について学ぶ際にも最適です。
気になった方は、以下のGitHubリポジトリにアクセスして、ぜひ試してみてください!
リファクタリング手法
単純系 : 事前定義された特定の値と完全に一致するかで分岐する場合
プロバイダー名('openai' や 'google' など)のような 「特定の値と完全に一致するかどうか」 で処理を分岐させるケースです。
これらの分岐では、if-else や switch 文を連ねてしまいがちですが、分岐の数が増えるにつれて、次第にコードが肥大化してしまいます。
このような単純な分岐を、ストラテジーパターンでリファクタリングする場合、 TypeScript の Record<K, V> 型や Map を利用することが、非常に効果的です。
各条件キーと実行したい処理 (ストラテジー) を、 1 対 1 のオブジェクトとしてマッピングすることで、条件分岐の記述を完全に排除できます。
リファクタリング前のサンプルコード
function handle(provider: string, modelName: string): LanguageModel {
if (provider === 'openai') {
// do something...
const openai = createOpenAI();
return openai(modelName);
} else if (provider === 'ollama') {
// do something...
const ollama = createOllama();
return ollama(modelName);
} else if (provider === 'google') {
// do something...
const google = createGoogle();
return google(modelName);
} else {
throw new Error(`Unsupported provider: '${provider}'`);
}
}
function handle(provider: string, modelName: string): LanguageModel {
switch (provider) {
case 'openai': {
// do something...
const openai = createOpenAI();
return openai(modelName);
}
case 'ollama': {
// do something...
const ollama = createOllama();
return ollama(modelName);
}
case 'google': {
// do something...
const google = createGoogle();
return google(modelName);
}
default:
throw new Error(`Unsupported provider: '${provider}'`);
}
}
リファクタリング後のサンプルコード
// 1. ストラテジーの型定義
type ModelStrategy = (
modelName: string,
) => LanguageModel;
// 2-1. 個別ストラテジーの実装
const openAIModelStrategy: ModelStrategy = (
modelName: string,
) => {
// do something...
const openai = createOpenAI();
return openai(modelName);
};
// 2-2. 個別ストラテジーの実装
const ollamaModelStrategy: ModelStrategy = (
modelName: string,
) => {
// do something...
const ollama = createOllama();
return ollama(modelName);
};
// 2-3. 個別ストラテジーの実装
const googleModelStrategy: ModelStrategy = (
modelName: string,
) => {
// do something...
const google = createGoogle();
return google(modelName);
};
// 3. 各種ストラテジーを集約 (Record<K, V> を型として利用)
const modelStrategies: Record<string, ModelStrategy> = {
openai: openAIModelStrategy,
ollama: ollamaModelStrategy,
google: googleModelStrategy,
};
// 4. 実行関数で必要なストラテジーを呼び出す (計算量 $O(1) で処理を取得)
function handle(provider: string, modelName: string): LanguageModel {
const modelStrategy = modelStrategies[provider];
if (!modelStrategy) {
throw new Error(`Unsupported provider: '${provider}'`);
}
return modelStrategy(modelName);
}
複雑系 : 条件式の判定結果に応じて動的に分岐する場合
入力値の型 (typeof) や特定プロパティの有無、あるいは「 A が B より大きい場合」というような 「条件式の評価結果 (true/false) 」 で処理を分岐させるケースです。
これらの複雑な条件では、単純なキーのマッピング(ディクショナリ)だけで判定結果を得るのが困難です。
このような場合の解決策としては、「条件を判定する責務 (canHandle)」と「実際の処理を行う責務 (handle)」の 2 つを持つストラテジーオブジェクトを個別定義します。
その後、それらのストラテジーオブジェクトを配列に格納し、 Array.prototype.find() などを用いて、条件に合致するストラテジーを動的に探索・実行するアプローチをとります。
リファクタリング前のサンプルコード
type ExecCommandInput = {
command?: unknown;
commandName?: unknown;
commandArgs?: unknown;
};
function execCommand(input: ExecCommandInput): string {
let commandName = '';
let commandArgs: string[] = [];
if (typeof input.command === 'string') {
const parts = parse(input);
if (!parts) {
throw new Error("Unexpectedly failed to parse: 'command'");
}
commandName = parts[0] || '';
commandArgs = parts.slice(1);
} else if (typeof input.commandName === 'string') {
commandName = input.commandName;
const args = input.commandArgs;
if (
args !== undefined &&
(!Array.isArray(args) || !args.every((arg) => typeof arg === "string"))
) {
throw new Error("'commandArgs' must be specified as an array of strings.");
}
commandArgs = args as string[];
} else {
throw new Error("You must specify the field of 'command' or 'commandName'.");
}
return doSomething(commandName, commandArgs);
}
type ExecCommandInput = {
command?: unknown;
commandName?: unknown;
commandArgs?: unknown;
};
function execCommand(input: ExecCommandInput): string {
let commandName = '';
let commandArgs: string[] = [];
switch (true) {
case typeof input.command === 'string': {
const parts = parse(input);
if (!parts) {
throw new Error("Unexpectedly failed to parse: 'command'");
}
commandName = parts[0] || '';
commandArgs = parts.slice(1);
break;
}
case typeof input.commandName === 'string': {
commandName = input.commandName;
const args = input.commandArgs;
if (
args !== undefined &&
(!Array.isArray(args) || !args.every((arg) => typeof arg === "string"))
) {
throw new Error("'commandArgs' must be specified as an array of strings.");
}
commandArgs = args as string[];
break;
}
default:
throw new Error("You must specify the field of 'command' or 'commandName'.");
}
return doSomething(commandName, commandArgs);
}
リファクタリング後のサンプルコード
// 1. ストラテジーの型定義
type CommandStrategy = {
canHandle: (input: ExecCommandInput) => boolean;
handle: (input: ExecCommandInput) => {
commandName: string;
commandArgs: string[];
};
};
// 2-1. 個別ストラテジーの実装
const simpleCommandStrategy: CommandStrategy = {
canHandle: (input) => typeof input.command === "string",
handle: (input) => {
const parts = parse(input);
if (!parts) {
throw new Error("Unexpectedly failed to parse: 'command'");
}
return {
commandName: parts[0] || '',
commandArgs: parts.slice(1),
}
},
};
// 2-2. 個別ストラテジーの実装
const separatedCommandStrategy: CommandStrategy = {
canHandle: (input) => typeof input.commandName === 'string',
handle: (input) => {
const args = input.commandArgs;
if (
args !== undefined &&
(!Array.isArray(args) || !args.every((arg) => typeof arg === "string"))
) {
throw new Error("'commandArgs' must be specified as an array of strings.");
}
return {
commandName: input.commandName as string,
commandArgs: (args || []) as string[],
}
},
};
// 2-3. 個別ストラテジーの実装 (デフォルト処理)
const defaultStrategy: CommandStrategy = {
canHandle: (input) => true, // 常に条件一致させる
handle: (input) => {
throw new Error("You must specify the field of 'command' or 'commandName'.");
},
};
// 3. 各種ストラテジーを集約 (オブジェクト配列を型として利用)
const commandStrategies: CommandStrategy[] = [
simpleCommandStrategy,
separatedCommandStrategy,
];
// 4. 実行関数で必要なストラテジーを呼び出す (計算量 $O(N) で処理を取得)
function execCommand(input: ExecCommandInput): string {
const strategy = commandStrategies.find(
(cs) => cs.canHandle(input)
) ?? defaultStrategy; // 全てのストラテジーと条件不一致の場合には、デフォルト処理を呼び出す
const { commandName, commandArgs } = strategy.handle(input);
return doSomething(commandName, commandArgs);
}
リファクタリングによって得られる利点
ここからは、ストラテジーパターンを用いたリファクタリングによって、どのような利点を得られるのかについて、具体的に説明してます。
機能追加・変更時の影響範囲を縮小できる
ストラテジーパターンを用いたリファクタリングによる最大の利点は、オブジェクト指向設計の原則の1つである OCP (Open-Closed Principle : 開放閉鎖の原則) を満たしやすくなる点です。
具体例として、先に説明した単純系の分岐処理で、新たに anthropic という LLM プロバイダーを追加しようとした場合、 if-else や switch 文では、呼び出し元の関数 (handle) に、直接手を加えて分岐を書き足す必要があります。
しかしながら、これにより既存の動作するコードを誤って破壊してしまうリスク (デグレ) が伴います。
一方、ストラテジーパターンの場合は、新規プロバイダー用のストラテジー関数を新たに作成し、マッピング用のオブジェクト(または配列)に登録するだけで済みます。
したがって、既存のロジックや呼び出し元の関数には、一切変更を加える必要がないため、極めて安全に機能拡張を実施することができます。
ソースコードの可読性が向上する
if-else や switch 文のブロック内に、複雑なビジネスロジックを直接記述すると、いわゆる「ファット関数 (肥大化した関数)」になってしまい、その関数で結局何をしようとしているのか、という全体像の把握が難しくなります。
このような場合に、ストラテジーパターンを適用することで、分岐の条件式や個別の処理が、それぞれの関数やオブジェクトにカプセル化されます。
その結果、呼び出し元の関数は、適切なストラテジーを取得して実行する、というルーティングの役割 (関心の分離) のみに集中でき、コードの意図が非常に明確化して、処理の見通しが劇的に改善されます。
各機能の単体テストを独立して実装・検証できる
巨大な分岐を持つ関数をテストする場合、全ての分岐ルートを網羅するために、複雑なモックの注入や、膨大なパターンのテストケースなどを、1つのテストファイル内に記述しなければなりません。
一方で、このような処理をストラテジーとして分割していれば、 openAIModelStrategy や simpleCommandStrategy のように、個別の処理単位に対して、それぞれの単体テストを分けて記述することができます。
故に、前提条件の準備やモック化がシンプルになり、エッジケースの検証も容易となるため、テスト品質の向上やメンテナンスコストの大幅な削減に貢献できます。
まとめ
今回は、 TypeScript において肥大化しがちな if-else や switch 文を、ストラテジーパターンを用いてリファクタリングする方法とその利点について解説しました。
- リファクタリング手法
- 完全一致の分岐 (単純系)
-
Record<K, V>型を使った辞書パターンで条件分岐を消去 - 計算量は $O(1)$ であり、分岐が増えても特に影響無し
-
- 条件評価の分岐(複雑系)
-
canHandleとhandleメソッドを持つオブジェクト配列からfind()で探索 - 計算量は $O(N)$ であり、分岐の優先順位を考慮すると効率的
-
- 完全一致の分岐 (単純系)
- リファクタリングによって得られる利点
- OCP の遵守 (壊れにくく拡張性が高い)
- 関心の分離 (可読性が向上する)
- 単体テストの独立 (メンテナンスコストを削減できる)
条件分岐が増えて、コードの見通しが悪くなってきた…と感じたら、本稿で紹介したストラテジーパターンへのリファクタリングを、ぜひ検討してみてください!
参考文献