mcpField
S
service-boundary-and-native

service-boundary-and-native

github.com/zakariaf開発63152 回インストール5 週間前に更新
リポジトリ

service-boundary-and-native について

Flutter 開発向けに、プラットフォームの副作用を注入可能なインターフェースの背後に隔離するスキルです。型付きの結果、Riverpod による依存関係の配線、決定的な Fake、管理されたネイティブ境界を扱います。

主な機能

  1. 01注入可能なインターフェースで副作用を隔離
  2. 02型付き結果による失敗表現
  3. 03Riverpod Provider による依存関係の配線
  4. 04契約を守る手書き Fake
  5. 05MethodChannel の配置を一元化
  6. 06Flavor のコンポジションルートを検査

活用シーン

  1. 01テスト可能な Flutter サービス境界の設計
  2. 02機能コードから SDK 依存を排除
  3. 03実 SDK なしで失敗経路をテスト
  4. 04ネイティブチャネルとアプリ Flavor の管理

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/zakariaf/flutter-skills --skill service-boundary-and-native

インストール後、Claude Code で/service-boundary-and-nativeを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

examples/
native_gateway.dart
service_boundary.dart
references/
multi-flavor.md
native-channels.md
service-interface.md
scripts/
check-flavor-graph.sh
check-service-boundaries.sh

詳細

サービス境界とネイティブ機能

すべてのプラットフォーム機能(「現在時刻」の読み取り、共有、イベントの記録、リモートフラグの取得、MethodChannel の呼び出し)は、注入されたインターフェースを介してアプリのレイヤーをまたぎます。インターフェースで使うのは値型のみで、具体的な SDK はちょうど 1 つのクラスにのみ配置し、コンポジションルート(main)では 1 つの実際の実装を接続し、テストでは決定論的なフェイクを接続します。**テストで実行できないものだけを抽象化します。それ以上は不要です。**この境界により、機能の純粋性を保ち、SDK を交換可能にし、アプリ全体をヘッドレスでテストできるようになります。

作業内容に応じて、以下のリファレンスを確認してください。

  • references/service-interface.md — bool/例外ではなく型付きの結果を使う方法、網羅性、@useResult、手書きの環境フェイク、アローコールバックで Future が破棄される落とし穴、単一の書き込み経路。
  • references/native-channels.md — MethodChannel の配置、多言語間の契約の所有者を示す表、バージョン管理されたファイルと shared_preferences の落とし穴、ネイティブの高速経路の独立性、往復を検証する integration_test。
  • references/multi-flavor.md — 1 行ずつ対応するコンポジションルート、実行時の検出ではなくビルドフレーバーを使う方法、フレーバーごとのプラグインのピン留め、禁止依存関係を検証する CI グラフゲート。

PR を作成する前に、scripts/check-service-boundaries.sh と scripts/check-flavor-graph.sh を実行してください。

