This article is written by Jahswill Essien
It’s your first day at an exciting company, and everything’s going great. It’s time to familiarize yourself with the codebase. So you fork it from GitHub, and lo and behold, your warm smile is replaced with utter confusion. The codebase is filled with spaghetti code. Simply, it’s chaotic.
We developers are often faced with scenarios like this. True, we all have our unique coding styles — however, we need some guiding principles to adhere to in order to avoid writing chaotic code.
Over the years, many software designs and architecture patterns have sprung up with the aim of adding clarity and structure to the way software developers write applications.
This article aims to explore the clean architecture design pattern. Its intended audience is developers who are comfortable with the Flutter framework and are concerned about using the right architecture for their Flutter apps.
Spaghetti code is unstructured, unmaintainable, and anti-patterned source code.
Before delving into why you should care about the architecture of your software, it’s crucial to understand the concepts of a software application’s components and elements.
Components and elements of software
For this article, we will adhere to the following definitions.
Components
Components are detachable parts of a system. Although they communicate with other parts of the systems, they can be developed independently.
The organization of components and the way they interact with each other forms the structure of the software architecture.
Elements
We can subdivide software into two major elements: policy and details.
The policy element is the main sauce of your software. It encompasses the business logic of your application.
The details are elements that are not directly related to the business logic of your software.
If you are developing software that automates employee payments, for example, the policy element is the logic of the system that tells it when to make payments, whom to make them to, and how much should be paid.
On the other hand, the details are things like the database, servers, frameworks, and devices used for this payment system.
The policy doesn’t care about the details of the system since they can be replaced or their implementation can be deferred. For instance, I can decide to change my database from a SQL to a NoSQL database.
Why should you care about your software’s architecture?
If you were to open the codebase of a two-screen app and find that all of the screens, network and local repositories, and network client classes were defined in a single Dart file, it would be weird, but you could overlook it.
However, if you were to find the same situation in a more complex program like a banking app, it would be chaotic and difficult to manage. This is because a banking app typically involves a large amount of data and functionality, and organizing it all within a single file would make it incredibly unwieldy and hard to maintain. Good luck fixing a bug in this codebase.
You start to appreciate the essence of architecture in your software when you begin to add more and more features and use cases.
Have you ever revisited a project you haven’t worked on in months and can’t figure out what the heck is going on? Have you worked with a complicated codebase and broken the app by making a few slight changes? Have you been in a situation in which you can’t find certain classes in a codebase? If your answers to some or all of these questions are yes, then you should care about the architecture of the software you build.
What makes good architecture?
When considering architecture for your software, remember that good architecture should accomplish the following things:
- Make a system easy to understand, develop, maintain, and deploy
- Decouple the system’s policy from its details (it should not depend on the framework, state management, database, or servers you use)
- Make your system testable
- Encourage code reusability
In other words, a well-architected Flutter codebase should make it easy for you to do the following:
- Locate and understand files in the codebase
- Change back-end technology, for example, from Firebase to RESTful APIs (or vice versa)
- Switch state management solutions
- Write unit, widget, and integration tests
What is clean architecture?
The clean architecture was proposed by Robert C. Martin and relies heavily on the SOLID principles. Clean architecture aims to encourage developers to design and structure source code in a clean and organized way. It follows the separation of concerns design principle by introducing four distinct layers.
Separation of concerns is a design principle that ensures that software components are grouped based on the correlation between the kind of work they execute.
Image by Robert C. Martin
This image illustrates the clean architecture proposed by Robert C. Martin. We can see that it comprises four different layers pointing inwards. It doesn’t tell us much about structuring a Flutter app, but it does lay down the core concepts of clean architecture: the separation of concerns and the dependency rule.
It comprises these layers:
Frameworks and Drivers: This layer contains the “details” of a system. These can include the user interface (UI), devices, or external interfaces. Assuming we are building an ordering machine, this layer deals with the machine interface.
Interface Adapters: This layer consists of things like controllers, state-holders, or view-models. It is responsible for getting data from the UI or device and transforming it into something usable for the business logic. Considering our ordering machine system, this layer will consist of objects that hold the user’s input from the screen, and it sends it to the use case for processing.
Application Business Rule/Use Cases: This layer deals with the specific intent of the system. It encompasses application logic that helps accomplish business needs. This rule governs how the application behaves under certain conditions and is specific to the application. Using our ordering machine as a case study, this would be the logic that handles user input to order food or drinks, calculate the total cost of the order, and validate that the user has enough funds in their wallet to complete the transaction.
Enterprise Business Rule/Entities: This layer deals with core logic specific to business needs. Unlike application logic, the business rule covers multiple applications. Examples of the business rules for the ordering machine include rules that manage how data is stored across applications (client and server-side applications), govern data security, control access to data, and so on.
One thing to note is that the directional flow is inwards. The arrow direction signifies that an outer level is dependent on the inner level.
Frameworks and Drivers depend on Interface Adapters, which depend on Use Cases, which depend on Entities.
Clean architecture for mobile applications
Quoting directly from Robert’s text, “There’s no rule that says you must always have just these four layers. However, the Dependency Rule always applies. Source code dependencies always point inwards.” Therefore, for mobile applications, we can categorize the layers of clean architecture into three layers.
Reso Coder designed a more detailed architecture representation for Flutter apps.
Image by Reso Coder
Presentation layer
The presentation layer deals with the UI of the system. In practical terms, any code that displays information on the screen or handles UI logic ought to be in this layer. Specifically, widgets, controllers, and state-holders are citizens of the presentation layer.
This layer depends on the domain layer and must not have any reference whatsoever to the data layer.
Domain layer
The domain layer sits between the presentation layer and the data layer. It contains business logic. This layer consists of entities, use cases, and contracts — (abstract) classes of repositories. It’s a crime (according to clean architecture rules) for this layer to reference classes, functions, and imports of the presentation or data layer.
To bridge the gap between the data layer and domain, we use the dependency inversion rule.
The dependency inversion rule states that a higher-level module should not depend on the concrete implementation of lower-level modules. Rather, it should depend on abstractions or interfaces.
Data layer
The data layer contains code that communicates with the server or database. It consists of an implementation of contracts (abstract classes) defined in the domain layer, local/remote data sources, services, models, and so on.
Demonstration
To further understand this concept and how components interact with each other, we have a demo application that fetches the characters of the cartoon Ricky and Morty and has a feature that enables you to create and delete your favorite characters.
Run the command git clone git@github.com:JasperEssien2/ricky_and_morty.git
to clone the project.
You’ll see three top-level folders under the lib
folder. This folder arrangement corresponds to the layers of clean architecture.
The presentation layers contain necessary widgets and a data controller.
Domain layer walkthrough
The domain layer contains the following:
A
CharacterEntity
class, which is defined in thecharacter_entity.dart
file. It is just a simple data class. Think of entity objects as model classes but for the domain layer. Entities should only contain data that is needed to display on the UI.A contract, or an abstract class with abstract methods, is in the
repository.dart
file. This is the bridge between the domain layer and the data layer.abstract class Repository { Future<List<CharacterEntity>> fetch(); Future<List<CharacterEntity>> fetchFavourites(); Future<bool> deleteFavourite(int id); Future<bool> addFavourite(int id); }
The
use_cases
folder under the domain folder defines all the use cases of this app. We will spotlight theDeleteFavouriteCharactersUseCase
class to understand the role of use cases in clean architecture.
import 'package:ricky_and_morty/domain/repository.dart';
class DeleteFavouriteCharactersUseCase {
DeleteFavouriteCharactersUseCase({required Repository repository})
: _repository = repository;
final Repository _repository;
Future<bool> call(int id) => _repository.deleteFavourite(id);
}
lib/domain/use_cases/delete_favourite_character_use_case.dart
In the above snippet, the use case is responsible for deleting a favorite character from the database. Here are a few key points to note when defining use cases:
A use case can only depend on the repository contract and other use cases
A single use case class should only perform one action per class
You make the instance of a Dart class callable as a function by defining a
call()
methodfinal deleteFavouriteCharactersUseCase = DeleteFavouriteCharactersUseCase(); /// By defining call(), you can call the instance as you would a function await deleteFavouriteCharactersUseCase(0); /// The callable class is analogous to the statement below await deleteFavouriteCharactersUseCase.call(0);
You’re probably thinking, “Should I create a separate use case class for each use case of my application?” The answer to that is yes if you intend to adhere to clean architecture strictly.
As discouraging as this sounds, it has some benefits. For example, if you encounter an issue with deleting characters, you can quickly identify and fix it. It encourages code reusability, as use cases can be reused.
At the end of the day, users are only concerned with the UI of our app, not its internal code. The next section will walk you through how the domain layer connects with the presentation layer.
Presentation layer walkthrough
The presentation
folder contains all our widgets and state-holder classes.
The state-holders are simply components that store and manage the state of an application. In clean architecture, state-holders depend on use case classes. The code snippet below shows the fetch()
method of the CharactersDataController
state-holder class and how it uses the get characters use case to fetch characters from the database.
@override
Future<void> fetch() async {
/// Update state to loading. The [state] is a setter method that calls `notifyListeners()` under the hood
state = ConnectionState.waiting;
try {
// Fetch characters data
_data = await getCharactersUseCase();
} catch (e) {
_error = e.toString();
}
// Check and update characters that are marked favourite
updateFavouritedCharacters();
/// Update state to done. The [state] is a setter method that calls `notifyListeners()` under the hood
state = ConnectionState.done;
}
On the home screen, a reusable widget named Consumer
is defined. Its job is to listen to DataController
— a subclass of ChangeNotifier
— and rebuild when it is updated.
class Consumer extends StatelessWidget {
const Consumer({
super.key,
required this.dataController,
required this.childBuilder,
});
/// An instance of [DataController]
final DataController<List<CharacterEntity>> dataController;
/// A widget builder callback
final Widget Function(BuildContext context) childBuilder;
@override
Widget build(BuildContext context) {
/// [AnimatedBuilder] accepts a [Listenable] as animation, and [ChangeNotifier] is a subclass of [Listenable]
return AnimatedBuilder(
animation: dataController,
builder: (context, _) {
if (dataController.isLoading) {
return const Center(child: CircularProgressIndicator());
} else if (dataController.hasError) {
return Center(
child: Text(
dataController.error ?? 'An error occurred!',
),
);
} else if (dataController.data!.isEmpty) {
return const Center(
child: Text('No data to display'),
);
}
return childBuilder(context);
},
);
}
}
Even without prior knowledge of the API response data structure, it’s possible to create a functional app by focusing on the application’s use cases. The data logic can be worked on independently of other layers.
Data layer walkthrough
In the data layer of the codebase, a CharacterModel
class is defined in the file data/character_model.dart
. This is a data class with the ability to serialize and deserialize JSON data.
You may be wondering why we have to define data classes for the domain layer (entities) and data classes for the data layer (models). The reason is simple: Clean architecture encourages the separation of concerns.
One of the rules of clean architecture is that a component should have only one reason to change. With this, we can ensure each layer has a clear and distinct responsibility.
For example, if the data structure of the API response changes, we’ll be forced to modify the model class. Because our presentation layer utilizes the entity data class, both the domain and presentation layers remain unaffected. This approach improves the overall modularity and maintainability of the codebase.
We can introduce mapper classes that will be responsible for mapping a model class to and from an entity class.
import 'package:ricky_and_morty/data/models/character_model.dart';
import 'package:ricky_and_morty/data/models/stored_character_model.dart';
import 'package:ricky_and_morty/domain/domain_export.dart';
/// An abstract class to convert a type of M to E type and vice versa
abstract class Mapper<M, E> {
M fromEntity(E entity);
E toEntity(M model);
}
class _CharacterModelToEntityMapper
extends Mapper<CharacterModel, CharacterEntity> {
/// Converts [CharacterEntity] to an instance of [CharacterModel]
@override
CharacterModel fromEntity(CharacterEntity entity) => CharacterModel(
id: entity.id,
name: entity.name,
image: entity.image,
species: entity.specie,
);
/// Converts [CharacterModel] to an instance of [CharacterEntity]
@override
CharacterEntity toEntity(CharacterModel model) => CharacterEntity(
id: model.id!,
name: model.name!,
specie: model.species!,
image: model.image!,
);
}
In the repository contract, entity classes are typically used as the return value. This means that the conversion of model classes, which are obtained from data sources, to an entity class (or vice versa) is done in the repository implementation.
But what are data sources? To use an analogy, think of a data source as a well of water. Requesting data from the server or database is akin to pumping water out of the ground. The repository acts as the channel by which water flows from the well to the tank.
Below is a snippet of a remote source defined in the app.
import 'package:dio/dio.dart';
import 'package:ricky_and_morty/data/character_model.dart';
/// Respect the dependency inversion rule by abstracting the remote data source
abstract class RemoteDataSource {
Future<List<CharacterModel>> getCharacters();
}
class DioDataSource extends RemoteDataSource {
final dio = Dio();
@override
Future<List<CharacterModel>> getCharacters() async {
try {
/// Fetch data from the Rick and Morty server via the API
final response =
await dio.get('https://rickandmortyapi.com/api/character');
/// Map the raw JSON to [CharacterModel] class
return (response.data['results'] as List)
.map((e) => CharacterModel.fromMap(e))
.toList();
/// Catch exceptions
} on DioError catch (e) {
if (e.response != null) {
throw Exception(e.response?.data.toString() ?? 'An error occurred');
} else {
throw Exception('An error occurred');
}
}
}
}
lib/data/data_sources/remote_data_source.dart
The repository contract is implemented in this layer, and it depends on the data source.
import 'package:ricky_and_morty/data/data_sources/local_data_source.dart';
import 'package:ricky_and_morty/data/data_sources/remote_data_source.dart';
import 'package:ricky_and_morty/data/mapper.dart';
import 'package:ricky_and_morty/domain/domain_export.dart';
class RepositoryImpl extends Repository {
RepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
});
final RemoteDataSource remoteDataSource;
final LocalDataSource localDataSource;
@override
Future<bool> addFavourite(CharacterEntity character) async {
/// Conversion from [CharacterEntity] to [CharacterModel] is done here.
/// The line [character.toCharacterModel] uses the mapper class under the hood
/// through the power of Dart extensions.
return localDataSource
.saveFavouriteCharacter(character.toCharacterModel);
}
@override
Future<bool> deleteFavourite(int id) async =>
localDataSource.deleteFavouriteCharacter(id);
@override
Future<List<CharacterEntity>> fetch() async =>
(await remoteDataSource.getCharacters()).map((e) => e.toEntity).toList();
@override
Future<List<CharacterEntity>> fetchFavourites() async =>
(await localDataSource.getFavouriteCharacters())
.map((e) => e.toEntity)
.toList();
}
With this, you are now equipped to build your app using clean architecture.
Now let’s look at the practical benefits of clean architecture.
Benefits of clean architecture
Some benefits of using clean architecture are as follows:
Fosters collaboration between teams
The use of clean architecture leads to the creation of loosely coupled applications. This means that there is minimal dependence between each layer. As a result, various teams can work on different layers of the application without interfering with each other’s work.
You don’t have to resolve complicated merge conflicts and fixes if multiple team members modify some part of the codebase simultaneously, breaking the part of the codebase you are working on.
Highly maintainable codebase
Since the system’s components are arranged systematically, it’s easy for team members to modify parts of the system without affecting other parts. Additionally, clean architecture helps team members identify the appropriate layer to develop new code, promoting a clear and organized development process.
Enhances code testability
By following clean architecture, you make your codebase testable. Separation of concerns makes it easy to test business logic independently of details. Clean architecture also encourages the abstraction of dependencies, making it easy to replace these dependencies with a mocked version during testing.
Enables easy replacement of dependencies with alternative implementations
Another significant advantage of clean architecture is that you can provide an alternative implementation quickly and effortlessly.
For practical purposes, let’s assume our team decides to use GraphQL instead of the RESTful API for our app. You can effect this change by creating a concrete implementation of the remote data source. Even if the data structure changes, you can add a model class to suit the new data structure.
class GraphQLDataSource extends RemoteDataSource {
final _httpLink = HttpLink(
'https://rickandmortyapi.com/graphql',
);
late final GraphQLClient client = GraphQLClient(
cache: GraphQLCache(),
link: _httpLink,
);
final query = '''
query {
characters{
results{
id
name
species
image
gender
}
}
}
''';
@override
Future<List<CharacterModel>> getCharacters() async {
final response = await client.query(
QueryOptions(document: gql(query)),
);
if (response.hasException) {
throw Exception(response.exception);
} else {
return (response.data!['data']['characters']['results'] as List)
.map((e) => CharacterModel.fromMap(e))
.toList();
}
}
}
To learn more about GraphQL, see our article on how to implement GraphQL.
You easily altered the underlying request client without affecting the other layers.
With this benefit, you can also defer making decisions that affect the details of the software (database, back-end technology, and so on) while focusing on what matters.
Downsides of clean architecture
As you might have noticed already, there are also some downsides to clean architecture.
Increased development time
Clean architecture can increase development time since modularization and separation of concerns are prioritized, leading to the creation of multiple files to achieve a simple task.
Introduces more boilerplate code
Boilerplate code is sections of code that are repeated in multiple places with little to no variation. For example, we have to define model and entity data classes, which aren’t very different.
The increase in boilerplate code also increases the overall code that needs to be maintained by developers.
Increases chances of over-engineering
Over-engineering is solving a problem in a more complicated way than necessary.
Using a clean core architecture for a simple application is over-engineering, as you will introduce unnecessary abstractions and layers, adding overhead complexities.
Conclusion
Your dedication to reaching the end of this article shows you care about the architecture of the software you build. The information presented in this post may have been a lot to absorb, but it was intended to demonstrate clean architecture and educate you on its existence and significance. With some more practice, you’ll soon get the hang of it.
In practice, applying core clean architecture principles to a basic application like the one we demoed is excessive. The main objective here was merely to demonstrate the concepts of clean architecture.
Take note that during software development, tradeoffs must be made. Some developers may prefer not to utilize use case classes because they consider it an unnecessary step. In this case, their state-holder classes depend directly on the repository.
While it’s not necessary to adhere rigidly to the rules of clean architecture, it’s essential to uphold its principles. Remember that clean code doesn’t only make your team and future coworkers’ lives easier — it saves your future self from dealing with a messy codebase.