๐๏ธ 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.
- Domain Layer (the "What"): Pure business logic. Entities, abstract Repository contracts, and Use Cases. Knows nothing about Flutter, Dio, or JSON.
- Data Layer (the "How"): Models/DTOs (JSON), Data Sources (API), and Repository implementations.
- 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
StatelessWidgetand gives you aWidgetRef(ref). - Code Generation: we use
@riverpodannotations; the boilerplate is generated into*.g.dartfiles.
2. File-by-file walkthrough (the Documents feature)¶
Where the files live. Reusable, cross-app pieces (the
Documententity,DocumentModel,DocumentBytesResultvalue 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):
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:
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
DioExceptionand throw aServerException(preserving the server's message where available) rather than letting a rawDioExceptionleak upward. - Domain-specific failures get their own exception types (e.g.
EmailAlreadyInUseException,InvalidEmailExceptionin the membercode_signupfeature) 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.guardand renderasyncValue.when(error: ...). Nevercatch (_)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
freezedwith@JsonSerializable. Freezed generates==/hashCode,copyWith, and (with the JSON annotation)fromJson/toJson. This is the membercode_signuppattern (signup_session.dart). - Plain domain entities without JSON:
freezedis still fine;Equatableis 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:
- Expose the route on the page as static fields:
- Register it in
nav.dart, gating auth and reading params:
Structure routes for the web, not just mobile:
- Use
ShellRoutefor shared chrome. When a group of pages shares a scaffold (bottom nav, side rail, app bar), wrap them in aShellRoutethat 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-rolledcontext.pop()chains.
Direction of travel. Phase 5 of the refactoring roadmap replaces
FFRoute/nav.dartwith typed, feature-owned routes, provider-based guards,ShellRoutechrome, 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-typedqueryParameters.
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 itsDocumententity,DocumentModel, andDocumentBytesResulthere. - 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
ProviderScopeto inject fake loading/error/data states: - Shared harness: use
perci_platform_test_sharedfor 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 (thenlibrary;) before any imports, and run underflutter test <path> --tags golden.
11. Cheat sheet¶
ref.watch(provider): insidebuild()โ 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 youref.- Codegen:
melos build_runnerfrom 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.