Skip to content

๐Ÿ—๏ธ New Architecture Guide: Clean Architecture & Riverpod

Welcome to the architectural standard for the Perci Platform Flutter apps.

We are migrating from the FlutterFlow-exported legacy structure to a Feature-Based Clean Architecture using Riverpod for state management. This guide is the tutorial; the rulebook is flutter-development-standards.md โ€” read both, and when they disagree the standards doc wins. For the phased plan to migrate the whole codebase, see refactoring-roadmap.md.

The reference features this guide points at are the member Documents feature (apps/perci-platform-members/lib/features/documents) and, for the typed API client, the clinician Payments feature (apps/perci-platform-clinicians/lib/features/payments).


๐ŸŒŸ Why the change?

Old Way (Legacy) New Way (Clean Architecture)
Logic mixed inside UI Widgets Separation of Concerns: Logic, Data, and UI are separate
Hard to test (needs UI harness) Testable: Business logic tested with pure Dart unit tests
Global FFAppState singleton Scoped State: Features manage their own state via Providers
Direct API calls in Widgets Repositories: Centralized, mockable data access

1. Core Concepts

๐Ÿง… Clean Architecture Layers

Every feature is divided into three layers. Dependencies point inward: presentation depends on domain, data depends on domain, and domain depends on nothing.

  1. Domain Layer (the "What"): Pure business logic. Entities, abstract Repository contracts, and Use Cases. Knows nothing about Flutter, Dio, or JSON.
  2. Data Layer (the "How"): Models/DTOs (JSON), Data Sources (API), and Repository implementations.
  3. Presentation Layer (the "Show"): Pages, Widgets, and Riverpod Providers.

๐Ÿ’ง Riverpod (state management)

Riverpod is our Dependency Injection and state-management solution.

  • Providers replace ChangeNotifier/GetIt. They hold state or wire dependencies.
  • ConsumerWidget replaces StatelessWidget and gives you a WidgetRef (ref).
  • Code Generation: we use @riverpod annotations; the boilerplate is generated into *.g.dart files.

2. File-by-file walkthrough (the Documents feature)

Where the files live. Reusable, cross-app pieces (the Document entity, DocumentModel, DocumentBytesResult value object) live in the shared package (perci_library_9rk85z/features/documents/โ€ฆ). Each app owns its repository contract, repository implementation, data source, use cases, providers, and UI. See ยง9 App vs shared package for the rule.

๐Ÿ“ฑ Presentation Layer (UI & state)

1. presentation/pages/documents_page.dart โ€” the screen. A ConsumerWidget:

final documentsAsync = ref.watch(documentsProvider);

return documentsAsync.when(
  data: (docs) => DocumentsTable(documents: docs),
  loading: () => const CircularProgressIndicator(),
  error: (err, _) => ErrorView(err),
);

It also exposes its route as static fields (see ยง8 Navigation):

static String routeName = 'DocumentsPage';
static String routePath = '/documentspage';

2. presentation/widgets/document_row.dart โ€” a small ConsumerWidget that triggers an action on tap via ref.read(...) without rebuilding.

3. presentation/providers/member_documents_provider.dart โ€” the wiring. Functional @riverpod providers do the DI (data source โ†’ repository โ†’ use cases); a page-scoped notifier owns page state (see ยง4 Controllers):

@riverpod
MemberDocumentRemoteDataSource memberDocumentRemoteDataSource(Ref ref) =>
    MemberDocumentRemoteDataSource(ref.watch(apiClientProvider));

@riverpod
MemberDocumentRepository memberDocumentRepository(Ref ref) =>
    MemberDocumentRepositoryImpl(ref.watch(memberDocumentRemoteDataSourceProvider));

@riverpod
GetDocuments getVisibleDocuments(Ref ref) =>
    GetDocuments(ref.watch(memberDocumentRepositoryProvider));

๐Ÿง  Domain Layer (business logic)

4. domain/entities/document.dart (in the shared package) โ€” a plain value class, no fromJson. See ยง7 Equality for how we give entities value equality.

5. domain/repositories/member_document_repository.dart โ€” an abstract class contract. It declares what the app needs (getDocuments(), uploadDocument(...)) but not how. This is what tests mock.

6. domain/usecases/get_documents.dart โ€” one atomic action per file (note the file is named after the class, get_documents.dart, not ..._usecase.dart). The UI calls the use case; the use case calls the repository. Complex features may also have a domain/services/ folder for pure orchestration logic (Documents has domain/services/document_viewer/).

