Exploring Different Architectural Patterns (MVVM, MVP, Clean Architecture) in Flutter

Flutter, Google’s UI toolkit for building natively compiled applications for mobile, web, and desktop from a single codebase, offers developers the flexibility to choose from various architectural patterns. These patterns help organize, structure, and maintain the codebase, making it scalable and testable. In this blog post, we will explore three popular architectural patterns in Flutter: MVVM (Model-View-ViewModel), MVP (Model-View-Presenter), and Clean Architecture.

Why Architectural Patterns Matter

Architectural patterns play a crucial role in software development by:

  • Improving Code Organization: Providing a clear structure that separates concerns and makes code easier to understand.
  • Enhancing Testability: Enabling easier unit testing by decoupling components.
  • Promoting Reusability: Allowing components to be reused across different parts of the application.
  • Facilitating Scalability: Making it easier to add new features and scale the application.
  • Simplifying Maintenance: Reducing complexity and making it easier to maintain the codebase over time.

1. Model-View-ViewModel (MVVM)

MVVM is a widely adopted architectural pattern that separates the UI (View) from the business logic (Model) through an intermediary layer called ViewModel.

Components of MVVM

  • Model: Represents the data and business logic. It includes data sources, repositories, and data models.
  • View: Represents the UI of the application. It observes the ViewModel and updates the UI based on the ViewModel’s state.
  • ViewModel: Acts as an intermediary between the View and the Model. It exposes data and commands that the View can bind to.

Implementation in Flutter

Here’s how you can implement MVVM in Flutter:

Step 1: Define the Model

Create a model class that represents the data:

class User {
  final String name;
  final String email;

  User({required this.name, required this.email});
}
Step 2: Create the ViewModel

Create a ViewModel class that fetches and provides data to the View:

import 'package:flutter/material.dart';

class UserViewModel extends ChangeNotifier {
  User? _user;
  bool _isLoading = false;

  User? get user => _user;
  bool get isLoading => _isLoading;

  Future<void> fetchUser() async {
    _isLoading = true;
    notifyListeners();

    // Simulate fetching user data from a remote source
    await Future.delayed(Duration(seconds: 2));
    _user = User(name: 'John Doe', email: 'john.doe@example.com');

    _isLoading = false;
    notifyListeners();
  }
}
Step 3: Build the View

Create a View that observes the ViewModel and updates the UI:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class UserView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final userViewModel = Provider.of<UserViewModel>(context);

    return Scaffold(
      appBar: AppBar(
        title: Text('MVVM Example'),
      ),
      body: Center(
        child: userViewModel.isLoading
            ? CircularProgressIndicator()
            : userViewModel.user != null
                ? Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Text('Name: ${userViewModel.user!.name}'),
                      Text('Email: ${userViewModel.user!.email}'),
                    ],
                  )
                : Text('No user data'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          userViewModel.fetchUser();
        },
        child: Icon(Icons.refresh),
      ),
    );
  }
}
Step 4: Integrate with Provider

Use the Provider package to provide the ViewModel to the View:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => UserViewModel(),
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'MVVM Example',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: UserView(),
    );
  }
}

Advantages of MVVM

  • Improved Testability: ViewModel can be easily unit tested without UI dependencies.
  • Separation of Concerns: Clear separation between the UI and business logic.
  • Code Reusability: ViewModel can be reused across multiple Views.

Disadvantages of MVVM

  • Increased Complexity: Requires more classes and files compared to simpler architectures.
  • Learning Curve: Developers need to understand the MVVM pattern and its components.

2. Model-View-Presenter (MVP)

MVP is another architectural pattern that aims to separate the UI (View) from the business logic (Model) through a Presenter.

Components of MVP

  • Model: Represents the data and business logic, similar to MVVM.
  • View: The UI of the application, which implements an interface to communicate with the Presenter.
  • Presenter: Acts as an intermediary between the View and the Model. It retrieves data from the Model and updates the View.

Implementation in Flutter

Step 1: Define the Model

Create a model class:

class User {
  final String name;
  final String email;

  User({required this.name, required this.email});
}
Step 2: Create the View Interface

Define an interface for the View:

abstract class UserViewInterface {
  void showLoading();
  void hideLoading();
  void displayUser(User user);
  void displayError(String error);
}
Step 3: Implement the View

Implement the View using the interface:

import 'package:flutter/material.dart';

class UserView extends StatefulWidget {
  final UserPresenter presenter;

  UserView({required this.presenter});

  @override
  _UserViewState createState() => _UserViewState();
}

