skillZs
★ LIVE SKILL TAGS ★
>>> LIVE SKILLS INDEX <<<
* OPEN SOURCE *
NO LOGIN, NO TRACKING
※ REAL INSTALL DATA ※
← back to all skills
zakariaf/flutter-skills94 installs

service-boundary-and-native

Wires every side effect and native channel as an injectable interface behind a Provider that throws UnimplementedError until the composition root overrides it, with one live impl per flavor, value-typed signatures returning typed results, hand-written contract-honouring fakes over mocks, MethodChannel quarantined to one lib/native/ directory, and versioned cross-language contracts edited on both sides in one commit. Use when adding or changing a ShareService/AnalyticsService/RemoteConfigService or any side-effect port, injecting a Clock (package:clock) via clockProvider, a MethodChannel/platform channel or native widget bridge, a flavor entrypoint (main_*.dart) or per-flavor provider override, a shared-file/JSON contract mirrored in Kotlin/Swift, replacing a stray DateTime.now() or direct SDK call inside a widget/notifier/repository, or wiring a fake via ProviderScope(overrides:) in tests.

How do I install this agent skill?

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

Is this agent skill safe to install?

  • Gen Agent Trust Hubpass

    This skill provides architectural guidelines and local static analysis scripts for Flutter and Dart development. It focuses on enforcing service boundaries, dependency injection patterns, and native platform channel isolation. No malicious patterns or security risks were detected.

  • Socketpass

    No alerts

  • Snykpass

    Risk: LOW · No issues

What does this agent skill do?

Service boundary and native

Every platform capability — reading "now", sharing, logging an event, fetching remote flags, calling a MethodChannel — crosses the app's layers as an injected interface. The interface names value types only; the concrete SDK lives in exactly one class; the composition root (main) wires one live impl; tests wire a deterministic fake. Abstract exactly what cannot run in a test — nothing more. This is the seam that keeps features pure, SDKs swappable, and the whole app testable headlessly.

Read the reference for the task at hand:

  • references/service-interface.md — typed outcomes vs bool/throw, exhaustiveness, @useResult, hand-written env-fakes, the arrow-callback Future-drop hole, single write path.
  • references/native-channels.md — MethodChannel placement, the cross-language contract ownership table, versioned-file-vs-shared_preferences traps, native-fast-path independence, the round-trip integration_test.
  • references/multi-flavor.md — line-for-line composition roots, build flavors over runtime detection, per-flavor plugin pins, the banned-dependency CI graph gate.

Run scripts/check-service-boundaries.sh and scripts/check-flavor-graph.sh before a PR.

Non-negotiable rules

  1. Every side effect is an injected interface, never a concrete SDK at the call site. Features, Notifiers, and repositories depend on the interface; the app imports the real SDK in exactly one live-impl class. WHY: one seam to swap, one place to quarantine platform quirks, zero SDK reach into pure code.
  2. Abstract only what cannot run in flutter test. One interface + one real impl + one fake = justified. One interface + one impl + no fake = delete the interface. WHY: interfaces "for symmetry" are dead weight that hide where the real risk (untestable I/O) is.
  3. Interface signatures reference value types only. Name a DateTime, a domain value object, a sealed outcome — never a plugin type, never a dart:io symbol. WHY: keeps the interface nameable from any layer and the SDK confined to one class.
  4. Every method returns a typed result — failure is control flow, not a swallowed exception. Model a cancel, an empty fetch, an unavailable target as a sealed outcome or Result<T, F extends Failure>, never a thrown-and-forgotten exception. WHY: Dart requires no throws declaration, so an unhandled failure silently vanishes. See error-handling-typed-results for the Result/Failure spine.
  5. The provider throws UnimplementedError until overridden — the provider IS the DI. No get_it, no service locator, no second container, no business logic in the provider body. WHY: a forgotten wiring becomes a loud startup failure instead of silent null data or a live SDK opening at import.
  6. Wire exactly one live impl per interface at the composition root. main (per flavor) is the only place a live SDK is constructed; app.dart and every widget below are wiring-blind. WHY: the composition root is the single seam where the real world is chosen.
  7. Tests install a hand-written fake that honours the contract — fakes over mocks. The fake's failure path must be reachable; a fake that always succeeds is a happy-path lie. implements (not extends) so a new interface method breaks the build. WHY: a passing test must reflect real behaviour, including the failure it exists to guard.
  8. The clock is injected via clockProvider (a Clock from package:clock) — "now" has exactly one source. No DateTime.now() / math.Random() reachable from a view, Notifier, repository, or pure core; feature code reads ref.read(clockProvider).now(), pure core takes a Clock or the ambient clock. Never a bespoke ClockService/SystemClock. WHY: this is what makes date/streak/expiry logic deterministic by overriding with Clock.fixed(...). See value-objects-money-and-units for the injected Clock.
  9. A mutating service feeds the single write path — persist before publish. A service that changes durable state is consumed by a repository method that commits (one transaction) before any state republishes. WHY: a crash mid-flow must never leave "acknowledged but not durable".
  10. Every MethodChannel lives under one lib/native/ directory; nothing else creates one. A channel born inside a widget, repository, or Notifier is a defect regardless of how well it works. WHY: platform channels are the least testable code in the app and must be found in one place.
  11. A cross-language shared format is a versioned contract with one owner per side, edited on both sides in the same commit, and proven by an integration_test. WHY: no compiler, analyzer, or unit test catches a renamed key across the language boundary — only a real round-trip does.

