はじめに
TRIAL&RetailAI Advent Calendar 2025 の24日目の記事です。
昨日は@azuma-takuyaさんの『# ゲーム開発のバックエンドを支える GaaS』
という記事でした。
GaaSというserviceがあるんですね。
この分野については全くの門外漢なので、ゲーム業界を取り巻く背景なども踏まえて多角的に知ることができ、とても興味深かったです!
私も全然ゲームをプレイ出来ていないですが、ストーリーを追ったり、人がプレイしているのをストリーミング配信などで見るのは好きなので、
こう言ったプロダクトの裏側について知ることが出来るのはまた別の面白さを感じつつも、
その大変さややり甲斐、情熱、誇りなどのストーリーもどこか想像される気がして、自分のモノづくりにも反映させていきたいです!
さて、今回は表題のリファクタリング手法について学んだので、
これを記事にしてみたいと思います。
自己紹介
TRIALの基盤チームにてモバイルアプリの開発に従事しています。
最近はそれ以外にもQAリード的なことやスクラムマスター的なことも主体的にやったりして、
忙しないですが楽しくやっています!
『落ちているボールは拾う』
『ユーザーも作り手も幸せであるようにしたい』
そんな人でありたいです💪
Strategy Patternとは
Strategy PatternとはGoF(Gang of Four)が提唱した23個のデザインパターンの一つであり、
コードの再利用性や変更容易性を高めるための設計手法です。
具体的には個々の具象的な振る舞いをカプセル化し、プログラムの実行時にそれらを互いに交換可能にする手法です。
具体例
とはいえテキストベースだけだと中々掴みづらい部分もありますので、コードに置き換えて説明してみようと思います。
リファクタリング前
最初はStrategy Patternを適用する前のコードから見てみましょう。
以下のコードを見てください。
// Strategy Pattern適用前: キャッシュ機能の切り替えはフラグで管理する設計
class DataRepository {
final Map<String, List<Item>> _cache = {};
bool _useCache = true;
bool _initialized = false;
bool get isReady => _initialized;
void initialize() {
_initialized = true;
}
void setUseCache(bool useCache) {
_useCache = useCache;
}
Future<List<Item>> getData(String id) async {
if (!_initialized) {
throw StateError('Repository not initialized');
}
if (_useCache && _cache.containsKey(id)) {
return _cache[id]!;
}
final data = await api.getItems(id);
if (_useCache) {
_cache[id] = data;
}
return data;
}
void clearCache() {
_cache.clear();
}
}
// 使用例
final repo = DataRepository();
repo.initialize();
repo.setUseCache(true); // キャッシュを使う
// または
repo.setUseCache(false); // 直接取得
このコードはAPIとコミュニーケーションをするDataRepositoryクラスについて定義されていますね。
具体的にはキャッシュデータを保存するように設計され、その有無によってデータ取得の方法が異なるように分岐的に設計されています。
一見するとシンプルな設計に思えますが、データを取得するgetDataメソッドではif文が連続しており、今後の機能修正次第では元の振る舞いを保証することが難しくなることが想定されます。
本来のあるべき姿としては元の振る舞いを崩さずして機能追加・修正が出来るようになっているべきであり、それを保証するように設計されているべきかと思われます。
加えて、キャッシュを返すのもAPIから直接取得するのもデータの取得という点においては変わりませんが、関心事としては異なってもいます。
これも本来は個々の具体的な関心事によって分離的に設計されているべきであり、それを抽象化したインターフェースによって変更に対して強くあるべきかと思われます。
リファクタリング後 ~ クラスベースを使う場合 ~
上記コードの弱点を踏まえた上で、次はStrategy Patternを適用したコードを見てみましょう。
以下のコードを見てください。
// Strategy Pattern適用後:
// キャッシュ機能は具象クラスに分離させ、Cotextで切り替え可能にする設計
// かつ、インターフェース分離の原則を適用し、必要なメソッドのみを持つインターフェースに分けている
// ダミー型定義
class Item {}
class Api {
Future<List<Item>> getItems(String id) async => [];
}
// Strategy インターフェース(基本)
abstract interface class DataFetchStrategy {
Future<List<Item>> fetch(String id);
bool get isReady;
}
// キャッシュ機能を持つStrategy用のインターフェース
abstract interface class CacheableDataFetchStrategy
implements DataFetchStrategy {
void clearCache();
}
// 実装1: キャッシュ付き
class CachedDataFetchStrategy implements CacheableDataFetchStrategy {
CachedDataFetchStrategy({required this.api});
final Api api;
final Map<String, List<Item>> _cache = {};
bool _initialized = false;
@override
bool get isReady => _initialized;
void initialize() {
_initialized = true;
}
@override
Future<List<Item>> fetch(String id) async {
if (_cache.containsKey(id)) {
return _cache[id]!;
}
final data = await api.getItems(id);
_cache[id] = data;
return data;
}
@override
void clearCache() {
_cache.clear();
}
}
// 実装2: 直接取得
class DirectDataFetchStrategy implements DataFetchStrategy {
DirectDataFetchStrategy({required this.api});
final Api api;
@override
bool get isReady => true;
@override
Future<List<Item>> fetch(String id) async {
return await api.getItems(id);
}
}
// 使用例
class DataRepository {
DataRepository(this.strategy);
final DataFetchStrategy strategy;
Future<List<Item>> getData(String id) async {
if (!strategy.isReady) {
throw StateError('Strategy not ready');
}
return strategy.fetch(id);
}
void refresh() {
// キャッシュ機能を持つストラテジーの場合のみクリア
switch (strategy) {
case CacheableDataFetchStrategy s:
s.clearCache();
default:
throw UnsupportedError('Strategy does not support cache operations');
}
}
}
void main() {
// 切り替え
final repo1 = DataRepository(CachedDataFetchStrategy(api: Api()));
final repo2 = DataRepository(DirectDataFetchStrategy(api: Api()));
}
先述のコードとの違いはDataFetchStrategyというインターフェースを定義して、
それを実装するように個々の具象的なCachedDataFetchStrategyクラスとDirectDataFetchStrategyクラスを設計していることです。
加えて先述のコードと同名のDataRepositoryクラスにはインターフェースであるDataFetchStrategyをプロパティに持たせてもいますね。
こうすることでコンストラクタインジェクションを効かせ、fetchというデータ取得のメソッドの振る舞いがプロブラムの実行時に変わるようになっています。
先述のコードにあったgetDataメソッド内のif文の冗長性が解消されているのもそのためであり、今後の機能追加・修正が入っても影響範囲は個々の具象クラスに限定化されるようになりました。
プログラムの実行時や修正時にその個別具体的な違いを意識しなくても良くなったというカプセル化が効いているのです。
さらに実装先の具象クラスによってその個々の関心事が表現されていることで、先述のDataRepositoryクラスにあったような「キャッシュを使うか否か」といったflagを持たせる必要性もなくなり、それに関連するメソッドも不要になりました。
DataRepositoryクラスの関心事もよりはっきりとなったことでコードの再利用性や変更容易性が高まったのです。
冗長なif文やif-else文での分岐がタイプコードによるものであった場合、その網羅性チェックも気になるところです。
その場合はこのStrategy Patternを応用しつつ、インターフェースをsealedクラス化することによって実装の強制力を持たせつつ、switchでの網羅性チェックを表現することもできます。
詳細は僭越ながら以下の記事に譲ります。
https://qiita.com/fujihara_hideyuki/items/c84a67dd7628b0a81594
詰まるところ、変更に対して強く、関心事もより明示的になったと言えるかと思われます。
応用編 ~ 関数ベース (typedef) で表現してみる
さっきの適用例はクラスベースでのリファクタリングでしたが、少し視点を変えて関数ベースによる別の表現手法も考えてみましょう。
以下のコードを見てください。
// Strategy Pattern適用後(関数型): 関数を使ってContextで切り替える設計
// ダミー型定義
class Item {}
class Api {
Future<List<Item>> getItems(String id) async => [];
}
class Cache {
final Map<String, List<Item>> _cache = {};
List<Item> get(String id) => _cache[id]!;
void put(String id, List<Item> items) {
_cache[id] = items;
}
}
// 関数の型定義
typedef DataFetcher = Future<List<Item>> Function(String id);
// 各実装
Future<List<Item>> fetchFromApi(String id, Api api) async {
return await api.getItems(id);
}
Future<List<Item>> fetchFromCache(String id, Cache cache) async {
return cache.get(id);
}
// 使用例
class DataRepository {
DataRepository(this.fetcher);
final DataFetcher fetcher;
Future<List<Item>> getData(String id) {
return fetcher(id);
}
}
void main() {
// 切り替え
final repo1 = DataRepository((id) => fetchFromApi(id, Api()));
final repo2 = DataRepository((id) => fetchFromCache(id, Cache()));
}
typedefという関数型定義の記法を用いてDataFetcherという関数型のインターフェースを定義しています。
このインターフェースには具体的な実装内容は含まれていないのですが、
同一のシグネチャを有するfetchFromApi関数とfetchFromCache関数とがそれを実装することで、個々の具象的な振る舞いを表現しています。
今後新たな振る舞いをする関数を実装する際もDataFetcherインターフェースを実装するようにすれば拡張性を持たせることができ、変化に対して強くあることができますね。
ちなみにその使い方については先述のクラスベースの手法と同じであり、DataRepositoryクラスのプロパティにインターフェースであるDataFetcherを持たせることで、実行時に具象的な振る舞いを切り替えて実行できるように設計されています。
関数型の手法を用いても関心事をより明示的にして、変更容易性を高めることができそうですね。
使い所の違い
クラスベースと関数ベースの表現手法を紹介してみましたが、その使い分けが気になるところですよね。
そこで以下のように整理してみました。
クラスベースを使う場合
- ✅ 内部状態を持つ (キャッシュ、設定など)
- ✅ 複数のメソッドが必要
- ✅ ライフサイクル管理 (initialize/dispose)
- ✅ 継承や共通ロジックの共有
関数ベース (typedef) を使う場合
- ✅ 単一の操作のみを抽象化
- ✅ 状態を持たない
- ✅ シンプルさ重視
- ✅ 軽量な実装
判断基準
「振る舞い1つだけ & 状態なし」→ 関数
「それ以外」→ クラス
状態を持たず抽象化したい振る舞いも1つだけであればシンプルな関数ベースで表現して、
それ以外のユースケースであればクラスベースで表現するといった使い分けイメージです。
こうすることでクラスの完全性を保ちながら変更容易性を高め、関心事の分離にも一役買うことが出来そうですね。
以下に具体例を書いてみたのでご参考に。
実際の判断例
// ❌ オーバーエンジニアリング (クラス不要)
abstract class StringFormatter {
String format(String input);
}
// ✅ シンプルに関数で十分
typedef StringFormatter = String Function(String input);
// ❌ 関数では不十分 (状態管理が必要)
typedef Logger = void Function(String message);
// ✅ クラスで状態管理
abstract class Logger {
void log(String message);
void setLevel(LogLevel level);
void clearLogs();
List<String> get history;
}
合成による手法も組み合わせてみる
ここでまた視点を変えて、先述のクラスベースの手法に別のリファクタリング手法「継承を避けて合成で書く」を組み合わせてみましょう。
この手法については僭越ながら以下の記事をご参考に。
// Strategy Pattern適用後(合成パターン):
// キャッシュ機能は具象クラスに分離させ、Cotextで切り替え可能にする設計
// かつ、インターフェース分離の原則を適用し、必要なメソッドのみを持つインターフェースに分ける
// かつ、継承ではなく合成によって共通機能を再利用する
// ダミー型定義
class Item {}
class Api {
Future<List<Item>> getItems(String id) async => [];
}
// Strategy インターフェース(基本)
abstract interface class DataFetchStrategy {
Future<List<Item>> fetch(String id);
bool get isReady;
}
// キャッシュ機能を持つStrategy用のインターフェース
abstract interface class CacheableDataFetchStrategy
implements DataFetchStrategy {
void clearCache();
}
// 基本実装: 共通機能を提供するベースクラス
class BaseDataFetchStrategy implements DataFetchStrategy {
BaseDataFetchStrategy();
final _api = Api();
@override
bool get isReady => true;
@override
Future<List<Item>> fetch(String id) async {
return await _api.getItems(id);
}
}
// 実装1: キャッシュ付き(合成 + ISPを使用)
class CachedDataFetchStrategy implements CacheableDataFetchStrategy {
CachedDataFetchStrategy();
// 合成: 基本機能を持つクラスをプライベートフィールドとして保持
final BaseDataFetchStrategy _baseStrategy = BaseDataFetchStrategy();
final Map<String, List<Item>> _cache = {};
bool _initialized = false;
@override
bool get isReady => _initialized;
void initialize() {
_initialized = true;
}
@override
Future<List<Item>> fetch(String id) async {
if (_cache.containsKey(id)) {
return _cache[id]!;
}
// 基本機能を委譲
final data = await _baseStrategy.fetch(id);
_cache[id] = data;
return data;
}
@override
void clearCache() {
_cache.clear();
}
}
// 実装2: 直接取得(合成を使用)
class DirectDataFetchStrategy implements DataFetchStrategy {
DirectDataFetchStrategy();
// 合成: 基本機能を持つクラスをプライベートフィールドとして保持
final BaseDataFetchStrategy _baseStrategy = BaseDataFetchStrategy();
@override
bool get isReady => _baseStrategy.isReady;
@override
Future<List<Item>> fetch(String id) async {
// 基本機能を委譲
return _baseStrategy.fetch(id);
}
}
// 使用例
class DataRepository {
DataRepository(this.strategy);
final DataFetchStrategy strategy;
Future<List<Item>> getData(String id) async {
if (!strategy.isReady) {
throw StateError('Strategy not ready');
}
return strategy.fetch(id);
}
void refresh() {
// キャッシュ機能を持つストラテジーの場合のみクリア
switch (strategy) {
case CacheableDataFetchStrategy s:
s.clearCache();
default:
throw UnsupportedError('Strategy does not support cache operations');
}
}
}
void main() {
// 切り替え
final cachedStrategy = CachedDataFetchStrategy();
cachedStrategy.initialize();
final repo1 = DataRepository(cachedStrategy);
final repo2 = DataRepository(DirectDataFetchStrategy());
}
個々の具象クラスで共通する振る舞いを表現したい場合、その振る舞い部分を合成パーツBaseDataFetchStrategyとして用意しておき、具象クラスではそのオブジェクトを生成し、それを介して振る舞いを表現することが出来ます。
こうすることで共通の振る舞いについて、それを呼び出す際はその振る舞いが同一であることを保証することができ、メンテナンス性も向上することが出来ます。
今後の開発で同様な共通の振る舞いが出て来た際も、合成パーツとして実装しておけば変化に対して強くあることができるので、コンポーネントとして抽象化できそうな場合は適用してみても良いかも知れません。
まとめ
今回はStrategy Patternと他の手法を組み合わせてコーディングをしてみました。
色々と応用が効きそうなデザインパターンなので、ユースケースごとに比較検討しながら書いていきたいですね。
他にも様々なデザインパターンや設計手法、リファクタリング手法があるので、これからもどんどんインプット&アウトプットをしていきます💪
最後に
明日は@le_thi_hangさんの『Protocol Buffers(protobuf)入門』という記事です。
お楽しみに〜🚀
RetailAIとTRIALではエンジニアを募集しています!
興味がある方はご連絡ください!
一緒に働けることを楽しみにしています!
参考