class _UserViewState extends State<UserView> implements UserViewInterface {
  User? _user;
  bool _isLoading = false;
  String _error = '';

  @override
  void initState() {
    super.initState();
    widget.presenter.view = this;
    widget.presenter.fetchUser();
  }

  @override
  void showLoading() {
    setState(() {
      _isLoading = true;
    });
  }

  @override
  void hideLoading() {
    setState(() {
      _isLoading = false;
    });
  }

  @override
  void displayUser(User user) {
    setState(() {
      _user = user;
    });
  }

  @override
  void displayError(String error) {
    setState(() {
      _error = error;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('MVP Example'),
      ),
      body: Center(
        child: _isLoading
            ? CircularProgressIndicator()
            : _error.isNotEmpty
                ? Text('Error: $_error')
                : _user != null
                    ? Column(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          Text('Name: ${_user!.name}'),
                          Text('Email: ${_user!.email}'),
                        ],
                      )
                    : Text('No user data'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          widget.presenter.fetchUser();
        },
        child: Icon(Icons.refresh),
      ),
    );
  }
}
Step 4: Create the Presenter

Create a Presenter class:

class UserPresenter {
  late UserViewInterface view;
  final UserRepository userRepository = UserRepository();

  Future<void> fetchUser() async {
    view.showLoading();
    try {
      final user = await userRepository.getUser();
      view.displayUser(user);
    } catch (e) {
      view.displayError('Failed to fetch user: ${e.toString()}');
    } finally {
      view.hideLoading();
    }
  }
}
Step 5: Create the Repository

Create a Repository class to fetch data:

class UserRepository {
  Future<User> getUser() async {
    // Simulate fetching user data from a remote source
    await Future.delayed(Duration(seconds: 2));
    return User(name: 'John Doe', email: 'john.doe@example.com');
  }
}
Step 6: Integrate in the Main App
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'MVP Example',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: UserView(presenter: UserPresenter()),
    );
  }
}

Advantages of MVP

  • Testability: Presenter can be easily unit tested with mock Views.
  • Clear Separation: Clear separation between View and business logic.
  • Maintainability: Promotes a well-structured codebase, making it easier to maintain.

Disadvantages of MVP

  • Complex Interfaces: Requires defining interfaces for Views, which can add complexity.
  • More Code: Generally requires more code compared to simpler architectures.

3. Clean Architecture

Clean Architecture, proposed by Robert C. Martin (Uncle Bob), is a more comprehensive architectural pattern that aims to create a system that is:

  • Independent of Frameworks: The architecture should not depend on any specific framework.
  • Testable: Business rules can be tested without the UI, database, or any external element.
  • Independent of UI: The UI can change easily, without changing the system logic.
  • Independent of Database: You can swap out databases without affecting the application’s business logic.
  • Independent of any external agency: Your business rules simply don’t know anything at all about interfaces to the outside world.

Layers of Clean Architecture

Clean Architecture consists of multiple layers:

  • Entities: Core business objects. They are the most generic and least likely to change.
  • Use Cases: Business logic that orchestrates the data flow to and from the entities.
  • Interface Adapters: Converts data from the format most convenient for the Use Cases and Entities, to the format most convenient for some external agency such as the Database or the Web.
  • Frameworks and Drivers: The outermost layer, consisting of frameworks, drivers, and tools used. This layer includes UI, device-specific code, and external libraries.

Implementation in Flutter

Implementing Clean Architecture in Flutter involves structuring the project into multiple layers or modules.

Step 1: Project Structure

Create the following directories:


- core/
  - entities/
  - usecases/
  - error/
- data/
  - models/
  - datasources/
  - repositories/
- domain/
  - entities/
  - usecases/
  - repositories/
- presentation/
  - bloc/
  - pages/
  - widgets/

Step 2: Define Entities

Create entities in the core/entities directory:

class User {
  final String name;
  final String email;

  User({required this.name, required this.email});
}
Step 3: Define Use Cases

Create use cases in the domain/usecases directory:

import '../entities/user.dart';
import '../repositories/user_repository.dart';

class GetUser {
  final UserRepository userRepository;

  GetUser(this.userRepository);

  Future<User> execute() async {
    return await userRepository.getUser();
  }
}
Step 4: Define Repositories

Create repository interfaces in the domain/repositories directory:

import '../entities/user.dart';

abstract class UserRepository {
  Future<User> getUser();
}
Step 5: Implement Repositories

Implement the repository in the data/repositories directory:

import '../../domain/entities/user.dart';
import '../../domain/repositories/user_repository.dart';
import '../datasources/user_remote_data_source.dart';

