flutter-sdk-contracts
Skill10 starsChanged 42 days agoMITView on GitHub
Use when adding, replacing or touching any third-party SDK or platform plugin in a Flutter app (Firebase, Sentry, PostHog, Dio, secure storage, shared_preferences, notifications, share, clipboard, permissions, purchases), or when feature code imports an SDK directly. Defines application-owned contracts (ports), adapters, no-op/composite/recording implementations and their Riverpod wiring.
In this repository5 other files it gives its agents.
- flutter-feature-scaffoldSkill
- flutter-result-errorsSkill
- flutter-riverpod3Skill
- flutter-testingSkill
- flutter-ui-side-effectsSkill
The file
We split this skill into the rules it gives an agent
9 rules. About 0 are common, 6 are distinctive, and 3 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈6
Distinctive · estimate
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 15
When only one adapter file imports the SDK, a migration touches one file.
- Distinctive: line 26
Only app-owned types in its signatures.
- Distinctive: line 54
Low-cardinality payloads: Never put tokens or emails in tags, properties or breadcrumbs.
- Very specific: line 56
Keep one capability per contract.
- Very specific: line 76
When the same event must reach several vendors, compose behind the same contract (assets/composite_analytics_service.dart).
- Very specific: line 95
Put the logger before the auth interceptor, so request headers are logged before the Authorization header is attached and tokens never hit …
- Distinctive: line 98
Put the breadcrumb/error-reporting interceptor last, so a 401 that the refresh interceptor is about to retry successfully reads as one line.
- Distinctive: line 100
Use a second Dio with no interceptors for the token-refresh call.
- Distinctive: line 107
Run from the project root.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
SDKs behind application-owned contracts
Every dependency that crosses the application boundary is wrapped behind a contract the application owns. Feature code depends on contracts, never on SDKs.
Why
- Replaceable. Vendors change (PostHog → Amplitude, Sentry → Crashlytics). When only one adapter file imports the SDK, a migration touches one file.
- Testable. Tests override a provider with a fake; no platform channel,
no
MissingPluginException, no SDK initialisation in widget tests. - Narrow. The contract exposes what the app needs (
track(event)), not the SDK's whole surface, so features cannot misuse it.
The recipe
For a capability Foo (analytics, error tracking, clipboard, storage…):
- Contract —
abstract interface class FooServiceincore/foo/. Only app-owned types in its signatures. Document why on the methods whose behaviour is surprising. - App-owned vocabulary — if the SDK takes free-form input, close it:
a
sealed class AnalyticsEventinstead oftrack(String, Map). - Adapter —
FirebaseFooService implements FooService. The only file that imports the SDK. It translates SDK errors intoAppFailure(seeflutter-result-errors) or swallows them if the contract says "never throws". - No-op —
NoOpFooServicefor tests and disabled builds. - Provider — expose the contract, never the adapter:
final fooServiceProvider = Provider<FooService>((ref) { final config = ref.watch(fooConfigProvider); return config.isEnabled ? SdkFooService(config) : const NoOpFooService(); }); - Test double — a recording fake in
test/helpers/(seeassets/recording_analytics_service.dart). Prefer recording fakes to mocks: assertions read as "this event was tracked".
Naming: <Capability>Service. Adapter: <Vendor><Capability>Service.
Contract design rules
- No SDK types in parameters, return types or thrown exceptions.
- Fallible →
AsyncResult<Unit>(or a typed value). Fire-and-forget reporting →Future<void>that never throws: reporting a failure must not fail the operation that caused it (ErrorTrackingService). - Low-cardinality payloads. Never put tokens or emails in tags, properties or breadcrumbs.
- Keep one capability per contract. Do not make an
AnalyticsAndCrashgod interface.
Config and wiring
- A required config provider throws until overridden at the composition
root, so a missing override fails loudly at startup:
final networkConfigProvider = Provider<NetworkConfig>( (ref) => throw UnimplementedError('override in bootstrap'), ); - An optional capability defaults to disabled (
AnalyticsConfig.disabled()) so widget and provider tests never initialise a platform SDK. Bootstrap overrides it for real runs. - Feature code never calls
Firebase…instance,Sentry.captureExceptionorDio(). Only adapters and provider files do.
Several destinations, one contract
When the same event must reach several vendors, compose behind the same
contract (assets/composite_analytics_service.dart). Rules: call every
destination even after one fails, and return the first failure so
partial delivery stays visible. The provider picks 0 / 1 / many with a
switch on the list length; features never learn the count.
The HTTP boundary (worked example)
assets/api_client.dart, dio_api_client.dart and the ApiException
hierarchy (from flutter-result-errors) are the reference implementation:
ApiClientspeaks decoded JSON maps and throws onlyApiExceptions.DioApiClientis the only file allowed to know Dio's API; it mapsDioExceptiontypes toApiConnectionException/ApiResponseException/ApiUnknownException.- Remote data sources depend on
ApiClientand run throughguard(...).
Interceptor order matters when you build the Dio in a provider:
- Put the logger before the auth interceptor, so request headers are
logged before the
Authorizationheader is attached and tokens never hit the console. Enable it in debug builds only. - Put the breadcrumb/error-reporting interceptor last, so a 401 that the refresh interceptor is about to retry successfully reads as one line.
- Use a second Dio with no interceptors for the token-refresh call. Refreshing through the main client re-enters the refresh interceptor, and since it is queued the nested call waits behind the very request it should unblock.
Verifying the boundary
Run from the project root; each should print nothing:
# SDKs must not be imported by feature code
grep -rnE "package:(dio|firebase_[a-z_]+|sentry[a-z_]*|posthog_flutter|purchases_flutter|shared_preferences|flutter_secure_storage)/" lib/features
# Static singletons must not be reached from features
grep -rnE "(Firebase[A-Za-z]*\.instance|Sentry\.capture|SharedPreferences\.getInstance|Dio\()" lib/featuresAdapters under lib/core/ and the provider files that construct them are
the only legitimate importers. Tune the package list to your pubspec.
Review checklist
- New SDK import appears in exactly one adapter file
- Contract signatures contain no SDK types
- A
NoOpand a recording fake exist - Provider returns the contract type (
Provider<FooService>), not the adapter - Optional capability defaults to disabled; required config throws until overridden
- Nothing sensitive goes into tags, properties or breadcrumbs
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

