Backend-agnostic building blocks for a Flutter app built on Clean
Architecture: a Result/AppFailure pair, a UseCase contract, a Dio
ApiClient with single-flight 401 refresh, log redaction, and
Supabase-backed secure storage for sessions and API keys.
None of this carries product logic — no features, no screens, no domain rules. It's the plumbing every layer above it depends on but that has nothing to do with what the app actually does.
Most Flutter apps re-solve the same handful of problems before they write
a single feature: how do use cases return errors without throwing, how does
the HTTP client refresh an expired token without stampeding the auth
server when five requests fail at once, and where does a session token
live so it never ends up in SharedPreferences or a log line by accident.
This package is that layer, extracted so it doesn't have to be rebuilt
per project.
flowchart TB
subgraph Foundation["errors / result / usecase"]
AppFailure["AppFailure\n(sealed)"]
Result["Result<T>\nOk / Err"]
UseCase["UseCase<Output, Input>"]
end
subgraph Network["network"]
ApiClient["ApiClient\n(Dio wrapper)"]
AccessTokenProvider["AccessTokenProvider"]
ApiErrorMapper["ApiErrorMapper"]
IdempotencyKey["IdempotencyKey"]
end
subgraph Storage["storage"]
SecureAuthStorage["SecureAuthStorage /\nSecurePkceStorage"]
SecureApiKeyRepository["SecureApiKeyRepository"]
SeenFlagStorage["SeenFlagStorage"]
end
subgraph Logging["logging"]
AppLogger["AppLogger"]
LogRedactor["LogRedactor"]
end
ApiErrorMapper --> AppFailure
UseCase --> Result
Result --> AppFailure
ApiClient --> AccessTokenProvider
AppLogger --> LogRedactor
ApiClient -.uses.-> AppLogger
Every subgraph above is independently importable — pull in only what you
need. errors/result/usecase have zero third-party dependencies.
network depends on dio. storage's Supabase-facing pieces depend on
supabase_flutter and flutter_secure_storage; SeenFlagStorage depends
only on shared_preferences.
The interceptor doesn't tear the session down on the first 401. It checks
whether another concurrent request already refreshed the token, joins an
in-flight refresh if one is already running, and only calls
onUnauthorized() once even if several requests fail at the same moment.
sequenceDiagram
participant A as Request A
participant B as Request B
participant Client as ApiClient
participant Provider as AccessTokenProvider
participant Server
A->>Client: GET /resource (token=T1)
B->>Client: GET /other (token=T1)
Client->>Server: A: Authorization Bearer T1
Client->>Server: B: Authorization Bearer T1
Server-->>Client: A: 401
Server-->>Client: B: 401
Client->>Provider: currentAccessToken()
Note over Client,Provider: still T1 -> no other refresh happened yet
Client->>Provider: refresh() [single-flight, called once]
Provider-->>Client: T2
Client->>Server: A retry: Authorization Bearer T2
Client->>Server: B retry: Authorization Bearer T2 (joins the same refresh)
Server-->>Client: A: 200
Server-->>Client: B: 200
If refresh itself fails, or the retried request is still 401,
onUnauthorized() fires exactly once regardless of how many requests were
in flight — the second caller finds the first Future already pending and
awaits it instead of starting a second sign-out.
Use cases return Result<T> instead of throwing, so a failure to reach the
server, an expired session, and a 500 from the API are all typed instead of
being three different exception classes a caller has to catch separately.
classDiagram
class Result~T~ {
<<sealed>>
}
class Ok~T~ {
+T value
}
class Err~T~ {
+AppFailure failure
}
class AppFailure {
<<sealed>>
+String message
}
class NetworkFailure
class UnauthorizedFailure
class ForbiddenFailure
class ServerFailure {
+int? statusCode
}
class UnknownFailure
Result~T~ <|-- Ok~T~
Result~T~ <|-- Err~T~
Err~T~ --> AppFailure
AppFailure <|-- NetworkFailure
AppFailure <|-- UnauthorizedFailure
AppFailure <|-- ForbiddenFailure
AppFailure <|-- ServerFailure
AppFailure <|-- UnknownFailure
class UseCase~Output,Input~ {
<<abstract>>
+call(Input) Future~Result~Output~~
}
UseCase~Output,Input~ ..> Result~T~ : returns
A Dart switch on the sealed Result/AppFailure hierarchy is exhaustive
— the analyzer flags a missing case instead of it surfacing as a runtime
bug.
Sessions, PKCE verifiers, and user-supplied API keys all go through the
platform Keychain/Keystore. If secure storage is unreachable, these throw
instead of silently degrading to SharedPreferences or a plaintext file —
a login should fail closed, not succeed into an insecure state.
flowchart LR
FSS["FlutterSecureStorage\n(Keychain / Keystore)"]
SecureAuthStorage["SecureAuthStorage\nimplements Supabase LocalStorage"]
SecurePkceStorage["SecurePkceStorage\nimplements Supabase GotrueAsyncStorage"]
SecureApiKeyRepository["SecureApiKeyRepository\n(any BYOK provider key)"]
SeenFlagStorage["SeenFlagStorage\n(SharedPreferences — non-sensitive)"]
SecureAuthStorage --> FSS
SecurePkceStorage --> FSS
SecureApiKeyRepository --> FSS
SeenFlagStorage -.-> SharedPreferences[("SharedPreferences")]
SupabaseClient["Supabase.initialize()"] -->|localStorage| SecureAuthStorage
SupabaseClient -->|pkceAsyncStorage| SecurePkceStorage
SecureAuthStorage and SecurePkceStorage both take a keyPrefix — pass
one prefix per build flavor/environment so dev, staging, and prod builds
installed on the same device never share a session.
flowchart TD
Start(["Request fired"]) --> HasRetryFlag{"extra['retried']\n== true?"}
HasRetryFlag -- yes --> SendAsIs["Send with existing\nAuthorization header"]
HasRetryFlag -- no --> FetchToken["tokenProvider.currentAccessToken()"]
FetchToken --> AttachHeader["Attach Authorization: Bearer <token>"]
AttachHeader --> SendAsIs
SendAsIs --> Response{"Response status"}
Response -- "not 401, or already retried" --> Done(["Return to caller"])
Response -- "401 and first attempt" --> CompareToken{"latest token !=\ntoken this request used?"}
CompareToken -- yes --> RetryLatest["Retry with the latest token\n(someone else already refreshed)"]
CompareToken -- no --> Refresh["refresh() — single-flight"]
Refresh -- "got new token" --> RetryLatest
Refresh -- "null" --> Unauthorized["onUnauthorized()\n(single-flight)"]
RetryLatest --> RetryResponse{"Retry status"}
RetryResponse -- "still 401" --> Unauthorized
RetryResponse -- "else" --> Done
Unauthorized --> Done
templates/import_boundary_test.dart enforces Clean Architecture layer rules by statically
scanning your source — a violation fails the test, so it doesn't depend on every reviewer
catching it by hand. Copy it into your project's test/architecture/ directory; it auto-detects
your package name from pubspec.yaml, so it needs no configuration.
flowchart TB
App["app/\n(composition root)"]
Core["core/"]
subgraph Feature["features/<name>/"]
direction TB
Presentation["presentation/"]
Domain["domain/\n(pure Dart only)"]
Data["data/"]
end
App --> Feature
App --> Core
Presentation -->|usecase call, not direct import| Domain
Data --> Domain
Presentation -.->|"✗ forbidden"| Data
Domain -.->|"✗ forbidden"| Presentation
Domain -.->|"✗ forbidden"| Data
Core -.->|"✗ forbidden"| Feature
Feature -.->|"✗ forbidden"| App
It resolves import targets to real file paths rather than matching strings, so a relative import
(../../data/x.dart) or a barrel file that re-exports a forbidden symbol is still caught — it
builds an export graph across lib/ and follows barrel chains recursively before deciding
whether a target is actually reachable.
canonicalizeJson/namedHash are a pure-Dart port of
rfc8785-jcs (Rust) — hashing a JSON value is
only meaningful if semantically-equal values always produce the same bytes first, regardless of
object key insertion order:
final a = {'b': 1, 'a': 2};
final b = {'a': 2, 'b': 1};
assert(canonicalizeJson(a) == canonicalizeJson(b));
final hash = namedHash('example', 1, {'hello': 'world'}); // 64-char lowercase hexLives in hashing/, not network/ or storage/ — it has no dependency on the rest of this
package and only needs crypto, so it's usable from a pure-Dart domain layer that forbids
Flutter imports.
dependencies:
flutter_clean_core:
git: https://github.com/Library-Core/flutter_clean_core.gitfinal tokenProvider = SupabaseAccessTokenProvider(Supabase.instance.client);
final apiClient = ApiClient(
baseUrl: 'https://api.example.com',
tokenProvider: tokenProvider,
);
await Supabase.initialize(
url: supabaseUrl,
anonKey: supabaseAnonKey,
authOptions: FlutterAuthClientOptions(
// Both are required — omitting pkceAsyncStorage makes Supabase fall
// back to SharedPreferences silently.
localStorage: SecureAuthStorage(keyPrefix: 'myapp.prod'),
pkceAsyncStorage: SecurePkceStorage(keyPrefix: 'myapp.prod'),
),
);
class FetchProfile extends UseCase<Profile, NoParams> {
FetchProfile(this._client);
final ApiClient _client;
@override
Future<Result<Profile>> call(NoParams _) async {
try {
final response = await _client.dio.get('/profile');
return Ok(Profile.fromJson(response.data));
} on DioException catch (e) {
return Err(ApiErrorMapper().map(e));
}
}
}There's no dependency injection framework, no HTTP retry/backoff policy beyond the single 401 refresh, and no bundled state-management glue (Riverpod/Bloc/Provider) — wire this into whatever you're already using. It's intentionally small enough to read end to end in one sitting.
MIT