絶対に守るルール

  1. すべての副作用は注入されたインターフェースを通し、呼び出し箇所で具体的な SDK を使ってはいけません。 Feature、Notifier、リポジトリが依存するのは インターフェース であり、アプリが実際の SDK をインポートするのは、ライブ実装クラスのちょうど 1 つだけです。理由:差し替え箇所を 1 つにし、プラットフォーム固有の癖を隔離する場所を 1 つにし、純粋なコードに SDK が入り込む余地をなくします。
  2. 抽象化するのは、flutter test で実行できないものだけです。 インターフェース 1 つ+実装 1 つ+フェイク 1 つなら妥当です。インターフェース 1 つ+実装 1 つ+フェイクなしなら、そのインターフェースを削除してください。理由:「対称性」のためだけのインターフェースは、テストできない I/O という本当のリスクがどこにあるかを見えにくくする無駄なものです。
  3. インターフェースのシグネチャが参照するのは値型だけです。 DateTime、ドメイン値オブジェクト、またはシールドされた結果型を指定し、プラグイン型や dart:io のシンボルは指定しません。理由:どのレイヤーからでもインターフェースを参照可能にし、SDK を 1 つのクラス内に閉じ込められます。
  4. すべてのメソッドは型付きの結果を返します。失敗は制御フローであり、握りつぶす例外ではありません。 キャンセル、空の取得結果、利用できないターゲットは、例外を投げて忘れるのではなく、シールドされた結果型または Result<T, F extends Failure> としてモデル化します。理由:Dart には throws 宣言がないため、未処理の失敗は気付かれないまま消えてしまいます。Result/Failure の基盤については error-handling-typed-results を参照してください。
  5. プロバイダーはオーバーライドされるまで UnimplementedError を投げます。プロバイダーが DI そのものです。 get_it、サービスロケーター、2 つ目のコンテナー、プロバイダー本体内のビジネスロジックは使いません。理由:配線の設定漏れを、無言の null データやインポート時に SDK が起動する状態ではなく、明確な起動時エラーとして知らせます。
  6. コンポジションルートで、インターフェースごとにライブ実装をちょうど 1 つ配線します。 main(フレーバーごと)がライブ SDK を構築する唯一の場所です。app.dart とその下にあるすべてのウィジェットは配線を意識しません。理由:コンポジションルートは、現実世界の実装を選ぶ唯一の接点です。
  7. テストでは、契約を守る手書きのフェイクを使います。モックよりフェイクを優先します。 フェイクの失敗経路は実行可能でなければなりません。常に成功するフェイクは、正常系だけを装ったものです。extends ではなく implements を使い、新しいインターフェースメソッドが追加されたらビルドが失敗するようにします。理由:テストの成功は、テストが防ごうとしている失敗も含め、実際の動作を反映していなければなりません。
  8. 時計は clockProvider(package:clock の Clock)を通じて注入し、「現在時刻」の取得元を 1 つにします。 View、Notifier、リポジトリ、純粋なコアから到達できる DateTime.now()/math.Random() は使いません。Feature のコードでは ref.read(clockProvider).now() を読み取り、純粋なコアでは Clock またはアンビエントな clock を受け取ります。独自の ClockService/SystemClock は使いません。理由:Clock.fixed(...) でオーバーライドし、日付/連続記録日数/有効期限のロジックを決定論的にできるためです。注入する Clock については value-objects-money-and-units を参照してください。
  9. 状態を変更するサービスは、単一の書き込み経路に渡し、公開前に永続化します。 永続状態を変更するサービスは、状態を再公開する前にコミット(単一のトランザクション)を行うリポジトリメソッドから利用します。理由:処理の途中でクラッシュしても、「確認済みだが永続化されていない」状態を決して残さないためです。
  10. すべての MethodChannel は 1 つの lib/native/ ディレクトリ内に置き、それ以外の場所では作成しません。 ウィジェット、リポジトリ、Notifier 内で作成されたチャネルは、どれほど問題なく動作していても不具合です。理由:プラットフォームチャネルはアプリ内で最もテストしにくいコードであり、1 か所に集める必要があります。
  11. 言語間で共有するフォーマットは、各側に単一の所有者を持つバージョン管理された契約とし、同一コミットで両側を編集して、integration_test で検証します。 理由:言語間の境界でキーが変更されても、コンパイラー、アナライザー、ユニットテストでは検出できません。実際に往復させて確かめる方法だけが有効です。

インターフェースとそのプロバイダー(Riverpod DI)

インターフェースは小さく、値型で、型付きの結果を返します。プロバイダーは、例外をスローするだけの薄い配線です。

// lib/services/share_service.dart — value types only, no plugin symbol.
sealed class ShareResult { const ShareResult(); }
final class Shared extends ShareResult { const Shared(); }
final class ShareDismissed extends ShareResult { const ShareDismissed(); }
final class ShareUnavailable extends ShareResult {
  const ShareUnavailable(this.code); // a stable code, never a localized string
  final String code;
}

abstract interface class ShareService {
  Future<ShareResult> share(String text); // a dismiss/unavailable is data, not a throw
}
// lib/services/service_providers.dart — the Provider IS the DI. It throws until
// the composition root overrides it, so a forgotten wiring fails loudly at startup.
final shareServiceProvider = Provider<ShareService>(
  (ref) => throw UnimplementedError('override shareServiceProvider in main.dart'),
);