The interface and its provider (Riverpod DI)

The interface is small, value-typed, and returns a typed outcome. The provider is a thin throwing wire.

// 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'),
);

The canonical smallest boundary is time — read everywhere, DateTime.now() nowhere. The one time type is Clock from package:clock, injected via clockProvider. Unlike an SDK-backed port, the clock provider safely defaults to the real thing (const Clock() opens no I/O and reaches no SDK), so it needs no override to run; tests override it with Clock.fixed(...). Do NOT define a bespoke 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());

One live impl at the composition root

main is the only place a live SDK is constructed. The concrete SDK import appears only inside the live-impl class.

// 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(),
  ));
}

When multi-package (workspace): the interface lives in a shared foundation package; the live impl and main live in the app package. In a single-package app all three are folders under lib/. See project-structure-and-packages.

When multi-flavor: ship one thin main_<flavor>.dart per flavor over a single flavor-blind app.dart. Each main overrides the same provider list in the same order — only the impl on each right-hand side changes — so the two files diff line-for-line. Real build flavors (Android product flavors + iOS schemes), never runtime detection. See references/multi-flavor.md.

Fakes over mocks

A hand-written fake that implements the interface and models the ways the world breaks. No mock framework; no codegen.

// 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
  }
}

The clock needs no hand-written fake — package:clock supplies Clock.fixed(...) (and withClock for pure code):

// 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>());
});

The native boundary

A MethodChannel is quarantined to lib/native/, wrapped by a thin Gateway, and exposed through the same injectable-interface seam. (A Gateway names a thin wrapper over a specific plugin/SDK or a MethodChannel; a Service names a capability interface you define — see naming-conventions.) Keep the native side able to run without the Dart engine alive where latency demands it.

// 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(),
    };

Every write path that changes a mirrored value republishes as part of the write, never as a follow-up someone remembers — a delete/clear republishes exactly as hard as a save. See references/native-channels.md for the ownership table, the shared_preferences traps, and the round-trip test that proves the mirror.

Anti-patterns

  • Calling a plugin/SDK from a feature, Notifier, or repository. The real SDK is imported only inside its live impl — never in a widget, controller, or repository body.
  • DateTime.now() / math.Random() reachable from a view, Notifier, repository, or pure core. "Now" has one source, the injected clock; anything else defeats deterministic time tests.
  • A provider that defaults to a live service instead of throwing — it hides a missing override and can open real I/O at import.
  • Reaching for a mock framework to fake a boundary, or a fake that always succeeds so the failure path is never exercised.
  • onTap: () => service.doThing() — the arrow closure returns the Future into a VoidCallback, so the Future and its error are dropped and no lint catches it. Route through a void-returning handler that unawaited(...)s with a catchError. See references/service-interface.md.
  • A MethodChannel created outside lib/native/, or renaming a contract key on one side of the language boundary without the other in the same commit.
  • Business logic inside the provider, or a second DI container (get_it, package:provider) alongside Riverpod.
  • User-facing copy inside a service. The boundary returns a typed failure with a stable code; the feature layer maps it to localized copy.
  • A #if FLAVOR / String.fromEnvironment('FLAVOR') fork inside feature code, or a binary that detects its ecosystem and swaps SDKs at runtime — flavors diverge only at the composition root.