๐Ÿ’พ Data Layer (APIs & serialization)

7. data/models/document_model.dart (shared package) โ€” the DTO. Holds fromJson and a toEntity() mapper. Keep DTOs in the data layer and map to entities at the repository boundary โ€” domain code never sees a DTO.

8. data/datasources/member_document_remote_datasource.dart โ€” the low-level API caller. See ยง3 API access โ€” prefer the generated typed client; the raw ApiClient (Dio) that Documents currently uses is the fallback.

9. data/repositories/member_document_repository_impl.dart โ€” implements the domain contract, coordinating DataSource โ†’ Model โ†’ toEntity():

@override
Future<List<Document>> getDocuments() async {
  final models = await remoteDataSource.getDocuments();
  return models.map((m) => m.toEntity()).toList();
}

3. API access: prefer the generated typed client

Always prefer the generated, typed OpenAPI client over hand-written ApiClient calls with string paths. The BFF exposes a generated Retrofit-style client (e.g. TypedBffClinicalClient, with sub-clients like clinicians, payments) plus typed request/response models under lib/backend/openapi/. It is regenerated from the BFF OpenAPI spec by swagger_parser (see ยง5 Build).

A RemoteDataSource injects the typed client and calls its typed methods โ€” the clinician Payments feature is the reference:

class PaymentsRemoteDataSourceImpl implements PaymentsRemoteDataSource {
  const PaymentsRemoteDataSourceImpl(this._client);
  final TypedBffClinicalClient _client;

  @override
  Future<PaymentsResult> getPaymentsAppointments({ ... }) async {
    try {
      final response = await _client.payments.getPayments( ... );
      // map typed response models -> domain entities
    } on DioException catch (e) {
      throw _serverExceptionOrRethrow(e); // -> ServerException, see ยง6
    }
  }
}

Only fall back to the raw ApiClient when an endpoint genuinely cannot be expressed through the generated client (as the older Documents data source still does). If an endpoint is missing from the client, regenerate it rather than hand-rolling the path.

The ApiClient/auth_interceptor core (Dio wrapper, x-session-token injection, token refresh) lives in the shared package under core/network/.


4. Actions & Controllers (complex interactions)

For simple reads, use a FutureProvider. For user actions (submit, delete, upload), use a Class-Based Provider (@riverpod class ...) as a controller.

Prefer one page-scoped notifier over a notifier per action. A single notifier that owns the page's state and exposes actions as methods has one predictable lifecycle tied to the page. Per-action notifiers autodispose the moment a widget stops listening, lose state mid-flow, and push you toward keepAlive workarounds that cause stale-state and disposal-timing bugs. (This is the rule in flutter-development-standards.md โ€” do not split each action into its own notifier.)

@riverpod
class DocumentsController extends _$DocumentsController {
  @override
  FutureOr<void> build() {}

  Future<void> upload(String filePath) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      await ref.read(uploadDocumentProvider).call(filePath);
      ref.invalidate(documentsProvider); // refresh the list
    });
  }
}

Guard async gaps: after every await inside a State/ConsumerState, add if (!mounted) return; before touching setState, ref, or callbacks.


5. The build command

We generate Riverpod, Freezed, JSON, and the typed API client. Run codegen through melos from the repo root โ€” the root pubspec.yaml build_runner script runs swagger_parser first (so the API client is regenerated) and then build_runner with --order-dependents across every package:

melos build_runner

Do not use the old fvm flutter pub run build_runner ... incantation: flutter pub run is deprecated, and running build_runner directly in one app skips the API-client regeneration and the cross-package ordering. Run it whenever you change a file with @riverpod, @freezed, or a part '...g.dart';/part '...freezed.dart'; directive.


6. Error handling conventions