最も小さな境界の基本となるのは時間です。どこでも読み取れるようにし、DateTime.now() はどこにも置きません。唯一の時間型は package:clock の Clock で、clockProvider 経由で注入します。SDK に依存するポートとは異なり、Clock プロバイダーは実際の Clock(const Clock() は I/O を開かず、SDK にアクセスしません)を安全にデフォルトとして使うため、動作にオーバーライドは不要です。テストでは Clock.fixed(...) でオーバーライドします。独自の ClockService/SystemClock は定義しないでください。

import 'package:clock/clock.dart';

// The ONE time seam. Feature/Notifier code reads ref.watch(clockProvider).now();
// pure core takes a Clock param or uses the ambient `clock` (testable via withClock).
final clockProvider = Provider<Clock>((ref) => const Clock());

コンポジションルートに置く唯一のライブ実装

ライブ SDK を構築する場所は main のみです。具体的な SDK のインポートは、ライブ実装クラスの内部にのみ記述します。

// lib/main.dart — the composition root. app.dart below is wiring-blind.
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(ProviderScope(
    overrides: [
      shareServiceProvider.overrideWithValue(const OsShareService()), // the ONE live impl
      // clockProvider self-defaults to const Clock() — no override needed outside tests.
    ],
    child: const App(),
  ));
}

マルチパッケージ(ワークスペース)の場合: インターフェースは共有基盤パッケージに、ライブ実装と main はアプリパッケージに配置します。単一パッケージのアプリでは、3 つとも lib/ 配下のフォルダーに配置します。project-structure-and-packages を参照してください。

マルチフレーバーの場合: 単一のフレーバー非依存な app.dart の上に、フレーバーごとに薄い main_<flavor>.dart を 1 つずつ用意します。それぞれの main は、同じプロバイダーリストを同じ順序でオーバーライドします。変わるのは各右辺の実装だけなので、2 つのファイルは行単位で差分を比較できます。実際のビルドフレーバー(Android の product flavor と iOS の scheme)を使い、実行時の判定は使いません。references/multi-flavor.md を参照してください。

モックではなくフェイクを使う

インターフェースを implements する手書きのフェイクを作成し、現実に起こり得る失敗をモデル化します。モックフレームワークもコード生成も使いません。

// lib/services/testing/fake_share_service.dart
final class FakeShareService implements ShareService {
  FakeShareService(this._next);
  final ShareResult _next;
  int calls = 0;
  factory FakeShareService.succeeding() => FakeShareService(const Shared());
  factory FakeShareService.unavailable() => FakeShareService(const ShareUnavailable('no_target'));

  @override
  Future<ShareResult> share(String text) async {
    calls++;
    return _next; // resolves instantly — no plugin, no OS sheet; failure path reachable
  }
}

Clock には手書きのフェイクは不要です。package:clock が Clock.fixed(...)(純粋なコードには withClock)を提供します。

// test — no emulator, no network, no real SDK. ProviderContainer.test() auto-disposes.
test('sharing an unavailable target surfaces a code, never throws', () async {
  final container = ProviderContainer.test(overrides: [
    shareServiceProvider.overrideWithValue(FakeShareService.unavailable()),
    clockProvider.overrideWithValue(Clock.fixed(DateTime.utc(2026, 7, 21))),
  ]);
  final result = await container.read(shareServiceProvider).share('hello');
  expect(result, isA<ShareUnavailable>());
});

ネイティブ境界

MethodChannel は lib/native/ に隔離し、薄い Gateway でラップして、同じ注入可能なインターフェースの境界を通じて公開します。(Gateway は特定のプラグイン/SDK または MethodChannel を薄くラップするものを指し、Service は定義する機能インターフェースを指します。naming-conventions を参照してください。)遅延が問題になる場合に備え、Dart エンジンが稼働していない状態でもネイティブ側を実行できるようにします。

// lib/native/item_widget_bridge.dart — the ONLY MethodChannel owner for this feature.
class ItemWidgetBridge {
  static const _channel = MethodChannel('app/item_widget');
  Future<void> publish(Map<String, Object?>? contractJson) =>
      _channel.invokeMethod('publish', contractJson);
}

