Flutter and Clean Architecture (II)

Jesutoni Aderibigbe
9 min readAug 21, 2023

--

In our last article, we established that clean architecture is a software architectural pattern that provides a structured and modular way to design and organize code within a Flutter application. It aims to separate different concerns and responsibilities while maintaining a clear boundary between different layers of the application. The primary goal of Clean Architecture is to create a well-organized, maintainable, and testable codebase that is independent of external frameworks and libraries.

Clean Architecture involves the following layers:

  1. Presentation Layer(UI)
  2. Domain Layer(Business Logic)
  3. Data Layer(Repositories and Data Sources)

Presentation Layer contains the user interface components, such as widgets, screens, and user interactions. It is responsible for rendering the user interface and handling user input. It communicates with the Domain layer using abstract methods and classes

The Domain Layer on the other hand contains the entire business logic of your application. It defines the use cases and business rules without being tied to any specific UI or data framework. This layer contains entities, use cases and business logic interactions.

The data layer is responsible for providing data to the domain layer. It interacts with various data sources, such as databases, APIs, or local storage. The data layer includes repository interfaces that define the contract for data retrieval and storage.

In Flutter, there are several architectural patterns and approaches that developers can choose from, depending on the complexity of the app and the team’s preferences. Here are some of the most commonly used architectural patterns in Flutter:

MVC (Model-View-Controller)

The MVC is an architectural design that helps to organize an app’s code into distinct components that have separate responsibilities. It is divided into:

Model:

The Model component represents the application’s data and business logic. It encapsulates the data structure and handles operations related to data management. This includes retrieving, storing, and manipulating data. The model is responsible for

  • Represents the data entities, objects, and their relationships within the application.
  • Contains the business logic that processes and transforms the data.
  • Provides methods for CRUD operations (Create, Read, Update, Delete) on data entities.

View:

The View component is responsible for rendering the user interface (UI) and presenting data to the user. It displays the information provided by the Model and provides a visual representation of the application’s state. It is responsible for

  • Displays data to the user through a graphical interface.
  • Renders UI elements, such as buttons, labels, and forms, based on the data provided by the Model.
  • Listens for user interactions and forwards them to the Controller for processing.

Controller:

The Controller component acts as an intermediary between the Model and the View. It handles user interactions, processes user input, and communicates with the Model to update data or trigger business logic. It is responsible for

  • Listens for user input and events from the View.
  • Processes user actions and invokes appropriate methods in the Model.
  • Updates the Model based on user interactions.
  • Ensures that the View is updated with the latest data from the Model.

Let’s do a work-through of this model using a simple task management task that simply performs the CRUD operations. We will define the Model, View, and Controller components, and create a basic interaction where users can add tasks.

In our task class, we will define a simple model to interact with users

class Task {
final String id;
final String title;
final bool isCompleted;

Task({required this.id, required this.title, this.isCompleted = false});
}

class TaskModel {
List<Task> _tasks = [];

List<Task> get tasks => List.from(_tasks);

void addTask(String title) {
final task = Task(id: DateTime.now().toString(), title: title);
_tasks.add(task);
}
}

Let’s create a view to display tasks. However, we will be printing the results in our console

class TaskView {
void displayTasks(List<Task> tasks) {
print('Tasks:');
for (var task in tasks) {
final status = task.isCompleted ? 'Done' : 'Pending';
print('${task.title} - $status');
}
}
}

We will then create a controller that will serve as the middleware between the view and the model

class TaskController {
final TaskModel _model;
final TaskView _view;

TaskController(this._model, this._view);

void addTask(String title) {
_model.addTask(title);
updateView();
}

void updateView() {
final tasks = _model.tasks;
_view.displayTasks(tasks);
}
}

Let’s put all of this together and create an MVC architecture

void main() {
final taskModel = TaskModel();
final taskView = TaskView();
final taskController = TaskController(taskModel, taskView);

taskController.addTask('Buy groceries');
taskController.addTask('Finish report');
}

Here, your code is shorter. It saves your time and makes it easier to trace and catch bugs.

However, with this great ease comes its own disadvantages

  • Over time, the Model, View, and Controller components might become tightly coupled, making it harder to make changes without affecting other parts of the app.
  • Managing the application’s state can become complex, especially when the state needs to be shared between components.
  • As the app grows, maintaining a clear separation between components can become challenging.

MVVM (Model-View-ViewModel)

This is a popular pattern adopted by many flutter engineers who are involved in building complex UI applications. It is an architectural pattern where the components of the application are split into various components which are:

Model:

The Model component is responsible for managing the data and business logic of your application. It encapsulates the data structure, data retrieval, data storage, and manipulation operations. The Model is designed to be independent of the UI and the presentation logic. It is responsible for:

  • Defining the data entities and relationships
  • It contains business logic and validation of data
  • It serves as a single source of truth for your data’s manipulation
  • It serves as a single source of truth for your application’s data

View:

The View component represents the user interface (UI) and is responsible for rendering the data to the user. It does not contain any business logic or state management. It is responsible for:

  • Displaying data to the user by rendering widgets
  • Listening to the user’s input and interactions
  • Passing the user’s interaction to the ViewModel for processing.

ViewModel:

The ViewModel component acts as an intermediary between the Model and the View. It abstracts the presentation logic and holds the application’s state. It is responsible for:

  • Managing the application’s state and interactions.
  • Contains presentation logic, data transformations, and data formatting.
  • Provides data to the View in a format that is ready to be displayed.
  • Receives user input from the View and invokes appropriate actions on the Model.

WORK THROUGH USING THE MVVM ARCHITECTURE

In this case, we will use the same basic task management app where users can add and view tasks using simple state management called “Provider”

  • Let’s create a model and call it “task.dart”
class Task {
final String id;
final String title;
final bool isCompleted;

Task({required this.id, required this.title, this.isCompleted = false});
}
  • Create also a view model and name it `task_model.dart`
class TaskViewModel extends ChangeNotifier {
List<Task> _tasks = [];

List<Task> get tasks => List.from(_tasks);

void addTask(String title) {
final task = Task(id: DateTime.now().toString(), title: title);
_tasks.add(task);
notifyListeners(); // Notify listeners of state change
}
}
  • Let’s create the View(UI)
void main() {
runApp(MyApp());
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => TaskViewModel(), // Provide the ViewModel
child: MaterialApp(
title: 'MVVM Task App',
home: TaskListScreen(),
),
);
}
}

class TaskListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Task List')),
body: TaskListView(),
floatingActionButton: FloatingActionButton(
onPressed: () => _navigateToAddTask(context),
child: Icon(Icons.add),
),
);
}

void _navigateToAddTask(BuildContext context) {
Navigator.push(context, MaterialPageRoute(builder: (context) => AddTaskScreen()));
}
}

class TaskListView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final taskViewModel = Provider.of<TaskViewModel>(context); // Access ViewModel
final tasks = taskViewModel.tasks;

return ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return ListTile(
title: Text(task.title),
subtitle: Text(task.isCompleted ? 'Completed' : 'Pending'),
);
},
);
}
}

class AddTaskScreen extends StatefulWidget {
@override
_AddTaskScreenState createState() => _AddTaskScreenState();
}

class _AddTaskScreenState extends State<AddTaskScreen> {
final TextEditingController _titleController = TextEditingController();

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Add Task')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: _titleController,
decoration: InputDecoration(labelText: 'Task Title'),
),
SizedBox(height: 16.0),
ElevatedButton(
onPressed: () {
final taskTitle = _titleController.text;
if (taskTitle.isNotEmpty) {
_addTask(context, taskTitle);
}
},
child: Text('Add Task'),
),
],
),
),
);
}

void _addTask(BuildContext context, String taskTitle) {
final taskViewModel = Provider.of<TaskViewModel>(context, listen: false);
taskViewModel.addTask(taskTitle);
Navigator.pop(context); // Navigate back to TaskListScreen
}

@override
void dispose() {
_titleController.dispose();
super.dispose();
}
}

The View (TaskListScreen, TaskListView, and AddTaskScreen) is responsible for rendering UI elements and interacting with the ViewModel.

From this work through, we can safely state the following benefits of this architecture

  • Separation of Concerns: MVVM enhances the separation between data, presentation logic, and UI, making your codebase more modular and maintainable.
  • Testability: ViewModel’s business logic can be thoroughly unit-tested independently of the UI layer.
  • ViewModels can be reused across different UI components, as they do not depend on UI implementation details.
  • Teammates can further collaborate without having conflicts
  • The separation between the View and ViewModel makes it easier to identify the source of bugs and issues. Debugging becomes more straightforward as the responsibilities of each component are well-defined.
  • With this structure, it is easier to scale your codebase over time.
  • Refactoring and Maintenance: MVVM’s modular structure simplifies refactoring efforts. You can make changes to one layer without affecting the others, which is especially valuable when maintaining legacy codebases.