class UserRepositoryImpl implements UserRepository {
  final UserRemoteDataSource remoteDataSource;

  UserRepositoryImpl({required this.remoteDataSource});

  @override
  Future<User> getUser() async {
    return await remoteDataSource.getUser();
  }
}
Step 6: Define Data Sources

Create data sources in the data/datasources directory:

import '../../domain/entities/user.dart';

abstract class UserRemoteDataSource {
  Future<User> getUser();
}

class UserRemoteDataSourceImpl implements UserRemoteDataSource {
  @override
  Future<User> getUser() async {
    // Simulate fetching user data from a remote source
    await Future.delayed(Duration(seconds: 2));
    return User(name: 'John Doe', email: 'john.doe@example.com');
  }
}
Step 7: Create the Presentation Layer

Create the presentation layer with UI components and BLoC (Business Logic Component) for state management:

First, add the flutter_bloc package to your pubspec.yaml:

dependencies:
  flutter_bloc: ^8.1.3

Create a BLoC in the presentation/bloc directory:

import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/user.dart';
import '../../domain/usecases/get_user.dart';

// Define Events
abstract class UserEvent {}

class FetchUserEvent extends UserEvent {}

// Define States
abstract class UserState {}

class UserInitial extends UserState {}

class UserLoading extends UserState {}

class UserLoaded extends UserState {
  final User user;

  UserLoaded(this.user);
}

class UserError extends UserState {
  final String message;

  UserError(this.message);
}

class UserBloc extends Bloc<UserEvent, UserState> {
  final GetUser getUserUseCase;

  UserBloc({required this.getUserUseCase}) : super(UserInitial()) {
    on<FetchUserEvent>((event, emit) async {
      emit(UserLoading());
      try {
        final user = await getUserUseCase.execute();
        emit(UserLoaded(user));
      } catch (e) {
        emit(UserError(e.toString()));
      }
    });
  }
}

Create the UI in the presentation/pages directory:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/user.dart';
import '../bloc/user_bloc.dart';

class UserPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Clean Architecture Example'),
      ),
      body: Center(
        child: BlocBuilder<UserBloc, UserState>(
          builder: (context, state) {
            if (state is UserInitial) {
              return Text('Press the button to load user data.');
            } else if (state is UserLoading) {
              return CircularProgressIndicator();
            } else if (state is UserLoaded) {
              final User user = state.user;
              return Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text('Name: ${user.name}'),
                  Text('Email: ${user.email}'),
                ],
              );
            } else if (state is UserError) {
              return Text('Error: ${state.message}');
            } else {
              return Text('Unexpected state');
            }
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          BlocProvider.of<UserBloc>(context).add(FetchUserEvent());
        },
        child: Icon(Icons.refresh),
      ),
    );
  }
}
Step 8: Integrate in the Main App
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/injection.dart'; // Assuming you have a dependency injection setup
import 'presentation/bloc/user_bloc.dart';
import 'presentation/pages/user_page.dart';

void main() async {
  await configureDependencies(); // Initialize dependency injection
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Clean Architecture Example',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: BlocProvider(
        create: (context) => sl<UserBloc>(), // Using service locator (sl)
        child: UserPage(),
      ),
    );
  }
}

Remember to set up a dependency injection container to manage the dependencies of the Clean Architecture layers.

For a complete example, consider using a dependency injection library such as get_it or injectable to manage dependencies in a structured way.

Advantages of Clean Architecture

  • Framework Independence: Allows the flexibility to switch frameworks easily.
  • Testability: Ensures that business logic is thoroughly testable.
  • Maintainability: Reduces the impact of changes by isolating components.
  • Scalability: Supports large and complex projects with a clear structure.

Disadvantages of Clean Architecture

  • Complexity: More complex compared to other architectures, especially for smaller projects.
  • Steep Learning Curve: Requires a good understanding of architectural principles and layered design.
  • More Boilerplate Code: Involves more interfaces and classes, resulting in increased boilerplate code.

Conclusion

Choosing the right architectural pattern for your Flutter application is essential for maintainability, scalability, and testability. MVVM offers a balance between simplicity and separation of concerns, making it suitable for many applications. MVP provides a clear separation between the View and the business logic, making it ideal for complex UIs. Clean Architecture, while more complex, provides the highest level of decoupling and flexibility, making it suitable for large, enterprise-level applications.

When deciding which pattern to use, consider the complexity of your application, the size of your team, and the long-term maintainability requirements. Each of these architectural patterns has its strengths and weaknesses, and the best choice will depend on the specific needs of your project.