// lib/native/item_widget_contract.dart — SOLE Dart owner of the shared format.
// Mirror: android/.../ItemWidgetContract.kt  (edit BOTH files in the same commit).
const int kItemWidgetContractVersion = 1;
Map<String, Object?> itemToContractJson(String title, DateTime updatedAtUtc) => {
      'v': kItemWidgetContractVersion, // a versioned file we own, not shared_preferences
      'title': title,
      'updatedAtUtc': updatedAtUtc.toIso8601String(),
    };

ミラーされた値を変更するすべての書き込み処理では、誰かが後から忘れずに行う処理としてではなく、書き込みの一部として再公開します。削除/クリアでも、保存と同じ確実さで再公開します。所有権テーブル、shared_preferences の落とし穴、ミラーを検証する往復テストについては、references/native-channels.md を参照してください。

アンチパターン

  • 機能、Notifier、リポジトリからプラグイン/SDK を呼び出すこと。 実際の SDK をインポートするのは、そのライブ実装の内部のみです。ウィジェット、コントローラー、リポジトリの本体では決してインポートしません。
  • ビュー、Notifier、リポジトリ、純粋なコアから DateTime.now()/math.Random() にアクセスできること。 「現在時刻」の情報源は注入された Clock の 1 つだけです。それ以外を使うと、決定的な時間テストができなくなります。
  • スローする代わりにライブサービスをデフォルトとして使うプロバイダー。 オーバーライドの不足が隠れ、インポート時に実際の I/O が開始される可能性があります。
  • 境界のフェイクにモックフレームワークを使うこと、または常に成功して失敗経路を一度も実行しないフェイクを使うこと。
  • onTap: () => service.doThing() — アロー関数クロージャーが Future を VoidCallback に返すため、Future とそのエラーの両方が破棄され、これを検出する lint はありません。void を返すハンドラーを経由し、catchError を付けた unawaited(...) を呼び出してください。references/service-interface.md を参照してください。
  • lib/native/ の外で MethodChannel を作成すること、または同じコミット内で相手側を変更せずに言語境界の片側だけでコントラクトキーの名前を変更すること。
  • プロバイダー内にビジネスロジックを置くこと、または Riverpod と併用して 2 つ目の DI コンテナー(get_it、package:provider)を使うこと。
  • サービス内にユーザー向けの文言を置くこと。 境界は安定したコードを持つ型付きの失敗を返し、機能レイヤーがそれをローカライズされた文言に変換します。
  • 機能コード内に #if FLAVOR/String.fromEnvironment('FLAVOR') による分岐を置くこと、またはエコシステムを検出して実行時に SDK を切り替えるバイナリーを使うこと。 フレーバーによる違いはコンポジションルートでのみ扱います。

完了の定義

  • 境界はabstract interface classであり、そのシグネチャが参照するのは値型のみです。プラグイン型やdart:ioのシンボルは含みません。
  • インターフェースを設けるのは、テストでは実行できないため(実際の実装とフェイクがあるため)に限ります。「対称性のため」にインターフェースを追加してはいけません。
  • すべてのメソッドは型付きの結果(sealed outcome/Result<T, F extends Failure>)を返します。キャンセル/利用不可/空は制御フローとして扱い、例外をスローしません。失敗にはローカライズ済み文字列ではなく、安定したコードを持たせます。
  • プロバイダーはオーバーライドされるまでUnimplementedErrorをスローします。利用側はref.watch/ref.readで参照します。get_itや2つ目のコンテナーは使わず、プロバイダーにロジックを置きません。
  • インターフェースごとに、稼働中の実装を1つだけmainで(フレーバーごとに)配線します。app.dartは具体的なSDKをインポートしません。複数のフレーバーのmainは、行ごとの差分がありません。
  • 手書きのフェイクがインターフェースをimplementsし、契約を遵守します(失敗経路に到達可能です)。overrideWithValueで導入し、モックフレームワークは使いません。
  • ビュー、Notifier、リポジトリ、純粋なコアからDateTime.now()/math.Random()に到達できません。時刻は注入されたClock(clockProvider)を通じてのみ渡し、独自のClockServiceは使いません。
  • 状態を変更するサービスは、単一の書き込み経路に接続します。公開する前に永続化します。
  • すべてのMethodChannelはlib/native/配下に置きます。稼働中の実装だけがSDKをインポートし、scripts/check-service-boundaries.shが成功します。
  • 言語間で共有する形式がある場合は、バージョン管理し、各側で一度だけ所有し、同じコミットで両側を編集し、ラウンドトリップのintegration_testで検証します。
  • フレーバーがある場合は、依存禁止を検証するCIグラフゲート(scripts/check-flavor-graph.sh)により、各フレーバーが禁止されたSDKに一切リンクしていないことを証明します。