MVVM && BLOC ARCHITECTURE

Since clean architecture emphasizes separating different concerns and responsibilities while maintaining a clear boundary between different layers of the application. State Management plays a pivotal role by managing the flow of data and maintaining the state within the application. It is important for the following reasons:

  • You won’t get a job without it. So, Oga(Yoruba word for Boss), go and learn it.
  • State management allows you to control how data flows through your application. This predictability makes it easier to write unit tests for your business logic and UI components.
  • Updating your UI is easier and faster. It relieves you from unnecessary stress
  • Clean Architecture, when combined with effective state management, leads to a smoother user experience. UI components can quickly respond to changes in data, enabling interactive and real-time features.
  • State management helps achieve this isolation by ensuring that UI components do not directly manipulate data or perform complex operations. Instead, the UI layer interacts with the ViewModel or UseCase to obtain and display data.

This article is not on state management. Hopefully, I write another article on why you should use state management. I only stated the benefits as regards clean architecture.

Let’s move to BLoC and Clean Architecture.

BLoC stands for Business Logic Component which is a design pattern that is used to manage the flow of data and handle the business logic in an application. BLoC helps separate the UI components from the underlying data and business logic, making the codebase more organized, modular, and easier to maintain.

Again, this article is not about BLoC but its usefulness with clean architecture. I will write extensively about BLoC in my piece on State Management.

BLoC provides a structured way to manage state and handle user interactions using streams and events. While MVVM focuses on the separation of concerns and enhancing testability, BLoC serves as an implementation approach for the ViewModel.

Let’s build a simple task management app using this approach

  • Define the model
class Task {
final String id;
final String title;
final bool isCompleted;

Task({required this.id, required this.title, this.isCompleted = false});
}
  • Let’s create a view_model
class TaskBloc {
final _taskListController = StreamController<List<Task>>.broadcast();

List<Task> _tasks = [];
Stream<List<Task>> get taskListStream => _taskListController.stream;

void dispose() {
_taskListController.close();
}

void addTask(String title) {
final task = Task(id: DateTime.now().toString(), title: title);
_tasks.add(task);
_taskListController.sink.add(_tasks);
}

// Other methods for updating and deleting tasks
}
  • Let’s create the view(UI)
void main() {
runApp(MyApp());
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final taskBloc = TaskBloc(); // Instantiate the TaskBloc

return MaterialApp(
title: 'Task Management App',
home: TaskListScreen(taskBloc: taskBloc),
);
}
}

class TaskListScreen extends StatelessWidget {
final TaskBloc taskBloc;

TaskListScreen({required this.taskBloc});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Task List')),
body: TaskListView(taskBloc: taskBloc),
floatingActionButton: FloatingActionButton(
onPressed: () => _navigateToAddTask(context),
child: Icon(Icons.add),
),
);
}

void _navigateToAddTask(BuildContext context) {
Navigator.push(context, MaterialPageRoute(builder: (context) => AddTaskScreen(taskBloc: taskBloc)));
}
}

class TaskListView extends StatelessWidget {
final TaskBloc taskBloc;

TaskListView({required this.taskBloc});

@override
Widget build(BuildContext context) {
return StreamBuilder<List<Task>>(
stream: taskBloc.taskListStream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return CircularProgressIndicator();
}
final tasks = snapshot.data!;

return ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return ListTile(
title: Text(task.title),
subtitle: Text(task.isCompleted ? 'Completed' : 'Pending'),
);
},
);
},
);
}
}

class AddTaskScreen extends StatefulWidget {
final TaskBloc taskBloc;

AddTaskScreen({required this.taskBloc});

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

class _AddTaskScreenState extends State<AddTaskScreen> {
final TextEditingController _titleController = TextEditingController();

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Add Task')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: _titleController,
decoration: InputDecoration(labelText: 'Task Title'),
),
SizedBox(height: 16.0),
ElevatedButton(
onPressed: () {
final taskTitle = _titleController.text;
if (taskTitle.isNotEmpty) {
widget.taskBloc.addTask(taskTitle); // Use the provided taskBloc
Navigator.pop(context);
}
},
child: Text('Add Task'),
),
],
),
),
);
}

@override
void dispose() {
_titleController.dispose();
super.dispose();
}
}

In summary, whatever architecture you choose, it is to ensure that your codes are maintainable, testable, and also scalable.

There will be part 3 of this. Watch out for it!

Please leave your comments.

--

--