Definition of done

  • The boundary is an abstract interface class; its signatures reference value types only — no plugin type, no dart:io symbol.
  • The interface exists only because it cannot run in a test (has a real impl AND a fake); no interface added "for symmetry".
  • Every method returns a typed result (sealed outcome / Result<T, F extends Failure>); cancel/unavailable/empty are control flow, not thrown exceptions; a failure carries a stable code, never a localized string.
  • The provider throws UnimplementedError until overridden; consumers ref.watch/ref.read it; no get_it, no second container, no logic in the provider.
  • Exactly one live impl per interface is wired in main (per flavor); app.dart imports no concrete SDK; multi-flavor mains diff line-for-line.
  • A hand-written fake implements the interface, honours the contract (failure path reachable), and is installed via overrideWithValue — no mock framework.
  • No DateTime.now() / math.Random() is reachable from a view, Notifier, repository, or pure core; time enters only through the injected Clock (clockProvider), never a bespoke ClockService.
  • A mutating service feeds the single write path — persist before publish.
  • Every MethodChannel lives under lib/native/; the live impl is the only SDK importer; scripts/check-service-boundaries.sh is green.
  • Any cross-language shared format is versioned, owned once per side, edited on both sides in the same commit, and covered by a round-trip integration_test.
  • If flavors exist, the banned-dependency CI graph gate (scripts/check-flavor-graph.sh) proves each flavor links none of its forbidden SDKs.

Related skills

  • See state-management-riverpod for Notifier/AsyncNotifier discipline and the watch/read/listen split that consumes these providers.
  • See error-handling-typed-results for the sealed Result/Failure spine every boundary method returns.
  • See async-safety for the void-handler pattern that closes the arrow-callback Future-drop hole and the mounted/BuildContext guards.
  • See app-startup-and-bootstrap for the composition-root ordering (error handlers, settings, DI overrides) that hosts these overrides.
  • See project-structure-and-packages for where the interface, live impl, and main sit in a single-package vs workspace layout.
  • See dependency-hygiene for vetting and pinning the plugin behind each live impl.
  • See testing-strategy for fakes-over-mocks and the acceptance-gate posture.
  • See value-objects-money-and-units for the injected Clock (package:clock) and canonical value types these signatures name.
  • See naming-conventions for the Service (capability interface you define) vs Gateway (wrapper over a specific plugin/SDK or MethodChannel) suffix rule.
  • See ads-and-iap-monetization for the policy that rides on the ads/billing boundaries — including why the entitlement must be restored before the ad SDK is initialized.
  • See data-export-and-restore for the share/file-picker Gateway and its fake, and release-and-store-shipping for the permissions and store declarations each new plugin brings with it.

Provider / ChangeNotifier appendix

The same rules hold with the official Flutter-guide stack; only the wiring mechanism changes. Rules 1–4, 7–11 are identical. The interface and typed outcome are unchanged.

  • DI is package:provider at the composition root, injecting the interface. There is no throw-until-overridden idiom, so provide the real impl at the root and never construct an SDK below it:
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(),
  ));
}
  • A ViewModel ChangeNotifier receives the interface by constructor, never reaches a global, and exposes intent methods over immutable state:
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
}
  • Tests override by wrapping the widget in a Provider<Interface>.value(value: FakeShareService...()) (or passing the fake straight into the ViewModel constructor). Same hand-written fake, same failure-path coverage.
  • Do not mix get_it/injectable in as a second container. One DI mechanism; the interface is the seam either way.

References

Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.

<a href="https://skillzs.dev/skills/zakariaf/flutter-skills/service-boundary-and-native">View service-boundary-and-native on skillZs</a>