「UserManagerというのかい?贅沢な名だね」Flutter/Dartで絶対に迷わないクラス命名パターン12選【実装例つき】
はじめに
こんにちは、株式会社メンバーズ Cross Application カンパニーの田原です。
プログラミングにおいて、変数名や関数名、そしてクラス名といった「名前」は、コードの品質を左右する非常に重要な要素です。特に、システムの根幹をなす「クラス」の命名は、そのクラスが何をするものなのか、どのような役割を担うのかを明確に示し、開発効率と保守性を大きく向上させる鍵となります。
しかし、いざクラス名を付けようとすると、「結局何でもかんでも 〜Manager や、 〜Service になってしまう…」と悩んだ経験はないでしょうか?
Manager や Service といった命名は汎用性が高く便利ですが、その汎用性の高さゆえに、クラスの具体的な役割や責務が曖昧になってしまいがちです。結果として、コードを読んだときに「このクラスは何をしているのだろう?」「どこを見れば目的の処理が見つかるのだろう?」といった疑問が生じ、予期せぬバグの温床となったり、新しい機能の追加や改修の際に多大な時間を要することにもなりかねません。
もし、Manager や Service クラスが担っている責務を適切に仕分けし、細分化した責務に相応しい命名のクラスに分割できたら、どうでしょうか?コードはより明確になり、各クラスの役割が瞭然となるはずです。
役割が明確なクラスは、変更の影響範囲を限定し、バグの発生を抑えます。
本記事では、より意図が明確で保守しやすいコードを書くためのクラス命名パターンについて深く掘り下げていきます。単に「どう名付けるか」だけでなく、「なぜその名が付けられるのか」という背景にある設計思想や、その命名が示唆する「どのような実装が期待されるか」に焦点を当てて解説します。
この記事を通じて、皆さんがクラス命名に迷うことなく、より洗練された、そして「話せるコード」を書けるようになる一助となれば幸いです。
クラス命名パターンとその役割
ここでは、具体的なクラス命名パターンを、その役割、期待される実装、そして Flutter / Dart による具体的なコード例とともに解説します。
1. Client
役割・責務
Client は、外部システムやサービスとの通信を担当する責務を持つクラスです。特定のAPIエンドポイントへのリクエスト送信やレスポンス処理を行います。
このクラスは具体的な通信手段(HTTP、WebSocket、gRPC など)や認証ロジック、データフォーマット(JSON、XML など)といった低レベルな通信詳細を隠蔽し、上位レイヤーからは抽象化された通信インターフェースを提供します。これにより、上位層は外部システムとのやり取りを意識することなく、ビジネスロジックに集中できます。
なぜその命名なのか
「顧客」「依頼者」という言葉の通り、Client は外部システムにリクエストを「発行する」役割を明確に示します。この命名は、クライアントがサービスを利用する側であるという関係性をシンプルに表現し、特定の外部サービスとのインターフェースとしての役割を直感的に伝えます。
期待される実装
Client クラスには、主に以下の役割が期待されます。
- プロトコルに特化した通信処理
- HTTP通信、SOAP通信、WebSocket通信など、具体的なプロトコルに特化したリクエストの構築と送信、およびレスポンスの受信と初期処理を担当します。
- 認証情報の付与とエラーハンドリング
- リクエストに認証トークンやAPIキーなどを付与するロジック、およびネットワークエラー、HTTPステータスコードによるエラー(400系、500系など)のハンドリングを担います。これにより、上位層は通信エラーの詳細を意識せず、アプリケーションレベルのエラーに集中できます。
- 付帯処理
- リトライ処理、タイムアウト設定、リクエスト・レスポンスのロギング、受け取ったレスポンスの初期的なパース(JSON → Map など)などもこの層で担当することが多いです。
具体的なコード例(Flutter / Dart)
以下の例では、ApiClient を抽象クラスとして定義することで、HTTP通信だけでなく、将来的にWebSocket通信やローカルストレージへのアクセスなど、異なる通信方法が必要になった場合でも、同じインターフェースで対応できる柔軟性を持たせています。
また、HttpApiClient の内部では Dio という具体的なライブラリを使用していますが、これは ApiClient インターフェースの背後に隠蔽されており、上位のレイヤー(例: Repository)は Dio の存在を知る必要がありません。これにより、依存関係が疎になり、テストやメンテナンスがしやすくなります。
// --- 抽象インターフェースの定義例 ---
abstract class ApiClient {
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? queryParameters});
Future<Map<String, dynamic>> post(String path, Map<String, dynamic> data);
Future<Map<String, dynamic>> put(String path, Map<String, dynamic> data);
Future<void> delete(String path);
}
// --- 実装クラスの定義例 ---
import 'package:dio/dio.dart'; // HTTP通信ライブラリのDioを使用
// 抽象インターフェースの具体的なHTTP実装
class HttpApiClient implements ApiClient {
final Dio _dio;
HttpApiClient(this._dio);
@override
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? queryParameters}) async {
try {
final response = await _dio.get(path, queryParameters: queryParameters);
if (response.statusCode! >= 200 && response.statusCode! < 300) {
return response.data as Map<String, dynamic>;
} else {
// HTTPステータスコードに基づくエラーハンドリング
throw ApiException('Failed to load data: Status Code ${response.statusCode}');
}
} on DioException catch (e) {
// Dio固有のネットワークエラーやサーバー応答エラーを捕捉し、アプリケーションの例外として返す
throw ApiException('Network or API error: ${e.message}');
} catch (e) {
// その他の予期せぬエラーを捕捉
throw ApiException('An unexpected error occurred: $e');
}
}
@override
Future<Map<String, dynamic>> post(String path, Map<String, dynamic> data) async { ... }
@override
Future<Map<String, dynamic>> put(String path, Map<String, dynamic> data) async { ... }
@override
Future<void> delete(String path) async { ... }
}
// --- カスタム例外クラスの定義例 ---
class ApiException implements Exception {
final String message;
ApiException(this.message);
@override
String toString() => 'ApiException: $message';
}
注意点・アンチパターン
Client パターンを効果的に使用し、その責務を明確に保つためには、以下の点に注意してください。
Client 実装時の一般的な考慮事項:
- 責務の範囲を通信に限定する: Client は外部システムとの通信実行に徹し、ビジネスロジックやドメインオブジェクトへのデータマッピングは含めません。レスポンスは生のデータ(例: Map<String, dynamic> や DTO)で返し、ドメインオブジェクトへの変換は上位の Repository や Converter / Mapper の役割とします。
- 適切なエラーハンドリングの粒度: Client で処理するエラーは、主に通信レベル(ネットワークエラー、HTTPステータスコードエラーなど)に限定します。業務ロジックに起因するエラー(例: 無効な入力によるAPIからの400エラーの詳細な解釈)は、Client が基本的な情報を提供し、上位層で意味付けを行うのが適切です。
- 適切な抽象化レベルの判断: 小規模なアプリや通信方法の変更がほとんど想定されない場合、インターフェースによる過度な抽象化(例: 上記コード例の ApiClient インターフェース)は不要なこともあります。しかし、将来の拡張性やテスト容易性(モックへの差し替えなど)を考慮するならば、抽象化は有効な手段です。
他の類似パターンとの使い分け・注意点:
- Driver との役割分担を明確に: Client は主に外部「サービス」を利用(リクエスト送信・レスポンス受信)する立場です。一方、Driver はハードウェアや特定の通信プロトコルなど、より低レベルな「リソース」を直接「操作・制御」し、状態管理や双方向通信を担います。
- Gateway との違いを理解する: Client は特定の通信プロトコル(例: HTTP)や単一サービスとの直接的なやり取りに特化します。Gateway はより広範な外部システム全体への「窓口」として機能し、内部で複数の Client や複雑な通信戦略、認証方式などを隠蔽することがあります。単純なAPIアクセスには Client で十分な場合、Gateway は過剰かもしれません。
- Repository との責務境界を守る: Client はデータ取得の「手段」を提供するのに対し、Repository はドメインオブジェクトの「集合」を管理し、永続化の詳細(ローカルDB、リモートAPIなど)を抽象化します。Client がドメインオブジェクトを直接生成して返すべきではありません。
2. Driver
役割・責務
Driver は、主にアプリケーションと低レベルなリソース(ハードウェアデバイス、外部システム、特定の通信プロトコルなど)との間のインターフェースとして機能し、それらを「駆動」または「操作」する責務を持つクラスです。
アプリケーションの他の部分が、リソース固有の複雑な詳細(例: ハードウェアコマンド、通信プロトコルの詳細)を意識することなく、抽象化されたインターフェースを通じてそのリソースを利用できるようにします。
なぜその命名なのか?
「運転手」「駆動装置」という言葉の通り、特定のハードウェアやシステムを「動かす」「制御する」役割を明確に示します。アプリケーションと対象リソースとの間の「仲介役」として、指示を伝えてリソースを操作するイメージです。
期待される実装
Driver クラスには、主に以下の役割が期待されます。
- ハードウェアデバイスとの通信カプセル化
- 特定のハードウェアデバイス(例: Bluetoothプリンター、USBカメラ、センサー、NFCリーダー)との間で発生する、低レベルな通信ロジックや制御コマンドの送受信をカプセル化します。
- 低レベルデータベースAPIの提供
- データベースシステムとの接続確立、SQLクエリの実行、結果セットの取得といった、低レベルなデータベース操作API を提供します(例: PostgresDriver、MySqlDriver)。これらは通常、より高レベルな Repository や Gateway の内部で利用されます。
- ネットワークプロトコルや低レベルI/Oの実装
- 特定のネットワークプロトコル(例: MQTTブローカーとの間でメッセージを送受信する MqttDriver)や、OSレベルのファイルI/O操作など、低レベルな通信・入出力処理を実装します。
- 外部SDKのラップとインターフェース提供
- 外部サービスが提供するSDKの複雑な、あるいは低レベルな機能をラップし、アプリケーションの他の部分がよりシンプルで一貫性のあるインターフェースを通じて利用できるようにします。
- インフラストラクチャ層への配置
- 通常、アプリケーションのコアなビジネスロジックからは隔離され、システムと外部リソース間の境界となるインフラストラクチャ層に位置づけられます。
具体的なコード例(Flutter / Dart)
以下は、Flutterアプリケーションが NFC タグの読み書きを行うための NfcDeviceDriver のインターフェースと、その実装例です。この実装例では、NfcDriverImpl が、実際のNFC操作プラグイン(ここでは_UnderlyingNfcPluginAPIとしてその存在を示唆)の機能をラップし、アプリケーションに安定したインターフェースを提供します。
import 'dart:async';
import 'dart:typed_data';
// --- データモデル (Driverがアプリケーションに提供するNFC関連情報) ---
class NdefRecord {
final Uint8List payload;
final String? typeNameFormat; // NDEF Type Name Format
NdefRecord({required this.payload, this.typeNameFormat});
String get decodedPayloadAsString { // 簡易デコード
try { return String.fromCharCodes(payload); } catch (_) { return "[Payload Decode Error]"; }
}
@override String toString() => 'NdefRecord(tnf: $typeNameFormat, payload: "$decodedPayloadAsString")';
}
class NdefMessage {
final List<NdefRecord> records;
NdefMessage({required this.records});
@override String toString() => 'NdefMessage(records: $records)';
}
class NfcTagInfo {
final String id; // タグID
final List<String> techList; // サポート技術
final dynamic _internalTagRepresentation; // Driver内部で使用するプラグイン固有タグ表現
NfcTagInfo(this.id, this.techList, this._internalTagRepresentation);
@override String toString() => 'NfcTagInfo(id: $id, techList: $techList)';
}
class NfcTagPollingEvent {
final NfcTagInfo? tag;
final String? errorMessage;
NfcTagPollingEvent({this.tag, this.errorMessage});
bool get hasError => errorMessage != null;
@override String toString() => hasError ? 'NfcTagPollingEvent(error: "$errorMessage")' : 'NfcTagPollingEvent(tag: $tag)';
}
// --- 低レベルなNFC操作APIの例 ---
class _UnderlyingNfcPluginAPI {
static final _UnderlyingNfcPluginAPI _instance = _UnderlyingNfcPluginAPI._();
factory _UnderlyingNfcPluginAPI() => _instance;
_UnderlyingNfcPluginAPI._();
Future<bool> checkAvailability() async => true; // 常に利用可能とする
void startTagDiscovery({ // コールバックで結果を返す非同期操作
required Function(dynamic rawTag) onTagDiscovered,
required Function(String error) onError,
}) { ... }
Future<void> stopTagDiscovery() async { ... }
Future<Uint8List?> readNdef(dynamic rawTag) async { ... }
Future<bool> writeNdef(dynamic rawTag, Uint8List ndefMessage) async { ... }
}
// --- NFCデバイスドライバー インターフェース定義 ---
abstract class NfcDeviceDriver {
Future<bool> isAvailable();
Stream<NfcTagPollingEvent> startPolling({
Duration timeout = const Duration(seconds: 10),
String alertMessage = 'NFCタグを近づけてください',
});
Future<void> stopPolling();
Future<NdefMessage?> readNdefMessage(NfcTagInfo tag);
Future<void> writeNdefMessage(NfcTagInfo tag, NdefMessage message);
}
// --- NFCデバイスドライバーの実装例 ---
class NfcDriverImpl implements NfcDeviceDriver {
final _UnderlyingNfcPluginAPI _pluginAPI;
NfcDriverImpl() : _pluginAPI = _UnderlyingNfcPluginAPI();
@override
Future<bool> isAvailable() {
return _pluginAPI.checkAvailability();
}
@override
Stream<NfcTagPollingEvent> startPolling({
Duration timeout = const Duration(seconds: 10),
String alertMessage = 'NFCタグを近づけてください',
}) {
final controller = StreamController<NfcTagPollingEvent>();
_pluginAPI.startTagDiscovery(
onTagDiscovered: (dynamic rawTag) {
// 低レベルAPIのデータ構造からDriver定義のNfcTagInfoへ変換
final tagInfo = _convertToNfcTagInfo(rawTag);
if (!controller.isClosed) {
controller.add(NfcTagPollingEvent(tag: tagInfo));
if (!controller.isClosed) controller.close();
_pluginAPI.stopTagDiscovery();
}
},
onError: (String error) {
if (!controller.isClosed) {
controller.add(NfcTagPollingEvent(errorMessage: error));
if (!controller.isClosed) controller.close();
}
},
);
// Driver側でポーリング全体のタイムアウトを設定
return controller.stream.timeout(timeout, onTimeout: (sink) {
if (!controller.isClosed) { // controllerの状態を確認
sink.add(NfcTagPollingEvent(errorMessage: 'Polling timed out by Driver.'));
sink.close();
_pluginAPI.stopTagDiscovery(); // タイムアウト時も低レベルAPIのポーリングを停止
}
});
}
@override
Future<void> stopPolling() {
return _pluginAPI.stopTagDiscovery();
}
@override
Future<NdefMessage?> readNdefMessage(NfcTagInfo tag) async {
final rawNdefData = await _pluginAPI.readNdef(tag._internalTagRepresentation);
if (rawNdefData == null) return null;
// 低レベルAPIのデータからDriver定義のNdefMessageへ変換
return _convertToNdefMessage(rawNdefData);
}
@override
Future<void> writeNdefMessage(NfcTagInfo tag, NdefMessage message) async {
final rawNdefPayload = _convertFromNdefMessage(message); // Driverの型から低レベルAPIの型へ
final success = await _pluginAPI.writeNdef(tag._internalTagRepresentation, rawNdefPayload);
if (!success) {
throw Exception('Failed to write NDEF message to tag ${tag.id}');
}
}
// --- 型変換ヘルパー (Driverの重要な責務) ---
NfcTagInfo _convertToNfcTagInfo(dynamic rawTagData) {
// rawTagData (Map<String, dynamic>を想定) から必要な情報を抽出
final id = rawTagData['id']?.toString() ?? 'unknown_id';
final tech = (rawTagData['tech'] as List?)?.map((t) => t.toString()).toList() ?? [];
return NfcTagInfo(id, tech, rawTagData); // プラグイン固有表現も保持
}
NdefMessage _convertToNdefMessage(Uint8List rawNdefData) {
// rawNdefDataをパースしてNdefRecordのリストを作成する
return NdefMessage(records: [NdefRecord(payload: rawNdefData, typeNameFormat: 'U')]);
}
Uint8List _convertFromNdefMessage(NdefMessage message) {
// NdefMessageを低レベルAPIが期待するUint8Listにシリアライズする
if (message.records.isNotEmpty) return message.records.first.payload;
return Uint8List(0);
}
}
注意点・アンチパターン
Driver パターンを適切に適用し、一般的な落とし穴を避けるためには、以下の点に注意してください。
Driver実装時の一般的な考慮事項:
- ビジネスロジックの分離: Driver は低レベルなリソース操作に専念させ、アプリケーション固有のビジネスルールは上位層(例: UseCase, Service)に実装します。
- 適切な抽象化レベルの維持: 操作対象リソースの複雑さを十分に隠蔽し、Driver の利用側がその詳細を意識せずに済むような、適切に抽象化されたインターフェースを提供します。
- ライブラリ依存の隠蔽: Driver の公開インターフェースが、特定の外部ライブラリやSDKの型・概念に過度に依存しないように設計し、将来的なライブラリ変更の影響を最小限に抑えます。
- テスト容易性の確保: 外部リソースとのやり取りはテストを難しくするため、インターフェースを定義し依存性注入(DI)を活用することで、モックを使った単体テストが可能な設計を心がけます。
他の類似パターンとの使い分け・注意点:
- Client との区別: 主目的が外部サービスへの単純なリクエスト送信とレスポンス受信であれば Client が適しています。Driver は、より低レベルなリソースの直接的な「操作・制御」、状態管理、双方向通信といった側面を担います。
- Adaptor との目的の明確化: 単に互換性のないインターフェース同士を「適合させる」ことが目的なら Adaptor を検討します。Driver も適合の側面を持ちますが、主眼は低レベルリソースの操作方法の抽象化と、より能動的なリソース制御にあります。
- Gateway との役割分担: 外部システムへの統一された「窓口」として、より広範な機能セットをアプリケーションに提供するのが目的なら Gateway が適切です。Driver は、その Gateway が内部で利用する、より具体的なリソース操作部品となることがあり、リソース固有の複雑な対話や状態を直接扱います。
3. Gateway
役割・責務
Gateway(ゲートウェイ)は、アプリケーションが外部システムやサービスとやり取りするための「出入り口」や「門」としての役割を担うクラスです。外部システムの複雑な詳細(特定の API 仕様、通信プロトコル、データ形式など)をカプセル化し、アプリケーション内部にはよりシンプルで安定したインターフェースを提供します。
これにより、外部システムへの依存度を低減し、変更容易性やテスト容易性を高めることを目的とします。Gateway は、アプリケーションの境界を明確にし、内部ロジックと外部世界との間の情報の流れを制御・仲介します。
なぜその命名なのか
「門」「出入り口」という言葉の通り、アプリケーションの境界に位置し、内部ロジックと外部世界(外部サービス、データベース、他のシステムなど)との間の情報の流れを制御・仲介する役割を明確に示します。外部システムの具体的な詳細をアプリケーションの他の部分から隠蔽する「ゲートキーパー」のようなイメージです。
期待される実装
Gateway クラスには、主に以下の役割が期待されます。
- 外部API呼び出しの抽象化
- 決済サービスAPI(PaymentGateway)や通知サービスAPI(NotificationGateway)など、特定の外部サービスとのやり取りを抽象化します。
- 低レベルなデータアクセス抽象化
- 特定のデータベースライブラリの操作を隠蔽する DatabaseGateway のような役割も担うことがあります(ただし、ドメインオブジェクトの永続化に特化する場合は Repository となることが多いです)。
- メッセージングシステムとの連携
- メッセージキューへのメッセージ送受信を抽象化する MessageQueueGateway のような役割。
- 技術的詳細のカプセル化
- 認証処理、APIキー管理、エラーハンドリング(ステータスコード変換、共通エラーオブジェクトへのマッピングなど)、リトライロジック、タイムアウト設定など、外部システムとの安定した通信に必要な技術的詳細を実装します。
- 依存コンポーネントの利用
- 内部的にClientを利用して実際の通信を行い、必要に応じて Converter を用いてデータ形式の変換を行います。
具体的なコード例(Flutter / Dart)
以下に、具体的な Gateway の例として PaymentGateway を示します。これは、外部の決済サービス(例: Stripe)との連携を抽象化するゲートウェイです。
// --- 依存コンポーネント定義例 ---
abstract class ApiClient {
Future<Map<String, dynamic>> get(String path, {Map<String, String>? headers});
Future<Map<String, dynamic>> post(String path, Map<String, dynamic> data, {Map<String, String>? headers});
}
// --- PaymentGateway(支払いゲートウェイ) ---
// 支払い処理に関するステータス
enum PaymentStatus { pending, success, failed, unknown }
// 支払いゲートウェイのインターフェース
abstract class PaymentGateway {
Future<bool> processPayment(String userId, double amount, String currency);
Future<PaymentStatus> getPaymentStatus(String transactionId);
}
// Stripe決済サービスを利用するGatewayの実装例
class StripePaymentGateway implements PaymentGateway {
final ApiClient _apiClient; // HTTP通信を行うApiClient
final String _stripeApiKey;
StripePaymentGateway(this._apiClient, this._stripeApiKey);
@override
Future<bool> processPayment(String userId, double amount, String currency) async {
print('StripePaymentGateway: Processing payment for user $userId, amount $amount $currency');
try {
// Stripe APIへのリクエストをApiClient経由で送信
final response = await _apiClient.post(
'https://api.stripe.com/v1/charges',
{
'amount': (amount * 100).toInt(),
'currency': currency,
'customer': userId,
'description': 'Application Payment via Gateway',
},
headers: {'Authorization': 'Bearer $_stripeApiKey'},
);
// レスポンスを解析して成功/失敗を判断
return response['status'] == 'succeeded';
} catch (e) {
print('StripePaymentGateway: Payment processing failed - $e');
return false;
}
}
@override
Future<PaymentStatus> getPaymentStatus(String transactionId) async {
print('StripePaymentGateway: Getting status for transaction $transactionId');
try {
final response = await _apiClient.get(
'https://api.stripe.com/v1/charges/$transactionId',
headers: {'Authorization': 'Bearer $_stripeApiKey'},
);
switch (response['status']) {
case 'succeeded':
return PaymentStatus.success;
case 'pending':
return PaymentStatus.pending;
case 'failed':
return PaymentStatus.failed;
default:
return PaymentStatus.unknown;
}
} catch (e) {
print('StripePaymentGateway: Failed to get payment status - $e');
return PaymentStatus.unknown;
}
}
}
注意点・アンチパターン
Gateway パターンを効果的に使用し、その責務を明確に保つためには、以下の点に注意してください。
Gateway 実装時の一般的な考慮事項:
- 責務過剰な「God Gateway」の回避: あらゆる種類の外部システム連携(支払い、通知、ストレージ、位置情報など)を単一の巨大な Gateway クラスに集約するのではなく、目的や連携先のシステムの種類に応じて、PaymentGateway、NotificationGateway のように適切に分割します。
- ビジネスロジックの分離: Gateway の主な責務は、外部システムとの技術的な連携の抽象化です。アプリケーションのコアなビジネスルールやドメインロジックは、UseCase や Service に委ねます。
- テスト容易性の確保: 外部システムに依存するため、インターフェースを定義し依存性注入(DI)を適切に行うことで、テスト時にはモック実装に差し替えられるように設計することが重要です。
他の類似パターンとの使い分け・注意点:
- Repository との役割分担を明確に: Repository はドメインオブジェクトの永続化と取得(コレクションのような振る舞い)に特化します。Gateway はより広範な外部サービスへのアクセス(例: 支払い実行、通知送信)を抽象化し、必ずしもドメインオブジェクトのCRUDに限定されません。
- Client との適切な使い分け: Client は特定の通信プロトコル(例: HTTP)を用いた具体的なリクエスト送信・レスポンス受信を担当します。Gateway は、その Client を利用しつつ、アプリケーションにとってより意味のある操作レベル(例: processPayment)でインターフェースを提供し、Client や通信プロトコルの詳細を隠蔽します。Gateway が Client のメソッドを単に1対1でラップするだけなら、Gateway の導入は冗長かもしれません。
- Adaptor との目的の違いを理解する: Adaptor の主目的は、互換性のない既存のインターフェース同士を「適合させる」ことです。Gateway も結果としてインターフェースの変換を行うことがありますが、その主眼は外部システムへのアクセス手段の「抽象化」と「カプセル化」にあります。
- Driver との連携と役割の違い: Gateway が外部システムへの窓口を提供するのに対し、Driver はその Gateway が内部で利用する、より低レベルなリソース(ハードウェア、DB接続プロトコル等)の直接操作部品となることがあります。Driver はリソース固有の複雑な対話や状態管理により深く関与します。
4. Converter / Mapper
役割・責務
Converter / Mapper は、アプリケーション内の異なるレイヤー間(例: ネットワーク層の DTO とドメイン層のモデル、あるいはドメイン層のモデルとプレゼンテーション層のUIモデル)で、データ形式の変換とマッピングを専門に行う責務を持つクラスです。これらのクラスは、レイヤー間の依存関係を明確にし、複雑なデータ構造の不一致を吸収することで、アプリケーション全体のデータフローの整合性と保守性を大幅に向上させます。
一般的に、Converter と Mapper という用語は、この文脈ではほぼ同義で使われ、データの一方の表現からもう一方の表現へと変換・対応付ける役割を指します。
なぜその命名なのか
「変換器(Converter)」や「写像器(Mapper)」という言葉の通り、データの形式変換やデータ項目間の対応付け(マッピング)に特化した役割を明確に示します。そのクラスが「何」を「何に」変換または対応付けるのかが、命名(例: UserDtoToUserMapper、ApiUserConverter)から直感的に理解できます。異なる形式間の「橋渡し」役としての役割を明確に表現しています。
期待される実装
Converter / Mapper クラスには、主に以下の役割が期待されます。
- モデル間の構造変換
- DTO (Data Transfer Object) からドメインモデルへの変換、APIレスポンスの JSON(Map<String, dynamic>)からドメインオブジェクトへの変換、あるいはドメインモデルからUI表示用モデル(ViewModel など)への変換など、異なるレイヤーやコンテキスト間でデータモデルの構造を変換します。
- データフィールドの詳細なマッピング
- 異なるモデル間でのフィールド名の差異(例: user_id と userId、full_name と name)を吸収し、データ型を調整します(例: 文字列で表現された日付を DateTime オブジェクトに、整数で表現された Unix タイムスタンプを DateTime に、数値を特定の enum 値に変換)。
- データ構造の整形と再構築
- ネストしたデータ構造をフラット化したり、逆にフラットなデータからネストしたオブジェクトを構築したりするなど、各レイヤーで扱いやすいようにデータ構造を整形・再構築します。
- 複数データ表現への対応とロジックの一元管理
- 単一のドメインモデル(例: User)に対して、複数の異なるデータソースのモデル(例: LocalUserData、ApiUserData)からの変換や、それらのデータソースモデルへの逆変換ロジックを一元的に管理します。これにより、複雑なデータマッピングロジックを集約し、再利用性を高めます。
具体的なコード例(Flutter / Dart)
以下は、複数の異なるデータソースから取得したデータを、単一のドメインモデルに統合して変換する Converter(Mapper)の実装例です。
// --- ドメインモデルの定義例 ---
class User {
final String id;
final String name;
final String email;
final DateTime createdAt;
User({required this.id, required this.name, required this.email, required this.createdAt});
@override
String toString() {
return 'User(id: $id, name: $name, email: $email, createdAt: $createdAt)';
}
}
// --- 外部システム/データ層のモデル定義例 ---
// 1. ローカルDBのユーザー情報モデル
class LocalUserData {
final String dbId;
final String fullName;
final String contactEmail;
final int unixTimestamp; // Unixエポックからの秒数
LocalUserData({required this.dbId, required this.fullName, required this.contactEmail, required required this.unixTimestamp});
@override
String toString() {
return 'LocalUserData(dbId: $dbId, fullName: $fullName, contactEmail: $contactEmail, unixTimestamp: $unixTimestamp)';
}
}
// 2. 外部APIのユーザー情報モデル
class ApiUserData {
final String id;
final String name;
final String email;
final String creationDate; // ISO 8601形式の文字列日付
ApiUserData({required this.id, required this.name, required this.email, required this.creationDate});
@override
String toString() {
return 'ApiUserData(id: $id, name: $name, email: $email, creationDate: $creationDate)';
}
}
// --- Converterクラスの実装例 ---
class MultiSourceUserConverter {
// LocalUserData を User ドメインモデルに変換
User fromLocalData(LocalUserData localData) {
print('Converting LocalUserData to User: $localData');
return User(
id: localData.dbId,
name: localData.fullName,
email: localData.contactEmail,
createdAt: DateTime.fromMillisecondsSinceEpoch(localData.unixTimestamp * 1000), // 秒 -> ミリ秒
);
}
// ApiUserData を User ドメインモデルに変換
User fromApiData(ApiUserData apiData) {
print('Converting ApiUserData to User: $apiData');
return User(
id: apiData.id,
name: apiData.name,
email: apiData.email,
createdAt: DateTime.parse(apiData.creationDate), // 文字列日付をDateTimeにパース
);
}
// User ドメインモデルを LocalUserData に変換
LocalUserData toLocalData(User user) {
print('Converting User to LocalUserData: $user');
return LocalUserData(
dbId: user.id,
fullName: user.name,
contactEmail: user.email,
unixTimestamp: user.createdAt.millisecondsSinceEpoch ~/ 1000, // ミリ秒 -> 秒 (整数除算)
);
}
// User ドメインモデルを ApiUserData に変換
ApiUserData toApiData(User user) {
print('Converting User to ApiUserData: $user');
return ApiUserData(
id: user.id,
name: user.name,
email: user.email,
creationDate: user.createdAt.toIso8601String(), // DateTimeをISO 8601文字列に変換
);
}
}
注意点・アンチパターン
Converter / Mapperを効果的に利用し、その責務を明確に保つためには、以下の点に注意してください。
Converter / Mapper 実装時の一般的な考慮事項:
- 過剰な汎用化を避ける: Converter / Mapperは、特定の2つのデータ形式間、または密接に関連する少数の形式間の変換に特化すべきです。「何でも変換できる」ような汎用クラス(例: GenericDataConverter)は責務が曖昧になり、「God Object」化するリスクがあります。
- ビジネスロジックの分離: 純粋なデータ形式の変換・マッピングに徹し、ビジネスルールやドメインロジック(例: 変換中の条件付きバリデーションや計算処理)は含めません。これらは UseCase や Service の役割です。
- 不必要な導入の回避 (モデル自身の変換機能との比較): 変換ロジックが非常に単純で、モデルクラス自身のファクトリコンストラクタ(例: User.fromJson(map)) やメソッド(例: user.toDto())で十分に表現できる場合は、独立した Converter / Mapper クラスは不要かもしれません。過剰なクラス化は複雑性を増す可能性があります。独立させるメリットは、変換ロジックの集中管理や、モデルクラスを純粋なデータ保持に留めたい場合などです。
他の類似パターンとの使い分け・注意点:
- Adaptor との目的の違いを明確に: Adaptor の主な目的は、互換性のないインターフェース(メソッドシグネチャなど)同士を適合させることです。一方、Converter / Mapper は、異なるデータ構造やデータ形式(オブジェクトのフィールドや型)を相互に変換・対応付けることに焦点を当てます。
- Factory や Builder との役割の違い: Factory や Builder は、オブジェクトの生成プロセスや段階的な構築に特化しています。Converter / Mapper も結果として新しいオブジェクトを生成しますが、その主な責務は既存のデータ構造から新しいデータ構造への変換・写像です。変換元となるオブジェクトが明確に存在する場合に Converter / Mapper が適しています。
5. Adaptor
役割・責務
Adapter は、既存のインターフェース(またはクラス)を、利用側が期待する別のインターフェースに適合させる責務を持つクラスです。これは、直接互換性のないコード資産や外部ライブラリを既存のシステムに組み込む際に特に有用で、異なるシステム間の連携や、レガシーコードとの統合において「翻訳者」や「橋渡し役」として機能します。
なぜその命名なのか
「適合器」「仲介役」という言葉の通り、異なる要素を「適合させる」「つなぎ合わせる」役割を明確に示します。GoF(Gang of Four)のデザインパターンの一つである「Adapterパターン」に由来しており、その意図が広く共有されています。この命名は、システム内の異なるコンポーネントがスムーズに連携できるように、インターフェースの不一致を解決するという本質的な目的を表現しています。
期待される実装
Adapter クラスには、主に以下の役割が期待されます。
- クライアントが期待するインターフェースの実装(Target インターフェース)
- 利用側が呼び出すことを想定しているインターフェースを実装します。これにより、利用側はアダプターの存在を意識せず、期待するインターフェースを通じて機能を利用できます。
- 既存の互換性のないクラス(Adaptee)の呼び出し
- 実装した Target インターフェースのメソッド内で、実際に処理を行う既存の互換性のないクラス(Adaptee)のメソッドを呼び出します。
- インターフェース間のマッピングロジックの提供
- Target インターフェースの引数と Adaptee のメソッドの引数、および戻り値の型や意味合いが異なる場合に、その間の変換(マッピング)ロジックを提供します。これには、データ形式の変換、単位の変換、例外処理の調整などが含まれます。
- 既存ライブラリやフレームワークの API 適応
- 外部のライブラリやフレームワークが提供する API を、アプリケーションの内部設計や命名規則に合うように適応させる役割も担います。これにより、アプリケーションの内部コードと外部ライブラリとの結合度を低く保てます。
具体的なコード例(Flutter / Dart)
以下は、旧バージョンの決済SDKをアプリケーションが期待する新しい支払いインターフェースに適合させる Adapter の実装例です。これにより、アプリケーションの他の部分が古いSDKに直接依存することなく、統一された PaymentService インターフェースを通じて支払い処理を行えます。
// アプリケーション内部が期待する新しい支払いサービスインターフェース(ターゲット)
abstract class PaymentService {
Future<bool> processPayment(double amount);
}
// 旧バージョンの決済SDKのAPI(アダプティ)
class OldPaymentSdk {
int initiatePayment(int rawAmount) {
// 旧SDKの決済処理を模倣
print('OldPaymentSdk: 決済処理を開始します...');
if (rawAmount > 0) {
print('OldPaymentSdk: 決済成功コードを返します。');
return 0; // 成功コード
} else {
print('OldPaymentSdk: 決済失敗コードを返します。');
return -1; // 失敗コード
}
}
void initializeSdk(String apiKey, String secretKey) {
// SDKの初期化ロジック
print('OldPaymentSdk: SDKを初期化します...');
}
}
// OldPaymentSdkをPaymentServiceインターフェースに適合させるアダプター
class OldPaymentSdkAdapter implements PaymentService {
final OldPaymentSdk _oldSdk; // 旧SDKのインスタンスへの依存
static const double _currencyConversionRate = 100.0;
OldPaymentSdkAdapter(this._oldSdk);
void initialize(String apiKey, String secretKey) {
_oldSdk.initializeSdk(apiKey, secretKey);
}
@override
Future<bool> processPayment(double amount) async {
// 新しいインターフェースのdouble型金額を、旧SDKが期待するint型に変換するロジック
final int rawAmount = (amount * _currencyConversionRate).toInt();
// 旧SDKのメソッドを呼び出し、その結果を新しいインターフェースの戻り値に変換
final int sdkResult = _oldSdk.initiatePayment(rawAmount);
// 成功コード(0)ならtrue、失敗コード(-1)ならfalseを返す
if (sdkResult == 0) {
print('OldPaymentSdkAdapter: 決済成功。');
return true;
} else {
print('OldPaymentSdkAdapter: 決済失敗。');
return false;
}
}
}
注意点・アンチパターン
Adaptor パターンを効果的に利用し、その責務を明確に保つためには、以下の点に注意してください。
Adaptor 実装時の一般的な考慮事項:
- 責務をインターフェース変換に限定する: Adaptor はあくまでインターフェースの「翻訳」に徹するべきです。複雑なビジネスロジックや、適合対象のクラスが本来持たない新しい機能を追加し始めると、Adaptor が肥大化し、保守性が低下します。そのようなロジックは別の Service や UseCase に委譲することを検討しましょう。
- 不要な Adaptor の導入を避ける: 既存のインターフェースと利用側が期待するインターフェースが非常に近い場合や、Adaptee(適合対象クラス)を直接修正できる場合は、無理に Adaptor パターンを適用する必要はありません。シンプルに直接利用するか、Adaptee を修正する方がコードが読みやすくなることもあります。
- 多層的なアダプテーションの複雑性: 複数の Adaptor を連鎖させるような設計(例: AをBに適合させ、BをCに適合させる Adaptor)は、コードの複雑性を増し、デバッグを困難にする可能性があります。可能であれば、単一の Adaptor で必要な変換を完結させることを目指しましょう。
他の類似パターンとの使い分け・注意点:
- Converter / Mapper との目的の違いを明確に: Converter / Mapper の主目的は、異なるデータ構造やデータ形式(例: DTOからドメインモデルへ)を相互に変換することです。一方、Adaptor は、互換性のないインターフェース(メソッドシグネチャ、呼び出し規約)同士を適合させることに焦点を当てます。Adaptor がデータ変換を伴う場合、内部で Converter / Mapper を利用することもあります。
- Gateway とのスコープの違い: Gateway は、外部システムや複雑なサブシステムへの統一された「窓口」を提供し、その詳細を隠蔽します。Adaptor は、特定の既存インターフェースを利用側が期待する別のインターフェースに「適合させる」という、より具体的な目的を持ちます。Gateway が、外部システムのAPIをアプリケーション内部のインターフェースに適合させるために Adaptor の役割を内包することはあり得ます。
- Driver との役割の違い: Driver は、ハードウェアや低レベルプロトコルなど、特定のリソースを直接「操作・制御」し、その複雑な詳細を抽象化します。Adaptor もインターフェースの抽象化を行いますが、Driver のような能動的なリソース制御や状態管理の側面は通常持ちません。Driver がSDKやライブラリをラップする際、結果としてインターフェースを適合させることもありますが、主眼はリソース操作です。
6. Repository
役割・責務
Repository は、ドメインオブジェクトの永続化と取得を抽象化する責務を持つクラスです。データソース(データベース、外部API、ローカルストレージなど)の詳細を隠蔽し、ドメイン層に一貫したデータアクセスを提供します。これにより、ビジネスロジックはデータの保存場所や取得方法に依存せず、よりクリーンでテストしやすいコードを実現できます。
なぜその命名なのか
「貯蔵庫」「保管場所」という言葉が示す通り、Repository はデータを集約し、アクセスするための窓口となる役割を明確に表しています。これは、ドメイン層がデータの具体的な永続化メカニズムから切り離され、ビジネス要件に集中できるという設計思想に基づいています。
期待される実装
Repository クラスには、主に以下の役割が期待されます。
- CRUD 操作に特化したメソッド群
- Create (生成): 新しいドメインオブジェクトを保存する。
- Read (読み取り): 特定の条件でドメインオブジェクトを取得する。
- Update (更新): 既存のドメインオブジェクトを更新する。
- Delete (削除): ドメインオブジェクトを削除する。
これらの操作は、データの永続化に関する関心事を Repository 内に閉じ込めることで、ドメイン層の関心事を純粋なビジネスロジックに限定します。
- 複数のデータソースからの集約
- 必要に応じて、異なるデータソース(例: ネットワークとローカルキャッシュ)からのデータ取得ロジックをRepository内で統合し、最適なデータを提供します。
- ドメインオブジェクトへのマッピング
- データソースから取得した生のデータ(JSON やデータベースの行など)を、アプリケーションのドメインオブジェクト(例: User クラス)に変換する責務も担います。これにより、ドメイン層はデータの構造ではなく、ビジネス上の意味合いでオブジェクトを扱えます。
具体的なコード例(Flutter / Dart)
// --- ドメインモデルの定義例 ---
class User {
final String id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
// JSONからUserオブジェクトを生成するファクトリコンストラクタ
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as String,
name: json['name'] as String,
email: json['email'] as String,
);
}
// UserオブジェクトからJSONを生成するメソッド
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
};
}
}
// --- 抽象リポジトリの定義例 ---
abstract class UserRepository {
Future<User> getUser(String userId);
Future<List<User>> getAllUsers();
Future<void> saveUser(User user);
Future<void> deleteUser(String userId);
}
// --- 外部システムクライアントの抽象化 ---
abstract class ApiClient {
Future<Map<String, dynamic>> get(String path);
Future<Map<String, dynamic>> post(String path, Map<String, dynamic> data);
Future<void> delete(String path);
}
// --- リポジトリの実装例 ---
class RemoteUserRepository implements UserRepository {
final ApiClient _apiClient; // APIクライアントのインターフェースへの依存
RemoteUserRepository(this._apiClient);
@override
Future<User> getUser(String userId) async {
final response = await _apiClient.get('/users/$userId');
return User.fromJson(response);
}
@override
Future<List<User>> getAllUsers() async {
final response = await _apiClient.get('/users');
return (response['data'] as List).map((json) => User.fromJson(json)).toList();
}
@override
Future<void> saveUser(User user) async {
await _apiClient.post('/users', user.toJson());
}
@override
Future<void> deleteUser(String userId) async {
await _apiClient.delete('/users/$userId');
}
}
注意点・アンチパターン
Repository パターンを効果的に適用し、その利点を最大限に活かすためには、以下の点に注意してください。
Repository 実装時の一般的な考慮事項:
- 単一責務の厳守(ビジネスロジックの排除): Repository は、あくまでドメインオブジェクトの永続化と取得に関する技術的な詳細を抽象化することに徹します。アプリケーション固有のビジネスルール(例: ユーザー登録時のパスワードハッシュ化、特定条件下でのみデータを更新するロジック、複数のエンティティをまたがる複雑な検証)は含めず、これらは Service や UseCase といった上位レイヤーの責務とします。
- データソース詳細の完全な隠蔽: Repository のインターフェースとその利用側は、データが具体的にどこ(例: 特定のSQLデータベース、NoSQLストア、外部APIのエンドポイント)に、どのように(例: SQL、HTTPリクエスト)格納・取得されるのかを知るべきではありません。これにより、将来的にデータソースを変更する際の影響を Repository の実装内部に限定できます。
- 過度な汎用化の回避と適切な粒度: 汎用的な BaseRepository<T> のような抽象クラスを作ることは可能ですが、多くの場合、特定のドメインエンティティ(例: UserRepository, ProductRepository, OrderRepository)ごとに具体的な Repository インターフェースと実装クラスを作成する方が、責務が明確になり、インターフェースもエンティティ固有の操作(例: findUsersByEmailDomain)を定義しやすくなります。
他の類似パターンとの使い分け・注意点:
- DAO(Data Access Object)との比較と使い分け: DAO は通常、特定のデータソース(例: 単一のDBテーブル)への低レベルなCRUD操作を直接カプセル化します。Repository はよりドメイン駆動設計寄りの概念で、ドメインオブジェクト(特に集約ルート)のコレクションのように振る舞い、永続化の手段を抽象化します。Repository の実装が内部的に一つまたは複数の DAO を利用することはあります。
- Gateway との役割の違いを認識する: Repository がドメインオブジェクトのライフサイクル管理と永続化に焦点を当てるのに対し、Gateway はより広範な外部システム(必ずしもデータ永続化が主目的でないサービスも含む)へのアクセスポイントを抽象化します。例えば、PaymentGateway は支払い処理のインターフェースを提供しますが、OrderRepository は注文ドメインオブジェクトの永続化を担当します。
- Service との責務境界を明確に: Repository はデータの永続化と取得という「技術的」な関心事を扱います。一方、Service はビジネスロジックや複数の Repository を協調させるような「業務的」な操作を担います。Repository 内に複雑な業務ルールを実装するのは避けましょう。
- Client との連携と抽象化レベル: リモートAPIをデータソースとする Repository は、内部的に Client(例: HttpClient)を利用して通信を行います。Repository は、その Client の存在や通信プロトコルの詳細を利用側から隠蔽し、ドメインオブジェクトの形でデータを提供・受け入れます。データ形式の変換(例: JSON からドメインオブジェクトへ)も Repository(またはそれが利用する Converter / Mapper)の責務です。
7. Provider
役割・責務
Provider は、外部リソースや依存関係を提供する責務を持つクラスです。データソースやサービスインスタンスなどを提供します。特に Flutter の文脈においては、UI コンポーネントツリーにデータや状態を「供給」し、その変更を通知することで UI の更新を促す役割を担います。
なぜその命名なのか:
「供給者」「提供者」という言葉の通り、必要なものを提供する役割を明確に示します。Flutter の provider パッケージでは、ウィジェットツリーを通じてデータが「提供される」様子を端的に表しています。
期待される実装
Provider クラスには、主に以下の2つの役割が期待されます。
- DIコンテナ/ファクトリとしての役割
- 特定の依存オブジェクトのインスタンスを生成し提供する。シングルトンやスコープに応じたインスタンス管理を含む場合があります。
- 状態管理ライブラリにおけるデータ提供者
- UI ツリーの上位でデータやオブジェクトを保持し、下位のウィジェットがそれにアクセスできるようにします。データが変更された際にリスナーに通知し、UI の再構築をトリガーするメカニズムを持ちます。
具体的なコード例(Flutter / Dart)
以下に、Provider の主な役割ごとの具体的なコード例を示します。
1. DIコンテナ/ファクトリとしてのProviderの例
この例では、AppRepositoryProvider が、実行環境(デバッグモードか否か)に応じて異なる UserRepository の実装を提供します。
// --- ドメインモデルの定義例 ---
class User {
final String id;
final String name;
User({required this.id, required this.name});
@override
String toString() => 'User(id: $id, name: $name)';
}
// --- 抽象リポジトリの定義例 ---
abstract class UserRepository {
Future<User> fetchUser(String id);
Future<void> saveUser(User user);
}
// --- リポジトリの具体的な実装例 (モック) ---
class MockUserRepository implements UserRepository { ... }
// --- リポジトリの具体的な実装例 (リモート) ---
class RemoteUserRepository implements UserRepository { ... }
// --- Providerクラスの実装例 ---
// 実行環境に応じて適切なUserRepositoryの実装を提供するProvider
import 'package:flutter/foundation.dart'; // kDebugMode のために必要
class AppRepositoryProvider {
static UserRepository getUserRepository() {
if (kDebugMode) {
print('AppRepositoryProvider: Providing MockUserRepository');
return MockUserRepository();
} else {
print('AppRepositoryProvider: Providing RemoteUserRepository');
return RemoteUserRepository();
}
}
}
2. 状態管理ライブラリにおけるデータ提供者の例 (Flutter provider パッケージ)
この例では、provider パッケージの ChangeNotifier と ChangeNotifierProvider を使用して、カウンターの状態をUIに提供し、更新します。
// --- 状態クラスの定義 (ChangeNotifierをmixin) ---
class Counter with ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners(); // 状態の変更をリスナーに通知
}
void decrement() {
_count--;
notifyListeners();
}
}
// --- Providerを使用したアプリケーションのルートウィジェット例 ---
class MyAppWithProvider extends StatelessWidget {
const MyAppWithProvider({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider( // Counterインスタンスをウィジェットツリーに提供
create: (context) => Counter(), // Counterのインスタンスを生成
child: MaterialApp(
title: 'Provider Demo',
home: CounterScreen(),
),
);
}
}
// --- 状態を利用するUIウィジェット例 ---
class CounterScreen extends StatelessWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context) {
// Provider.of<Counter>(context) を使って上位のProviderからCounterインスタンスを取得
final counter = Provider.of<Counter>(context);
return Scaffold( ... );
}
}
注意点・アンチパターン
Provider は汎用的な命名のため、その責務が不明瞭になりがちです。
- 単一責務の原則の意識
- Provider に過度なロジックを持たせると、単一責務の原則(SRP)に反する可能性があります。Providerはあくまでインスタンスの提供やデータの供給に徹し、ビジネスロジックは別のクラス(例: Service や UseCase)に委譲することを検討しましょう。
- 具体的な命名の検討
- 提供するものが特定のデータソースや外部サービスである場合は、DatabaseProvider やApiServiceProvider のように、より具体的な命名を検討することで、クラスの役割を明確にできます。
8. Resolver
役割・責務
Resolver は、何らかの「入力」(型、名前、識別子、パス、クエリ、条件など)に基づいて、対応する「出力」(インスタンス、値、リソース、データ、設定など)を決定し、提供する(つまり「解決する」)責務を持つクラスです。
その具体的な役割は、DIコンテナにおける依存性の解決から、ルーティングにおけるパスの解決、設定値の動的な決定、GraphQL におけるデータ取得ロジックまで、非常に多岐にわたります。共通するのは、不明確または抽象的な要求から、具体的で利用可能な結果を導き出すという点です。
なぜその命名なのか
「解決者」という言葉の通り、与えられた問題、要求、あるいは抽象的な指示に対して、適切な答え、具体的なインスタンス、あるいは必要な情報を見つけ出し、提供する役割を明確に示します。不明確な状態や間接的な参照から、直接利用可能な具体的なものへと「解決する」というニュアンスを持ちます。
期待される実装
Resolver の具体的な実装は多岐にわたりますが、以下のような例が挙げられます。
- Dependency Resolver
- 要求されたインターフェースやクラスに対応する具象インスタンスを生成または取得して提供します。インスタンスのライフサイクル管理(シングルトン、トランジェントなど)も担います。 (例: get_it パッケージ、Riverpod の Provider)
- Route Resolver
- 指定されたURLパスやルート名に対応する画面コンポーネントや、その画面に必要な初期データを解決(取得)するロジックを担当します。 (例: GoRouter の機能の一部)
- Configuration Resolver
- アプリケーションの実行環境(開発、本番など)やその他の条件に応じて、適切な設定値(API エンドポイント、API キーなど)を動的に決定し提供します。
- Data Resolver
- GraphQL サーバーにおいて、スキーマで定義された特定のフィールドのデータを実際にどのように取得するかというロジックをカプセル化します。
- Conflict Resolver
- 複数のデータソース間でデータの競合が発生した場合(例: オフライン同期時)、解決戦略(どちらを正とするか、マージするか)を実装します。
- Path Resolver
- ファイルシステム上の抽象的なパスから実際の物理パスを解決したり、アプリケーション内のリソースへのパスを解決したりします。
具体的なコード例(Flutter / Dart)
以下に、Resolverの具体的な例としてDependencyResolverとConfigurationResolverを示します。
1. 簡易的な DependencyResolver (DIコンテナのコア部分)
// --- 解決されるサービスのインターフェースと実装例 ---
abstract class AnalyticsService {
void trackEvent(String eventName);
}
class FirebaseAnalyticsService implements AnalyticsService { ... }
class MockAnalyticsService implements AnalyticsService { ... }
// --- DependencyResolverクラスの実装例 ---
class DependencyResolver {
final Map<Type, Object Function()> _factories = {};
final Map<Type, Object> _singletons = {};
final bool _useMocks;
DependencyResolver({bool useMocks = false}) : _useMocks = useMocks {
_registerDependencies();
}
void _registerDependencies() {
// AnalyticsServiceの解決ロジックを登録
if (_useMocks) {
registerSingleton<AnalyticsService>(MockAnalyticsService());
} else {
registerFactory<AnalyticsService>(() => FirebaseAnalyticsService());
}
// 他の依存関係も同様に登録...
}
void registerFactory<T extends Object>(T Function() factory) {
_factories[T] = factory;
}
void registerSingleton<T extends Object>(T instance) {
_singletons[T] = instance;
}
T resolve<T extends Object>() {
// まずシングルトンキャッシュを確認
if (_singletons.containsKey(T)) {
return _singletons[T] as T;
}
// 次にファクトリを確認
if (_factories.containsKey(T)) {
final instance = _factories[T]!() as T;
return instance;
}
// どちらにも登録されていなければエラー
throw Exception('Type $T not registered.');
}
}
2. ConfigurationResolver (環境に応じた設定値の解決)
// --- 環境定義と設定インターフェース ---
enum AppEnvironment { development, staging, production }
abstract class ApiConfiguration {
String get baseUrl;
String get apiKey;
Duration get timeout;
}
// --- 各環境向けの設定実装例 ---
class DevelopmentApiConfiguration implements ApiConfiguration { ... }
class StagingApiConfiguration implements ApiConfiguration { ... }
class ProductionApiConfiguration implements ApiConfiguration { ... }
// --- ConfigurationResolverクラスの実装例 ---
class ConfigurationResolver {
final AppEnvironment _currentEnvironment;
ConfigurationResolver(this._currentEnvironment);
/// 現在の環境に基づいてAPI設定を解決します。
ApiConfiguration resolveApiConfiguration() {
switch (_currentEnvironment) {
case AppEnvironment.development:
return DevelopmentApiConfiguration();
case AppEnvironment.production:
return ProductionApiConfiguration();
case AppEnvironment.staging:
return StagingApiConfiguration();
}
}
/// 指定された機能フラグの現在の状態を解決します。
bool resolveFeatureFlag(String flagName) {
print('ConfigurationResolver: Resolving feature flag "$flagName"...');
// 機能フラグを解決するロジック
if (_currentEnvironment == AppEnvironment.development &&
flagName == 'newAwesomeFeature') {
print(
'ConfigurationResolver: Feature "$flagName" is ENABLED for development.',
);
return true;
}
if (flagName == 'betaFeature' &&
(_currentEnvironment == AppEnvironment.staging ||
_currentEnvironment == AppEnvironment.development)) {
print(
'ConfigurationResolver: Feature "$flagName" is ENABLED for staging/development.',
);
return true;
}
print('ConfigurationResolver: Feature "$flagName" is DISABLED.');
return false; // デフォルトは無効
}
}
注意点・アンチパターン
Provider という名前は汎用的であるため、その責務範囲を明確に意識することが重要です。
Provider 実装時の一般的な考慮事項:
- 単一責務の原則を意識する: Provider はあくまでインスタンスの「提供」や状態の「供給」に徹するべきです。複雑なビジネスロジックや、提供対象のオブジェクトが担うべき処理(例: UserRepositoryProvider がデータアクセス処理を行う)まで Provider 自身が抱え込むと、責務が肥大化し、単一責任の原則に反します。ロジックは提供対象のクラスや Service、UseCase に委譲しましょう。
- 具体的な命名を検討する: 提供するものが明確な場合(例: 特定の UserRepository、ThemeData など)は、UserRepositoryProvider や ThemeProvider のように、より具体的な名前を検討することで、Provider の役割と提供対象が明確になります。DataProvider や SettingsProvider のような名前も、提供するデータの種類が明確であれば有効です。
他の類似パターンとの使い分け・注意点:
- Resolver との違いを理解する: Provider が比較的単純にインスタンスや値を「供給」するのに対し、Resolver は、より動的に、条件や入力に基づいて「何を供給すべきかを解決(決定)する」という能動的なロジックを含む場合に適しています。Provider が内部で複雑な解決ロジックを持ち始めたら、それは Resolver の役割かもしれません。
- Factory との役割分担: Factory の主な責務はオブジェクトの「生成プロセス」そのものです。Provider もインスタンスを生成して提供することがありますが(ファクトリ的役割)、Provider は既存インスタンスの提供(シングルトンなど)や、インスタンスのライフサイクル管理(DIコンテナとしての役割時)も担うことがあります。純粋なオブジェクト生成ロジックが複雑な場合は専用の Factory クラスに分離し、Provider はその Factory を利用してインスタンスを提供することを検討します。
9. Handler
役割・責務
Handler は、特定の種類のイベント、リクエスト、メッセージ、またはシグナルを処理するために特別に設計されたクラスです。特定の「出来事」(イベントやメッセージ)が発生または受信されたときに実行されるべきロジックをカプセル化します。
ハンドラーは多くの場合、何かが起こるのを待ち、それに応じて適切なアクションを実行するリアクティブなコンポーネントです。その主な責務は、受け取った情報を解釈し、適切な処理フローを開始することにあります。
なぜその命名なのか
「Handler(取り扱う人、処理する人)」という名前は、その目的を直接的かつ明確に表しています。つまり、特定の何かを「取り扱う」または「処理する」ことです。これは、入ってくる刺激に反応するという受動的な役割を意味し、「Xが発生したときに誰がそれを処理するのか?」という問いに明確に答えます。
期待される実装
Handler クラスには、主に以下の役割が期待されます。
- 特定イベント/メッセージへの応答
- 通常、対応するイベントやメッセージが発生したときに呼び出される一つ以上の公開メソッド(例: handle(eventData)、onEvent(payload)、processRequest(request)) を持ちます。メソッドのシグネチャは、それが処理するイベントやメッセージのタイプに特化していることが多いです。
- ディスパッチシステムの一部
- イベントやメッセージを登録されたハンドラーにディスパッチする大規模なシステム(例: イベントバス、メッセージキューリスナー、UIイベントループ)の一部となることがあります。
- 処理の実行と委譲
- 受け取ったデータの検証、他の Service や UseCase への処理の委譲、アプリケーションの状態の更新、さらなるイベントのトリガーなどのタスクを実行する場合があります。
- 適用範囲
- イベント駆動型アーキテクチャ、UIプログラミング(Flutter ではインライン関数やウィジェットメソッドで処理されることも多いですが、より複雑な場合は専用の Handler クラスが有効です)、ネットワーク通信、リクエスト処理パイプラインなどで一般的に使用されます。
具体的なコード例(Flutter / Dart)
以下に、Handler の具体的な例として NotificationHandler を示します。この Handler は、アプリケーションが受信する可能性のある様々な種類の通知(プッシュ通知のペイロードやディープリンクなど)を解釈し、適切なアクション(画面遷移やアラート表示など)を実行します。
// --- 通知ペイロードのデータモデル ---
class NotificationPayload {
final String type;
final Map<String, dynamic> data;
NotificationPayload({required this.type, required this.data});
@override
String toString() => 'NotificationPayload(type: $type, data: $data)';
}
// --- ハンドラーが依存するサービスのインターフェース例 ---
abstract class NavigationService {
void navigateToProduct(String productId);
void navigateToUserProfile(String userId);
void showGeneralAlert(String title, String message);
}
// --- NavigationServiceの実装例 ---
class AppNavigationService implements NavigationService { ... }
// --- NotificationHandlerクラスの実装例 ---
class NotificationHandler {
final NavigationService _navigationService;
NotificationHandler(this._navigationService);
/// 受信した通知ペイロードを処理します。
void handleNotification(NotificationPayload payload) {
print('NotificationHandler: Received notification of type "${payload.type}" with data: ${payload.data}');
switch (payload.type) {
case 'deep_link_product':
final productId = payload.data['productId'] as String?;
if (productId != null) {
_navigationService.navigateToProduct(productId);
} else {
print('NotificationHandler: Error - Product ID missing in deep_link_product payload.');
}
break;
case 'deep_link_user_profile':
final userId = payload.data['userId'] as String?;
if (userId != null) {
_navigationService.navigateToUserProfile(userId);
} else {
print('NotificationHandler: Error - User ID missing in deep_link_user_profile payload.');
}
break;
case 'simple_alert':
final title = payload.data['title'] as String? ?? 'Notification';
final message = payload.data['message'] as String? ?? 'You have a new notification.';
_navigationService.showGeneralAlert(title, message);
break;
default:
print('NotificationHandler: Unhandled notification type: ${payload.type}');
}
}
}
注意点・アンチパターン
Handler パターンを効果的に使用し、その責務を明確に保つためには、以下の点に注意してください。
Handler 実装時の一般的な考慮事項:
- God Handler (単一責任の原則違反): 単一の Handler クラスが、根本的に異なる種類のイベントやリクエストをあまりにも多く処理しようとすると、責務が肥大化し、単一責任の原則(SRP)に違反します。イベントの種類や関心事に応じて、より小さく、焦点の定まったHandler(例: LoginEventHandler、PaymentFailedNotificationHandler)に分割することを検討しましょう。
- Handler 内のビジネスロジック過多: Handler は主にイベントやメッセージに反応し、後続のアクションを「調整」または「委譲」する役割に徹するべきです。複雑なビジネスロジックそのものを Handler 内に実装するのではなく、UseCase やService といった適切なクラスに委譲します。
- 過度に汎用的な handle メソッドのペイロード: handle メソッドが非常に汎用的なペイロード(例: Object eventData やMap<String, dynamic> のみに頼る)を受け入れる設計は、多くの型チェックやキャストが必要となり、型安全性が低下しエラーを引き起こしやすくなります。可能であれば、処理対象のイベントやメッセージの型を明確に定義し、それ専用のhandleメソッドや型付けされたペイロードクラスを使用する方が堅牢です。
- イベントソースとの適切な分離: Handler は、処理するイベントの内容については知る必要がありますが、イベントがどのように配信されるかという特定のメカニズム(例: 特定のメッセージキューライブラリの詳細)からは、可能な限り分離するべきです。インターフェースや抽象クラスを通じてイベントを受け取ることで、テスト容易性や再利用性が向上します。
他の類似パターンとの使い分け・注意点:
- Controller / Presenter / ViewModel との関連と役割分担: これらのUIアーキテクチャパターンは、ユーザーインターフェースからのイベント(例: ボタンクリック、テキスト入力)を処理する、UIに特化した Handler と見なせます。多くの場合、これらのUIパターンは受け取ったUIイベントを解釈し、関連する UseCase や Service に処理を委譲します。汎用的な Handler は、UI以外のシステムイベントやバックグラウンド通知などを扱うことが多いです。
- UseCase / Interactor との連携と責務境界: UseCase はアプリケーションの具体的なビジネスフローやユーザーの意図を実行する責務を持ちます。Handler は、その UseCase を「起動するきっかけ」となるイベントを処理したり、逆にUseCase の実行結果として発生する副作用(例: 通知の送信、外部システムへの連携)を「処理する」ために呼び出されたりすることがあります。Handler はイベントの受付と初期解析、UseCase はビジネスロジックの実行、という分担が基本です。
- Router / RouteHandler との特化性: RouteHandler は、特定のURLパスやルート名に対する画面遷移リクエストを処理するという、ナビゲーションに特化した Handler です。一般的な Handler は、通知、メッセージ、システムイベントなど、より広範な種類の「出来事」を扱います。
- Resolverとの違いを認識する: Resolver は入力に基づいて何か(インスタンス、設定値、データなど)を「解決して提供する」のが主な役割です。一方、Handler は入力(イベント、メッセージ)に基づいて特定の「処理を実行する」のが主な役割です。システムによっては、イベントタイプに基づいて適切な Handler インスタンスを Resolver が選択し、その Handler に処理を委譲する、といった連携も考えられます。
10. Service
役割・責務
Service は、特定のビジネス機能や、複数のドメインオブジェクトにまたがる、あるいは複数の Repository や外部サービスを協調させて実現される複雑なビジネスロジックを担う責務を持つクラスです。これは、特定のユースケースに限定されず、システム全体で利用される汎用的なビジネスプロセスや、トランザクション境界を管理する役割を果たすことがあります。
UseCase (または Interactor) との責務はしばしば重なるように見えますが、Service はより汎用的で低レベルなビジネスプロセスを提供し、UseCase はそれを組み合わせて特定のアプリケーション機能(ユースケース)の実行フローを定義するという、「粒度」と「焦点」の違いがあります。Service は「ドメインサービス」と呼ばれることもあり、ドメイン層の一部として機能することもあります。
なぜその命名なのか
「奉仕」「提供」という言葉の通り、特定の機能を提供し、他のクラスから利用される役割を明確にします。
ただし、その汎用性の高さゆえに、安易に使うと「何でも屋」になりやすく、最も濫用されやすい命名の一つです。そのため、Repository や UseCase など、より具体的な命名が適切でない場合にのみ、慎重に検討すべきです。Service は、他の命名パターンでは表現しきれない、複数の責務を横断するビジネスプロセスをカプセル化する際に有効です。
期待される実装
Service クラスには、主に以下の役割が期待されます。
- 複数の Repository を組み合わせたデータ操作
- 異なるドメインオブジェクトの永続化を一連の操作としてまとめ、ビジネス要件に基づいた複雑なデータの整合性を保ちます。
- トランザクション管理
- 複数のデータ操作がアトミック(不可分)に行われることを保証します。つまり、一連の操作がすべて成功するか、すべて失敗するかのどちらかであるように管理します。
- ビジネスルールの適用
- アプリケーション全体で適用されるべき複雑なビジネスルールやポリシーを実装します。例えば、特定の条件下でのみ許可される操作や、データのバリデーションなどです。
- 他の Service や外部システムとの連携
- 別の Service の機能を利用したり、支払いゲートウェイ、通知サービス、認証サービスなどの外部システムとの連携を調整します。
具体的なコード例(Flutter / Dart)
以下に、OrderProcessingService が、注文の確定という複雑なビジネスプロセスをどのように調整するかを示します。これは、在庫確認、支払い処理、注文の保存、在庫更新という複数のステップから構成され、それぞれが異なる責務(Repository や Gateway)に委譲されています。
// --- ドメインモデルの定義例 ---
class Order {
final String id;
final List<OrderItem> items;
final double totalAmount;
Order({required this.id, required this.items, required this.totalAmount});
@override
String toString() => 'Order(id: $id, totalAmount: $totalAmount, items: ${items.length})';
}
class OrderItem {
final String productId;
final int quantity;
final double price;
OrderItem({required this.productId, required this.quantity, required this.price});
@override
String toString() => 'OrderItem(productId: $productId, quantity: $quantity)';
}
class Product {
final String id;
final String name;
int stock;
Product({required this.id, required this.name, required this.stock});
@override
String toString() => 'Product(id: $id, name: $name, stock: $stock)';
}
// --- 依存するインターフェースの定義例 ---
// 注文データ永続化のリポジトリ
abstract class OrderRepository {
Future<void> saveOrder(Order order);
}
// 商品データアクセスと在庫更新のリポジトリ
abstract class ProductRepository {
Future<Product> getProduct(String productId);
Future<void> updateStock(String productId, int quantityChange); // quantityChangeは増減値
}
// 支払い処理を担うゲートウェイ
abstract class PaymentGateway {
Future<bool> processPayment(double amount);
}
// --- Serviceインターフェースの定義例 ---
// 注文処理の複雑なビジネスプロセスを担うサービスの抽象インターフェース
abstract class OrderProcessingService {
Future<void> placeOrder(Order order);
}
// --- Serviceインターフェースの実装例 ---
// OrderProcessingServiceインターフェースの具体的な実装クラス
class OrderProcessingServiceImpl implements OrderProcessingService {
final OrderRepository _orderRepository;
final ProductRepository _productRepository;
final PaymentGateway _paymentGateway;
// コンストラクタによる依存性注入
OrderProcessingService(
this._orderRepository,
this._productRepository,
this._paymentGateway,
);
// 注文処理のメインロジック
Future<void> placeOrder(Order order) async {
print('--- OrderProcessingService: 注文処理を開始します ---');
// 1. 在庫確認: 商品ごとに在庫を確認
for (var item in order.items) {
final product = await _productRepository.getProduct(item.productId);
if (product.stock < item.quantity) {
throw Exception('在庫が不足しています: ${product.name} (現在の在庫: ${product.stock}, 要求数: ${item.quantity})');
}
}
print('OrderProcessingService: 在庫確認完了。');
// 2. 支払い処理: 外部の支払いゲートウェイを利用
final success = await _paymentGateway.processPayment(order.totalAmount);
if (!success) {
throw Exception('支払い処理に失敗しました');
}
print('OrderProcessingService: 支払い処理完了。');
// 3. 注文の保存: 注文リポジトリを利用
await _orderRepository.saveOrder(order);
print('OrderProcessingService: 注文情報保存完了。');
// 4. 在庫の更新: 商品リポジトリを利用
for (var item in order.items) {
await _productRepository.updateStock(item.productId, -item.quantity);
}
print('OrderProcessingService: 在庫更新完了。');
print('--- OrderProcessingService: 注文処理が正常に完了しました! ---');
}
}
注意点・アンチパターン
Service という名前は汎用的であるため、その責務を明確にし、他のパターンとの境界を意識することが特に重要です。
Service 実装時の一般的な考慮事項:
- 「何でも屋(God Object)」問題の回避: Service という名前の抽象度の高さから、関連性の薄い多数のロジックが単一のService クラスに集約され、結果として巨大で複雑な「God Object」になりがちです。これは保守性とテスト容易性を著しく低下させます。Service を定義する際は、そのService が提供する「具体的なビジネス機能の集合」を明確にし、例えば OrderProcessingService や NotificationService のように、その機能を反映した命名を心がけましょう。
- レイヤー違反の防止: Service はビジネスロジック(ドメインサービス)やアプリケーション固有の調整ロジック(アプリケーションサービス)を担うべきです。UIの更新指示や、データソースへの直接的な低レベルアクセス(本来 Repository や Client が担うべきこと)といった、異なるレイヤーの関心事を含めるべきではありません。
- 不必要な Service の導入を避ける: ビジネスロジックが非常に単純であるか、単一の Repository のメソッド呼び出しや、ごく少数のドメインオブジェクトの操作で完結する場合、独立した Service クラスを設ける必要がないこともあります。そのようなロジックは、UseCase に直接記述したり、ドメインモデルのメソッドとして実装したりする方が適切な場合があります。
他の類似パターンとの使い分け・注意点:
- UseCase / Interactor との適切な役割分担 (最重要): これは最も混同されやすいポイントです。Service は、複数のUseCase から再利用される可能性のある、より汎用的なビジネスプロセスや操作の部品を提供します。一方、UseCase は、特定のユーザーの目的やシステムの利用事例(例: 「ユーザー登録を行う」「商品を注文する」)に対応し、その目的達成のために必要な一連の処理フローを調整・実行します。UseCase が一つ以上の Service を利用してそのフローを実現することが一般的です。Service に特定のUI画面やユーザーインタラクションに強く依存したロジックを含めるべきではありません。
- Repository との責務境界: Repository はドメインオブジェクトの永続化と取得というデータアクセスに特化します。Service は、その Repository を利用してビジネスルールを適用したり、複数の Repository をまたがる操作を実行したりします。Service が直接データベースクエリを書いたり、APIクライアントを直接操作したりするのは通常 Repository や Gateway の責務です。
- Handler との連携と使い分け: Handler は特定のイベントやリクエストに反応して処理を開始する役割を持ちます。多くの場合、Handler は受け取ったイベントを解釈した後、実際のビジネス処理を Service や UseCase に委譲します。Service 自身がイベントの購読やディスパッチの責務まで負うことは少ないです。
- Manager への変質リスクに注意: Service が提供すべき「具体的なビジネス機能」の範囲を超え、リソースのライフサイクル管理や、広範な状態の調整といった「管理」タスクを多く抱え込み始めると、それは実質的に「Manager」パターン(そして多くの場合アンチパターン)に近づいてしまいます。Service はあくまで機能「提供」に焦点を当てましょう。
11. Interactor / UseCase
役割・責務
Interactor / UseCase は、アプリケーション固有のビジネスルールや、ユーザーがシステムと「何をしたいのか」という具体的な要求(ユースケース)を表現し、主要機能の実行フローを定義するクラスです。Clean Architecture などではアプリケーション層の中核を担います。
Service が再利用可能なビジネスプロセスの「部品」であるのに対し、UseCase は、ユーザー操作や外部イベントからトリガーされ、Service や Repository といった要素を調整して、特定の目標を達成する一連の処理全体を担います。
UseCase を設計する際は、以下の点を重視します。
- 単一の目標と適切な粒度
- ユーザーが達成したい単一の具体的な目標に対応させ、明確な機能的価値を提供します。例えば「カートから商品を注文する」(PlaceOrderFromCartUseCase)は、複数の内部ステップを含みつつもユーザーにとっては一つの目標であり、適切な粒度です。これは単一責任の原則(SRP)にも合致します。
- 独立した機能単位
- 「カートに商品を追加する」(AddItemToCartUseCase)や「カートを空にする」(ClearCartUseCase)といった操作は、それぞれが独立した意味を持つため、個別の UseCase として設計するのが一般的です。
- 責務の委譲
- UseCase 内で基本的な操作を行うことは許容されますが、その処理が複雑で再利用性が見込める場合は、独立した Service やより細かい粒度の UseCase に責務を委譲します。
なぜその命名なのか
「相互作用する者(Interactor)」や「利用事例(UseCase)」という言葉は、システムがユーザーや外部とどう関わるか、どのような「利用事例」を提供するのかという、アプリケーションの振る舞いを表現します。
「UseCase」は Ivar Jacobson が提唱し、システムが提供する機能的価値やユーザーの目的を記述する概念です。一方、「Interactor」は Robert C. Martin の Clean Architecture における用語で、ドメインエンティティと相互作用しビジネスルールを調整するオブジェクトを指します。
由来やニュアンスに違いはありますが、実質的に同じ責務を担うことが多く、「このクラスはユーザーが達成したいことそのものだ」と直感的に理解させる命名です。
期待される実装
Interactor / UseCase クラスには、主に以下の役割が期待されます。
- 特定のユースケース実行フローの記述
- アプリケーションの特定のユースケース(例: 「ユーザー登録」「商品購入」「パスワードリセット」)の開始から終了までの処理の流れを定義します。
- 依存コンポーネントのオーケストレーション
- 複数の Repository や Service を協調させ、それぞれの責務を呼び出し、結果を統合してユースケースの目標を達成します。
- 入力バリデーションと出力準備
- ユースケースの実行に必要な入力データを受け取り、そのバリデーション(アプリケーション固有のルールに基づくもの。データ形式などの基本的なバリデーションはプレゼンテーション層やドメインモデルで行うことが多い)を行います。また、ユースケースの実行結果をプレゼンテーション層が利用しやすい形に整形する指示を出すこともあります(ただし、UseCase 自体がプレゼンテーション層の具体的なデータ構造に依存するべきではありません)
具体的なコード例(Flutter / Dart)
ここでは、Serviceのセクションで登場した OrderProcessingService を利用し、より具体的な「カートから商品を注文する」というユースケースを表現する PlaceOrderFromCartUseCase の例を示します。
// --- ドメインモデルの定義例 ---
class Cart {
final String userId;
final List<CartItem> items;
double get total => items.fold(0.0, (sum, item) => sum + (item.price * item.quantity));
Cart({required this.userId, required this.items});
@override
String toString() => 'Cart(userId: $userId, items: ${items.length}, total: $total)';
}
class CartItem {
final String productId;
final int quantity;
final double price;
CartItem({required this.productId, required this.quantity, required this.price});
@override
String toString() => 'CartItem(productId: $productId, quantity: $quantity, price: $price)';
}
class Order {
final String id;
final String userId;
final List<OrderItem> items;
final double totalAmount;
Order({required this.id, required this.userId, required this.items, required this.totalAmount});
@override
String toString() => 'Order(id: $id, userId: $userId, items: ${items.length}, totalAmount: $totalAmount)';
}
class OrderItem {
final String productId;
final int quantity;
final double price;
OrderItem({required this.productId, required this.quantity, required this.price});
@override
String toString() => 'OrderItem(productId: $productId, quantity: $quantity, price: $price)';
}
// --- 依存するインターフェースの定義例 ---
// 注文処理の複雑なビジネスプロセスを担うサービス
abstract class OrderProcessingService {
Future<void> placeOrder(Order order);
}
// カートデータの永続化と取得を担うリポジトリ
abstract class CartRepository {
Future<Cart> getCart(String userId);
Future<void> clearCart(String userId);
Future<void> addItemToCart(String userId, CartItem item); // カートへの商品追加メソッド(例として)
}
// --- UseCaseクラスの実装例 ---
class PlaceOrderFromCartUseCase {
final CartRepository _cartRepository;
final OrderProcessingService _orderProcessingService; // Serviceへの依存
PlaceOrderFromCartUseCase(this._cartRepository, this._orderProcessingService);
Future<void> execute(String userId) async {
print('--- PlaceOrderFromCartUseCase: ユーザー $userId の注文処理を開始します。 ---');
// 1. ユーザーのカートを取得する
final cart = await _cartRepository.getCart(userId);
if (cart.items.isEmpty) {
print('PlaceOrderFromCartUseCase: カートに商品がありません。処理を中断します。');
throw Exception('カートに商品がありません。');
}
print('PlaceOrderFromCartUseCase: カート情報を取得しました: $cart');
// 2. カートの内容から新しい注文オブジェクトを作成する(UseCase固有のドメインロジックの一部)
// このような変換ロジックは、より複雑な場合は専用のConverterやFactoryに移譲することも検討
final newOrder = Order(
id: 'ORDER-${DateTime.now().millisecondsSinceEpoch}-${userId.substring(0,3)}', // ユニークなIDを生成(例)
userId: userId,
items: cart.items.map((cartItem) => OrderItem(
productId: cartItem.productId,
quantity: cartItem.quantity,
price: cartItem.price,
)).toList(),
totalAmount: cart.total,
);
print('PlaceOrderFromCartUseCase: 注文オブジェクトを作成しました: $newOrder');
// 3. 注文処理サービスを呼び出し、複雑なビジネスプロセスを委譲する
await _orderProcessingService.placeOrder(newOrder);
print('PlaceOrderFromCartUseCase: OrderProcessingServiceによる注文処理が完了しました。');
// 4. 注文成功後の後続処理(ユースケースの完了ロジック)
await _cartRepository.clearCart(userId);
print('PlaceOrderFromCartUseCase: ユーザー $userId のカートがクリアされました。');
print('--- PlaceOrderFromCartUseCase: 注文が正常に完了しました! ---');
}
}
注意点・アンチパターン
Interactor / UseCase を効果的に設計・実装するためには、その責務範囲を明確にし、他のパターンとの適切な使い分けを意識することが重要です。
UseCase / Interactor 実装時の一般的な考慮事項:
- UIロジックの混入を避ける: UseCase は純粋なアプリケーションのビジネスロジックに集中し、UIの表示方法や更新処理に直接関与するべきではありません。UI関連の処理は、UseCaseを 呼び出すプレゼンテーション層(例: ViewModel, Presenter, Controller)が担当します。
- 過度な詳細実装の回避 (「何を」に集中): UseCase はビジネスフローの「何を」行うかを定義し、その「どのように」行うかの具体的な技術的詳細は下位レイヤー(Service, Repository, Driver など)に委譲します。UseCase のメソッドが非常に長大になったり、低レベルなAPI操作が直接記述されたりしている場合は、責務分離が不十分な可能性があります。
- 単一責務の原則(一つの明確なユースケース): 各 UseCase クラスは、ユーザーの視点から見て一つの明確な目標やタスク(ユースケース)のみを担当するように設計します。「ユーザー管理全般 UseCase」のような曖昧なものではなく、「ユーザーを登録する UseCase (RegisterUserUseCase)」「ユーザー情報を更新する UseCase (UpdateUserProfileUseCase)」のように、具体的に命名し責務を限定します。
- 入力と出力の明確化 (契約の定義): UseCase がどのような入力(例: 専用の RequestModel やパラメータ)を期待し、どのような出力(例: ResponseModel、ドメインオブジェクト、処理結果を示す Future<void> や Future<Result>型など)を返すのかを明確に定義します。これにより、UseCase の利用側との契約が明確になり、テストもしやすくなります。
他の類似パターンとの使い分け・注意点:
- Service との適切な役割分担 (最重要): これが最も重要な区別です。Service は、複数の UseCase で再利用可能な、より汎用的で独立したビジネス操作やプロセスを提供します(例: PaymentService、EmailNotificationService)。一方、UseCase は、特定のユーザーシナリオやアプリケーションの目的に沿って、一つ以上の Service や Repository を調整・実行し、エンドツーエンドのビジネスフローを実現します。UseCase に汎用的なビジネスロジックを直接実装しすぎると再利用性が低下し、逆に Service が特定の画面フローに強く依存してしまうと Service の汎用性が失われます。
- Handler との連携と責務境界: Handler は特定のイベント(例: システム通知、ユーザー操作、メッセージ受信)に反応して処理を開始する役割です。多くの場合、Handler はそのイベントを解釈した後、実際のビジネス処理を対応する UseCase に委譲します。UseCase がイベントのディスパッチや低レベルなイベントリスニングまで行うべきではありません。
12. Manager
役割・責務
Manager は、特定の領域、リソース群、あるいはプロセスに対して、管理、調整、ライフサイクル制御といった包括的な責務を持つクラスに付けられることがある名前です。
しかし、この名前は非常に汎用的であるため、最も濫用されやすく、容易に責務過剰な「何でも屋」クラス(God Object)を生み出すアンチパターンとなり得ることに最大限の注意が必要です。
他のより具体的で責務が明確な命名が適用できないか、常に慎重に検討すべきです。安易に Manager と命名することは避けるべきプラクティスと考えられます。
なぜその命名なのか?
「管理者」という言葉の通り、ある対象を「管理・統括する」役割を示唆します。しかし、この「管理」が具体的に何を指すのかがクラス名やドキュメントで明確に定義されていない場合、そのクラスの実際の責務は曖昧になりがちです。
期待される実装 (限定的に許容されるケース)
Manager という名前が比較的許容されるのは、その「管理」対象と責務が明確に限定されている以下のような場合です。
- リソースのライフサイクル管理:
- SessionManager: ユーザーセッションの生成、検証、維持、破棄を管理します。
- ConnectionManager: ネットワーク接続やデータベース接続プールの作成、維持、解放といったライフサイクルを管理します。
- DownloadManager: 複数のダウンロードタスクを管理し、キューイング、開始、一時停止、再開、完了、エラー処理といったライフサイクルと状態を調整します。
- 特定の状態や設定の集約と調整:
- ThemeManager: アプリケーション全体のテーマ(ライトモード/ダークモードなど)の状態を一元的に管理し、変更をUIに通知・適用します。
- FeatureManager(または FeatureFlagManager): 機能フラグの状態を管理し、特定の機能が有効か無効かをアプリケーションの各部に提供します。
- 特定の外部システム/SDKの包括的な統括 (限定的):
- LocationManager: OSが提供する位置情報サービスとのやり取り(権限要求、精度設定、位置情報の購読・取得、ジオフェンシングなど)を包括的に管理します。
- BluetoothManager: デバイスの Bluetooth アダプタの有効/無効状態の管理、デバイスのスキャン、接続状態の管理など、Bluetooth 機能全体を統括するインターフェースを提供します(個々のデバイスとの具体的な通信は BluetoothDeviceDriver に委譲することが望ましいです)。
- この場合、Facade パターンの方がより適切な命名となることもあります。
具体的なコード例(Flutter / Dart)
Manager が比較的許容されやすい「ライフサイクル管理」の例として、SessionManager を示します。これは、ユーザーのログイン状態、セッショントークン、基本的なユーザープロファイルなどを管理し、アプリケーション全体でセッション情報にアクセスできるようにします。Flutter の ChangeNotifier を使い、状態変更をUIに通知することも想定しています。
// --- 依存するサービスやモデルのインターフェース/クラス定義 ---
abstract class AuthenticationService {
Future<String?> login(String email, String password); // トークンまたはnullを返す
Future<void> logout(String token);
Future<User?> getUserProfileFromToken(String token);
}
class User {
final String id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
@override
String toString() => 'User(id: $id, name: "$name", email: "$email")';
}
// セキュアストレージサービスのインターフェース
abstract class SecureStorageService {
Future<String?> readToken();
Future<void> saveToken(String token);
Future<void> deleteToken();
}
// --- SessionManagerクラスの実装例 ---
class SessionManager extends ChangeNotifier {
final AuthenticationService _authService;
final SecureStorageService _secureStorage; // トークン永続化用
User? _currentUser;
String? _sessionToken;
bool _isLoading = false;
bool _isInitialized = false;
User? get currentUser => _currentUser;
String? get sessionToken => _sessionToken;
bool get isLoading => _isLoading;
bool get isLoggedIn => _currentUser != null && _sessionToken != null;
bool get isInitialized => _isInitialized;
SessionManager(this._authService ,this._secureStorage);
/// アプリケーション起動時などに呼び出し、永続化されたセッションの復元を試みます。
Future<void> initializeSession() async {
if (_isInitialized) return; // 既に初期化済みなら何もしない
_setLoading(true);
String? storedToken = await _secureStorage.readToken(); // ストレージからトークンを読み込む
if (storedToken != null) {
try {
final userProfile = await _authService.getUserProfileFromToken(storedToken);
if (userProfile != null) {
_currentUser = userProfile;
_sessionToken = storedToken;
print('SessionManager: Session restored for user ${userProfile.name}.');
} else {
// トークンが無効だった場合
await _secureStorage.deleteToken();
}
} catch (e) {
print('SessionManager: Error restoring session - $e');
await _secureStorage.deleteToken(); // エラー時もトークンを削除
}
}
_isInitialized = true;
_setLoading(false);
print('SessionManager: Session initialization complete. Logged in: $isLoggedIn');
notifyListeners(); // 初期化完了を通知
}
/// ユーザーをログインさせ、セッションを開始します。
Future<bool> login(String email, String password) async {
if (isLoggedIn) return true; // 既にログイン済み
_setLoading(true);
try {
final token = await _authService.login(email, password);
if (token != null) {
final userProfile = await _authService.getUserProfileFromToken(token);
if (userProfile != null) {
_currentUser = userProfile;
_sessionToken = token;
await _secureStorage.saveToken(token); // トークンを永続化
print('SessionManager: User ${_currentUser!.name} logged in successfully.');
_setLoading(false);
notifyListeners(); // 状態変更を通知
return true;
}
}
_clearSessionInternal(); // ログイン失敗時はセッション情報をクリア
_setLoading(false);
notifyListeners();
return false;
} catch (e) {
print('SessionManager: Login attempt failed - $e');
_clearSessionInternal();
_setLoading(false);
notifyListeners();
return false;
}
}
/// 現在のユーザーをログアウトさせ、セッションを終了します。
Future<void> logout() async {
if (!isLoggedIn) return;
_setLoading(true);
print('SessionManager: Logging out user ${_currentUser?.name ?? ""}...');
if (_sessionToken != null) {
try {
await _authService.logout(_sessionToken!); // バックエンドでのログアウト処理
} catch (e) {
print('SessionManager: Error during backend logout - $e (proceeding with local logout)');
}
}
_clearSessionInternal();
await _secureStorage.deleteToken(); // 永続化されたトークンを削除
_setLoading(false);
print('SessionManager: User logged out.');
notifyListeners(); // 状態変更を通知
}
/// 内部的にセッション情報をクリアします (通知は別途行う)。
void _clearSessionInternal() {
_currentUser = null;
_sessionToken = null;
}
/// ローディング状態を設定し、リスナーに通知します。
void _setLoading(bool loading) {
if (_isLoading != loading) {
_isLoading = loading;
notifyListeners(); // ローディング状態の変更を通知
}
}
}
注意点・アンチパターン
Manager という名前は、設計上の問題を隠蔽したり、将来的な問題を引き起こしたりする可能性が高いため、使用には最大限の注意が必要です。
Manager という命名がもたらす主な問題点:
- 責務の肥大化(God Object化): 「〜を管理する」という名目で関連性の薄い機能が次々と追加され、結果として巨大で複雑、かつ単一責任の原則に著しく違反するクラスになりがちです。
- 具体的な役割の欠如と誤解の招きやすさ: UserManager や DataManager のような名前は、クラスが具体的に「何を」「どのように」管理するのかを伝えられず、開発者間での認識の齟齬や誤用を生みやすいです。
- テスト困難性の増大: 多くの依存関係と複雑な内部状態を持つことが多いため、単体テストが非常に困難になり、変更時のデグレーションリスクも高まります。
- 「Manager が多すぎる」というコードの臭い: プロジェクト内に多数の XxxManager クラスが存在する場合、それは多くの場合、設計における責務分担が不適切であることの兆候(コードスメル)です。
Manager を避け、より具体的なパターンを選択するための指針:
安易に Manager と名付ける前に、そのクラスが本当に担うべき中核的な責務を見極め、以下のより具体的で責務が明確なパターンで代替できないか常に検討してください。
- UIフローやユーザー入力の制御が主なら: XxxController, XxxPresenter, XxxViewModel などを検討します。
- 特定のビジネス機能や操作の提供が主なら: XxxService(例: OrderProcessingService, UserNotificationService)を検討します。
- ドメインオブジェクトの永続化やデータアクセスの抽象化が主なら: XxxRepository(例: UserRepository, ProductRepository)が適切です。
- 特定のアプリ機能やユーザー目的達成のためのフロー調整が主なら: XxxUseCase または XxxInteractor(例: SubmitOrderUseCase)を検討します。
- オブジェクトの生成や複雑な構築プロセスが主なら: XxxFactory や XxxBuilder を検討します。
- 依存関係の提供や動的なインスタンス解決が主なら: XxxProvider や XxxResolver を検討します。
- 特定のイベントやメッセージへの応答処理が主なら: XxxHandler(例: NotificationHandler, DeepLinkHandler)を検討します。
原則として、「Manager」という名前はそのクラスの具体的な責務を表していない最終手段と考え、可能な限り避けるべきです。 より具体的で、チームメンバーや未来の自分がその役割を一目で理解できる名前を選ぶ努力をしましょう。
まとめ
本記事では、様々なクラス命名パターンとその役割を解説しました。これらは設計の指針の一つであり、絶対的なルールではありません。実際の開発は、プロジェクトの状況や制約に応じた判断が常に求められ、実に多様な制約とトレードオフの中で進められます。時には、本記事でアンチパターンとして警鐘を鳴らした Manager のような「God Object」が、やむを得ず(あるいは戦術的に)「活躍」する場面も現実には存在するでしょう。また、あるコンテキストでは非常に有効なパターンが、別のコンテキストでは過剰な設計となることも十分にあり得ます。
そのような現実の中で、私たちが最も大切にすべきことは何でしょうか。それは、命名に明確な「意図」を込めること、そしてそのクラスが担うべき「スコープ(責務範囲)」を強く意識することです。なぜその名前を選んだのか。その名前によって、クラスは何をすべきで、何を含めるべきではないのか。この設計思想の明確化こそが、コードの可読性、保守性、そして拡張性を長期にわたって支える基盤となります。
そして、この込められた意図と定義されたスコープは、現在の開発メンバーだけでなく、将来そのコードベースを引き継ぐであろう、まだ見ぬメンバーにも正確に伝わらなければなりません。その意味で、命名とは、クラスの責務にその名が示す意味合いによる適切な「制約」を与え、設計の意図を未来へ「継承」していくための、単なる表面的な共通認識やコーディング規約よりも遥かに強力なコミュニケーションツールなのです。
適切に選ばれたクラス名は、それ自体がそのクラスの目的と責任範囲を雄弁に物語る「生きたドキュメント」となり、チーム内での誤解を減らし、変更容易性を高め、結果としてプロダクトの品質向上に貢献します。
この記事が、皆さんがより意図の明確な名前を選ぶ一助となれば幸いです。
最後に、蛇足ながら。
本稿では多くの命名パターンを扱い、特に「Manager」クラスの乱用には警鐘を鳴らしてきました。しかしながら、これだけのパターンについて、それぞれの役割から注意点までを「管理」しようと試みた結果、この記事自体が少々「責務過剰」で長大な『命名パターン解説 Manager』と化してしまったかもしれません。 この小さな自己矛盾も、読者の皆様にクスリと笑って許していただけるなら、筆者としては望外の喜びです。
最後までお読みいただき、ありがとうございました。
この記事を書いた人
Advent Calendar!
Advent Calendar 2024
What is BEMA!?
Be Engineer, More Agile