Skip to content

🏗️ 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.

  1. Domain Layer (The "What"): Pure business logic. Contains Entities (data structures) and Use Cases (actions). It knows nothing about Flutter or APIs.
  2. Data Layer (The "How"): Handles data retrieval. Contains Models (JSON parsing) and Repositories (connecting to APIs).
  3. 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?

  1. Create folder: lib/features/my_feature with domain, data, presentation subfolders.
  2. Start with Domain: Define your Entity and Repository Interface.
  3. Build Data: Create the Model (JSON) and Repository Implementation.
  4. 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:

fvm flutter pub run build_runner build --delete-conflicting-outputs

Run in watch mode (recommended during dev):

fvm flutter pub run build_runner watch --delete-conflicting-outputs


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 GetDocumentsUseCase by mocking the DocumentRepository. You don't need to run the app or hit the API.
  • Widget Tests: You can test DocumentsPage by overriding the documentsProvider to 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 inside build(). Rebuilds widget when data changes.
  • ref.read(provider): Use inside functions (like onTap). 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 to ref.

For more details, read the full docs/frontend/FRONTEND_REFACTORING_GUIDE.md.