🏗️ New Architecture Guide: Clean Architecture & Riverpod¶
Welcome to the new architectural standard for the Perci Platform!
We have migrated from a monolithic, legacy structure to a Feature-Based Clean Architecture using Riverpod for state management. This guide uses the newly implemented Documents Feature (lib/features/documents) as a reference to teach you the concepts.
Useful Links: - Riverpod Tutorials
🌟 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 can be 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 data fetching |
1. Core Concepts¶
🧅 Clean Architecture Layers¶
We divide every feature into three distinct layers. Data flows from the outside (API) in, and dependencies point from the outside in.
- Domain Layer (The "What"): Pure business logic. Contains Entities (data structures) and Use Cases (actions). It knows nothing about Flutter or APIs.
- Data Layer (The "How"): Handles data retrieval. Contains Models (JSON parsing) and Repositories (connecting to APIs).
- Presentation Layer (The "Show"): The UI. Contains Pages, Widgets, and Providers (State).
💧 Riverpod (State Management)¶
Riverpod is our Dependency Injection (DI) and State Management solution.
* Providers: Replace ChangeNotifier and GetIt. They hold state or logic.
* ConsumerWidget: Replaces StatelessWidget. It gives you a WidgetRef (ref) to interact with providers.
* Code Generation: We use @riverpod annotations to auto-generate the provider boilerplate.
2. File-by-File Walkthrough (The Documents Feature)¶
Let's trace the path of data from the UI back to the API using the files we just built.
📱 Presentation Layer (UI & State)¶
1. lib/features/documents/presentation/pages/documents_page.dart
* What it is: The main screen for the feature.
* Key Concept: It extends ConsumerWidget.
* Code Highlight:
// Watches the provider. Rebuilds automatically when data/loading/error changes.
final documentsAsync = ref.watch(documentsProvider);
// Handles all 3 states gracefully
return documentsAsync.when(
data: (docs) => DocumentsTable(documents: docs),
loading: () => CircularProgressIndicator(),
error: (err, stack) => Text('Error'),
);
2. lib/features/documents/presentation/widgets/document_row.dart
* What it is: A small reusable component.
* Key Concept: Also a ConsumerWidget because it needs to call logic (fetching URL) when tapped.
* Code Highlight: ref.read(getDocumentUrlUseCaseProvider) is used to trigger an action without rebuilding the widget.
3. lib/features/documents/presentation/providers/documents_provider.dart
* What it is: The "Brain" of the presentation layer. It defines the Riverpod providers.
* Key Concept: This file uses Code Generation. You write the function, and Riverpod generates the class.
* The Providers:
* documentsProvider: Fetches the list. Returns Future<List<Document>>.
* getDocumentsUseCase: Provides the domain logic class.
* documentRepository: Provides the data repository.
🧠 Domain Layer (Business Logic)¶
4. lib/features/documents/domain/entities/document.dart
* What it is: A plain Dart class representing a Document.
* Key Concept: NO fromJson here. This is pure data. It doesn't care if data comes from JSON, SQL, or the moon.
5. lib/features/documents/domain/repositories/document_repository.dart
* What it is: An abstract class (Contract).
* Key Concept: It defines what the app needs (getDocuments()), but not how to get it. This allows us to swap the API for mock data easily in tests.
6. lib/features/documents/domain/usecases/get_documents_usecase.dart
* What it is: An atomic business action.
* Key Concept: Encapsulates one specific task. The UI calls this, and this calls the repository.
💾 Data Layer (APIs & serialization)¶
7. lib/features/documents/data/models/document_model.dart
* What it is: Extends the Entity. Handles JSON.
* Key Concept: Contains fromJson. It converts raw API data into a clean DocumentModel.
8. lib/features/documents/data/datasources/document_remote_datasource.dart
* What it is: The low-level API caller.
* Key Concept: Uses our ApiClient (Dio) to hit the endpoints (e.g., /documents).
9. lib/features/documents/data/repositories/document_repository_impl.dart
* What it is: The actual implementation of the contract defined in Domain.
* Key Concept: It coordinates the DataSource (get JSON) -> Model (parse JSON) -> Entity (return clean data) flow.
🛠️ Core Infrastructure (Shared)¶
10. lib/core/network/api_client.dart
* A wrapper around Dio to standardize requests and error handling.
11. lib/core/network/auth_interceptor.dart
* Automatically injects x-session-token headers and handles refreshing expired tokens seamlessly.
3. Developer Workflow¶
How to create a new feature?¶
- Create folder:
lib/features/my_featurewithdomain,data,presentationsubfolders. - Start with Domain: Define your Entity and Repository Interface.
- Build Data: Create the Model (JSON) and Repository Implementation.
- Build Presentation: Create your Page and Providers.
⚙️ The Build Command (Crucial!)¶
Since we use code generation (Riverpod), you must run the build runner whenever you change a provider file (files with @riverpod or part '...g.dart').
Run one-off:
Run in watch mode (recommended during dev):
4. Actions & Controllers (Complex Interactions)¶
For simple data fetching, use FutureProvider. But for user actions (e.g., submitting forms, deleting items, uploading files), use a Class-Based Provider (Controller).
Example: Uploading a Document¶
1. The Use Case (lib/features/documents/domain/usecases/upload_document_usecase.dart)
Encapsulates the specific action.
class UploadDocumentUseCase {
final DocumentRepository repository;
// ...
Future<void> call(String filePath) => repository.uploadDocument(filePath);
}
2. The Controller (lib/features/documents/presentation/providers/documents_controller.dart)
Manages the state of the action (loading, success, error) and refreshes data.
@riverpod
class DocumentsController extends _$DocumentsController {
@override
FutureOr<void> build() {
// Initial state is void (idle)
}
Future<void> uploadDocument(String filePath) async {
state = const AsyncLoading(); // Set UI to loading
state = await AsyncValue.guard(() async {
// Perform the action
await ref.read(uploadDocumentUseCaseProvider).call(filePath);
// Refresh the list provider to show the new item!
ref.invalidate(documentsProvider);
});
}
}
3. The UI Button
ElevatedButton(
onPressed: () async {
// Trigger the controller action
await ref.read(documentsControllerProvider.notifier).uploadDocument(path);
},
child: Text('Upload'),
);
5. Testing¶
This architecture makes testing incredibly easy.
- Unit Tests: You can test
GetDocumentsUseCaseby mocking theDocumentRepository. You don't need to run the app or hit the API. - Widget Tests: You can test
DocumentsPageby overriding thedocumentsProviderto return fake data (loading, error, or list) without relying on the backend.
Example Override in Test:
ProviderScope(
overrides: [
documentsProvider.overrideWith((ref) => Future.value(fakeList)),
],
child: DocumentsPage(),
)
6. Cheat Sheet¶
ref.watch(provider): Use insidebuild(). Rebuilds widget when data changes.ref.read(provider): Use inside functions (likeonTap). Reads value once.AsyncValue: The object returned by FutureProviders. Has.when(data: ..., loading: ..., error: ...)helpers.ConsumerWidget: The widget type you use when you need access toref.
For more details, read the full docs/frontend/FRONTEND_REFACTORING_GUIDE.md.