Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

flutter_clean_core

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.

Why this exists

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.

Layers

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
Loading

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.

ApiClient: single-flight 401 refresh

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
Loading

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.

Result<T> / AppFailure

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
Loading

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.

Secure storage: fail closed, never plaintext

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
Loading

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.

Request lifecycle

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 &lt;token&gt;"]
    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
Loading

Architecture fitness test

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/&lt;name&gt;/"]
        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
Loading

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.

RFC 8785 JSON canonicalization

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 hex

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

Install

dependencies:
  flutter_clean_core:
    git: https://github.com/Library-Core/flutter_clean_core.git

Usage

final 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));
    }
  }
}

What this is not

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.

License

MIT

About

Backend-agnostic Flutter Clean Architecture core: Result/AppFailure, UseCase, a Dio ApiClient with single-flight 401 refresh, and Supabase-backed secure storage.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages