Flutter Clean Architecture 104: Mastering the Presentation Layer

 In this fourth part of the Clean Architecture series, we move into the layer that users directly interact with: the Presentation Layer. This is where UI, state management, and user-triggered logic come together.

Take a look at the previous related posts here: 101, 102, and 103.

What is the Presentation Layer?

The Presentation Layer is responsible for:

  • Displaying UI (Flutter widgets)

  • Reacting to user input

  • Managing and reflecting UI state

  • Interacting with Use Cases from the Domain Layer

It should not include:

  • Direct API calls

  • Business logic

  • Raw data models (DTOs)


How the Layers Interact

Goals of the Presentation Layer

  • Keep UI logic separate from business logic

  • Make the UI reactive and testable

  • Provide a clear entry point into the use case(s)


Core Components

1. Views (Widgets)

These are your Flutter widgets that render the user interface. Views:

  • Call methods on the controller to perform actions

  • Read values (like isLoading, user, or error) exposed by the controller

  • Do not contain business or data-fetching logic

2. Controllers (e.g., UserController)

Controllers act as an intermediary between the UI and the Domain Layer.

In this architecture, we do not create separate UI models. Instead:

  • We use domain entities (e.g., User) directly

  • We manage UI-specific state (e.g., loading, error) alongside them

  • The controller calls use cases and updates the state accordingly

  • Listeners are notified of changes so the UI can update


Here’s an example:

class UserController extends ChangeNotifier {
  final GetUserProfile _getUserProfile;

  bool isLoading = false;
  User? user;
  String? error;

  UserController(this._getUserProfile);

  Future<void> fetchUser() async {
    isLoading = true;
    error = null;
    notifyListeners();

    final result = await _getUserProfile();

    result.fold(
      (failure) => error = failure.message,
      (fetchedUser) => user = fetchedUser,
    );

    isLoading = false;
    notifyListeners();
  }
}

This setup is simple and consistent.
Your controller exposes exactly what the UI needs without any duplication.

Flutter UI Example

class UserView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final controller = context.watch<UserController>();

    if (controller.isLoading) {
      return const Center(child: CircularProgressIndicator());
    }

    if (controller.error != null) {
      return Center(child: Text('Error: ${controller.error}'));
    }

    if (controller.user != null) {
      return Center(child: Text('Welcome, ${controller.user!.name}'));
    }

    return Center(
      child: ElevatedButton(
        onPressed: () => controller.fetchUser(),
        child: const Text('Load Profile'),
      ),
    );
  }
}
This widget is completely dependent on the controller for logic.
It simply reacts to state and renders appropriate content.

State Management Options

This controller-based approach can be used with any of the major

Flutter state management solutions:

  • Provider (with ChangeNotifier)

  • Riverpod

  • Bloc / Cubit

  • GetX

  • MobX

The structure remains the same — controllers call use cases, hold state,

and notify the UI.


Leave a thought in the comment area.

Thank you!



Comments