Service boundary and native
SkillDev toolsWires 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.
Available today. Use it from your connected AI after setup.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the Service boundary and native skill
What this skill tells your AI
The instructions your AI receives, as published by zakariaf/flutter-skills in skills/service-boundary-and-native/SKILL.md and read by ahel’s review.
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_preferencestraps, native-fast-path independence, the round-tripintegration_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
- 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.
- 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. - Interface signatures reference value types only. Name a
DateTime, a domain value object, a sealed outcome — never a plugin type, never adart:iosymbol. WHY: keeps the interface nameable from any layer and the SDK confined to one class. - 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 nothrowsdeclaration, so an unhandled failure silently vanishes. Seeerror-handling-typed-resultsfor theResult/Failurespine. - The provider throws
UnimplementedErroruntil overridden — the provider IS the DI. Noget_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. - Wire exactly one live impl per interface at the composition root.
main(per flavor) is the only place a live SDK is constructed;app.dartand every widget below are wiring-blind. WHY: the composition root is the single seam where the real world is chosen. - 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(notextends) so a new interface method breaks the build. WHY: a passing test must reflect real behaviour, including the failure it exists to guard. - The clock is injected via
clockProvider(aClockfrompackage:clock) — "now" has exactly one source. NoDateTime.now()/math.Random()reachable from a view, Notifier, repository, or pure core; feature code readsref.read(clockProvider).now(), pure core takes aClockor the ambientclock. Never a bespokeClockService/SystemClock. WHY: this is what makes date/streak/expiry logic deterministic by overriding withClock.fixed(...). Seevalue-objects-money-and-unitsfor the injectedClock. - 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".
- 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. - 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
mainlive in the app package. In a single-package app all three are folders underlib/. Seeproject-structure-and-packages.
When multi-flavor: ship one thin
main_<flavor>.dartper flavor over a single flavor-blindapp.dart. Eachmainoverrides 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. Seereferences/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 theFutureinto aVoidCallback, so the Future and its error are dropped and no lint catches it. Route through avoid-returning handler thatunawaited(...)s with acatchError. Seereferences/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, nodart:iosymbol. - 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
UnimplementedErroruntil overridden; consumersref.watch/ref.readit; noget_it, no second container, no logic in the provider. - Exactly one live impl per interface is wired in
main(per flavor);app.dartimports no concrete SDK; multi-flavormains diff line-for-line. - A hand-written fake
implementsthe interface, honours the contract (failure path reachable), and is installed viaoverrideWithValue— no mock framework. - No
DateTime.now()/math.Random()is reachable from a view, Notifier, repository, or pure core; time enters only through the injectedClock(clockProvider), never a bespokeClockService. - 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.shis 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-riverpodfor Notifier/AsyncNotifier discipline and the watch/read/listen split that consumes these providers. - See
error-handling-typed-resultsfor the sealedResult/Failurespine every boundary method returns. - See
async-safetyfor the void-handler pattern that closes the arrow-callback Future-drop hole and themounted/BuildContextguards. - See
app-startup-and-bootstrapfor the composition-root ordering (error handlers, settings, DI overrides) that hosts these overrides. - See
project-structure-and-packagesfor where the interface, live impl, andmainsit in a single-package vs workspace layout. - See
dependency-hygienefor vetting and pinning the plugin behind each live impl. - See
testing-strategyfor fakes-over-mocks and the acceptance-gate posture. - See
value-objects-money-and-unitsfor the injectedClock(package:clock) and canonical value types these signatures name. - See
naming-conventionsfor theService(capability interface you define) vsGateway(wrapper over a specific plugin/SDK orMethodChannel) suffix rule. - See
ads-and-iap-monetizationfor 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-restorefor the share/file-picker Gateway and its fake, andrelease-and-store-shippingfor 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:providerat 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
ChangeNotifierreceives 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/injectablein as a second container. One DI mechanism; the interface is the seam either way.
References
- Flutter — Guide to app architecture and Architecture recommendations (dependency injection strongly recommended).
- Flutter — Build flavors (Android) and iOS build flavors.
- Flutter — Writing custom platform-specific code (MethodChannel).
- Riverpod — Testing your providers and What's new in Riverpod 3.0.
- pub.dev —
clock(theClock/withClocktime seam),provider,mocktail(one-off stubs only),home_widget(data bridge only).
Signals
- GitHub stars
- 36
- Forks
- 10
- Last commit
- Sep 2026
ahel review
K6low
bundled executables the agent is told to run
Automated review, not a security audit. Ruleset v1+k2.
Advanced
- Item type
- skill
- Key
service-boundary-and-native- Source
- github.com/zakariaf/flutter-skills