A single exception hierarchy lives in the shared package (core/exceptions/exceptions.dart): CustomException (base) with ServerException, NetworkException, LocalException, ValidationException.

  • Data sources catch transport errors and translate them into these typed exceptions. Catch DioException and throw a ServerException (preserving the server's message where available) rather than letting a raw DioException leak upward.
  • Domain-specific failures get their own exception types (e.g. EmailAlreadyInUseException, InvalidEmailException in the member code_signup feature) thrown from the data/repository layer so the UI can branch on them.
  • Repositories and use cases let these typed exceptions propagate; they do not swallow them.
  • Presentation catches them at the boundary โ€” wrap controller actions in AsyncValue.guard and render asyncValue.when(error: ...). Never catch (_) silently.

Do not invent a per-feature Result/Either type โ€” we use typed exceptions surfaced through AsyncValue.


7. Entities & DTOs: equality conventions

Value types need value equality, or Riverpod rebuilds and test assertions behave unexpectedly. The apps ship both freezed and equatable:

  • DTOs and entities that need JSON: use freezed with @JsonSerializable. Freezed generates ==/hashCode, copyWith, and (with the JSON annotation) fromJson/toJson. This is the member code_signup pattern (signup_session.dart).
  • Plain domain entities without JSON: freezed is still fine; Equatable is acceptable for a simple hand-written class.
  • Never hand-roll ==/hashCode, and never leave a value type with default identity equality.

Whichever you choose, entities stay JSON-free where possible โ€” the DTO in the data layer owns serialization and maps to the entity via toEntity().


8. Navigation: registering a route

Routing today still runs through flutter_flow/nav/nav.dart (a GoRouter with FFRoute wrappers). To add a screen:

  1. Expose the route on the page as static fields:
    static String routeName = 'DocumentsPage';
    static String routePath = '/documentspage';
    
  2. Register it in nav.dart, gating auth and reading params:
    FFRoute(
      name: DocumentsPage.routeName,
      path: DocumentsPage.routePath,
      requireAuth: true,
      builder: (context, params) => DocumentsPage(
        actionId: params.getParam('actionId', ParamType.String),
      ),
    ),
    

Structure routes for the web, not just mobile:

  • Use ShellRoute for shared chrome. When a group of pages shares a scaffold (bottom nav, side rail, app bar), wrap them in a ShellRoute that owns the chrome once, instead of every page rebuilding it. Cuts per-page duplication and keeps the chrome's state (selected tab, scroll position) alive across child navigation.
  • Nest routes; avoid new top-level paths. Almost everything today is a flat top-level route (/personalDetails), which breaks navigation-state restoration on web refresh โ€” the router cannot rebuild the parent stack from a flat URL, so refresh/back strand the user. Register a page as a child of its logical parent so the URL encodes the stack: /personalDetails โ†’ /about-you/your-details/personal-details. Refresh then restores the full hierarchy, and up-navigation falls out of the route tree instead of hand-rolled context.pop() chains.

Direction of travel. Phase 5 of the refactoring roadmap replaces FFRoute/nav.dart with typed, feature-owned routes, provider-based guards, ShellRoute chrome, and a nested route tree (with redirects from the legacy flat paths). Keep new routes thin so they are cheap to migrate, and prefer typed param objects over stringly-typed queryParameters.


9. Where does a feature slice live? App vs shared package

The goal is one shared package and thin apps. Decide placement per file, not per feature:

  • Shared package (perci_library_9rk85z): anything both apps use unchanged โ€” entities, DTOs, value objects, core/ infrastructure (network, exceptions, auth session), and the design system. Documents keeps its Document entity, DocumentModel, and DocumentBytesResult here.
  • App: app-specific composition โ€” the repository contract and implementation, the data source (member vs clinician BFF differ), use cases, providers, pages, and widgets.
  • Rule of thumb: if members and clinicians would need byte-identical code, it belongs in the shared package; if they diverge (endpoints, copy, layout), keep the slice in the app and share only the pure pieces underneath.

10. Testing

This architecture makes testing straightforward:

  • Unit tests: test a use case by mocking its repository; test a repository by mocking its data source. No app, no network.
  • Widget tests: override the feature's providers in a ProviderScope to inject fake loading/error/data states:
    ProviderScope(
      overrides: [documentsProvider.overrideWith((ref) => Future.value(fakeDocs))],
      child: const DocumentsPage(),
    )
    
  • Shared harness: use perci_platform_test_shared for Firebase/analytics fakes, channel mocks, network-image overrides, and the golden harness base โ€” do not re-mock these per test file.
  • Golden tests must start with the @Tags(['golden']) directive (then library;) before any imports, and run under flutter test <path> --tags golden.

11. Cheat sheet

  • ref.watch(provider): inside build() โ€” rebuilds when data changes.
  • ref.read(provider): inside callbacks (onTap) โ€” reads once.
  • AsyncValue: returned by FutureProviders; has .when(data:, loading:, error:).
  • ConsumerWidget: the widget type that gives you ref.
  • Codegen: melos build_runner from the repo root.

For the rules (folder structure, one-class-per-file, null safety), see flutter-development-standards.md. For the migration plan, see refactoring-roadmap.md.