関連スキル

  • これらのプロバイダーを利用するNotifier/AsyncNotifierの規律と、watch/read/listenの使い分けについては、state-management-riverpodを参照してください。
  • すべての境界メソッドが返すsealed Result/Failureの基盤については、error-handling-typed-resultsを参照してください。
  • アローコールバックでFutureが破棄される問題を防ぐvoidハンドラーパターンと、mounted/BuildContextのガードについては、async-safetyを参照してください。
  • これらのオーバーライドを配置するコンポジションルートの順序(エラーハンドラー、設定、DIオーバーライド)については、app-startup-and-bootstrapを参照してください。
  • 単一パッケージとワークスペースの構成において、インターフェース、稼働中の実装、mainを配置する場所については、project-structure-and-packagesを参照してください。
  • 稼働中の実装ごとに使用するプラグインの審査とバージョン固定については、dependency-hygieneを参照してください。
  • フェイクをモックより優先する方針と、受け入れゲートの考え方については、testing-strategyを参照してください。
  • 注入するClock(package:clock)と、これらのシグネチャで指定する標準の値型については、value-objects-money-and-unitsを参照してください。
  • 定義する機能インターフェースにはService、特定のプラグイン/SDKやMethodChannelをラップするものにはGatewayというサフィックスを使う規則については、naming-conventionsを参照してください。
  • 広告/課金境界に適用されるポリシー(広告SDKの初期化前にエンタイトルメントを復元する理由を含む)については、ads-and-iap-monetizationを参照してください。
  • 共有/ファイル選択のGatewayとそのフェイクについては、data-export-and-restoreを参照してください。また、新しいプラグインごとに必要となる権限設定とストアへの宣言については、release-and-store-shippingを参照してください。

Provider/ChangeNotifier 付録

公式の Flutter ガイドのスタックでも同じルールが適用されます。変わるのは接続の仕組みだけです。ルール 1〜4、7〜11 は同一です。インターフェースと型付きの結果も変わりません。

  • DI には構成ルートで package:provider を使い、インターフェースを注入します。 オーバーライドされるまで例外を投げるイディオムはないため、ルートで実際の実装を提供し、その下では SDK を決して生成しません。
void main() {
  runApp(MultiProvider(
    providers: [
      Provider<Clock>.value(value: const Clock()), // package:clock, not a bespoke ClockService
      Provider<ShareService>.value(value: const OsShareService()),
    ],
    child: const App(),
  ));
}
  • ViewModel の ChangeNotifier はコンストラクターでインターフェースを受け取り、グローバルには決してアクセスしません。 また、不変の状態に対する意図を表すメソッドを公開します。
final class ShareViewModel extends ChangeNotifier {
  ShareViewModel(this._share, this._clock);
  final ShareService _share;
  final Clock _clock;
  // intent method; keeps the single write path and the injected clock
}
  • テストでは、ウィジェットを Provider<Interface>.value(value: FakeShareService...()) でラップしてオーバーライドします(または、フェイクを ViewModel コンストラクターに直接渡します)。手書きのフェイクを使い、失敗経路も同様にカバーします。
  • 2 つ目のコンテナーとして get_it/injectable を混在させないでください。 DI の仕組みは 1 つにします。どちらの場合も、境界となるのはインターフェースです。

参考資料

コメント