Service Boundary And Native logo

Service Boundary And Native

Community
zakariaf
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.

Overview

Publisherzakariaf
RepositoryFlutter-Skills
Skill nameservice-boundary-and-native
Stars
64
Forks
15
Bundled files
7
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 7 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by zakariaf on GitHub. Read the source before you install it.

Installation

Install the Service Boundary And Native AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/zakariaf/Flutter-Skills/tree/main/skills/service-boundary-and-native
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/zakariaf/Flutter-Skills.git /tmp/Flutter-Skills
mkdir -p .claude/skills
cp -r /tmp/Flutter-Skills/skills/service-boundary-and-native .claude/skills/service-boundary-and-native
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Service Boundary And Native in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Service Boundary And Native on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Service Boundary And Native is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

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.

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

dart
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.

dart
// 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.

dart
// 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):

dart
// 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.

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

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:
dart
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:
dart
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

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Service Boundary And Native AI skill do?

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/pla...

Why use Service Boundary And Native on TypingMind?

Because you install it once and use it with any model. Service Boundary And Native is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Service Boundary And Native in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/zakariaf/Flutter-Skills/tree/main/skills/service-boundary-and-native. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Service Boundary And Native?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Service Boundary And Native?

As many as you like. As long as a model supports skills, you can use Service Boundary And Native with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Service Boundary And Native AI skill free?

Yes. It is published on GitHub by zakariaf under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