Recently, I started re-writing a personal project with the intention of targeting more than just Android. I settled on using Flutter because I can target Android, iOS, Web and Desktop with minimal code changes for each platform.
I had the Android application built in Kotlin using Model-View-ViewModel (MVVM) and Clean Architecture and I wanted to apply the same architectural approach for my Flutter project and in this blog I will outline how it was implemented.
Fist, we will discuss what is clean architecture. I will go through the different layers in the architecture and what do they represent in a mobile application project. Then we will see how to get it implemented in Flutter.
What is clean architecture?
It is a way to architecture a system or a project which enforces separation of concerns between the different parts of the system. The architecture is split into four separate layers.
In a mobile application project, the four layers of the clean architecture are:
- Enterprise Business Rules (Data Layer)
This layer contains repositories implementation and model classes to facilitate the communication with the backend.
- Application Business Rules (Domain Layer)
This layer is the link between the UI side of the project and the data side of the project. It defines all the available use cases (things the application can do with data), and makes it available for the UI layers to execute.
- Interface Adapters (Presentation Layer)
This is where the application computes its UI state. This layer executes use cases then determines the state of the UI based on the result for the use case execution.
- Frameworks & Drivers (UI Layer)
Finally, the UI layer. This is all widgets and screen. This layer doesn’t know what are the use cases, who executes them and how. All it does is take a state then shows a state.
One important rule to make this architecture work for you is to ensure that each layer is only dependent on the layer below it. UI should depend on presentation only. Presentation should depend on domain, and domain should only depend on data.
Data Layer Repositories
Repositories are the classes you used to do CRUD operations on your data. They should provide you with a simple API to do those operations. If you have multiple data stores for the same piece of information, then you might need different repositories. However, those repositories should have the same API.
A Repository API is defined in the domain layer which allows data layer repositories to provide implementations and allows the presentation layer to know that a repository with a specific API exists.
Separating Models
One the main advantages for clean architecture is that it makes all elements of the application testable. A side effect things being testable is that the application behaviour is predictable. However, relying on API responses on its own provide an element on uncertainty.
For example, consider an API that can either return or remove a value.
With Blog key:
{
"name": "Amr Yousef",
"blog": "https://amryousef.me"
}
Without Blog key:
{
"name": "Amr Yousef"
}
If we want to map this JSON to a class we might have something similar to this
class Profile {
String name;
String blog;
}
But what happens when the JSON response doesn’t include blog key? Should we assign null or empty string to blog?
This is the kind of uncertainty that we should avoid. That’s why it is recommended to have a separation between the API models and models that will be used in other layers.
Since a repository API will need to know its return type, those models should be defined in the domain layer and Repository implementation should take care of mapping API models to domain models.
Here is an example that uses sealed_unions to represent the Profile model in the domain layer
class SafeData<T> extends Union2Impl<Available<T>, NotAvailable> {
static Doublet<Available, NotAvailable> _factory = Doublet();
SafeData(Union2<Available<T>, NotAvailable> union) : super(union);
factory SafeData.available(T data) =>
SafeData(_factory.first(Available._(data)));
factory SafeData.notAvailable() =>
SafeData(_factory.second(NotAvailable._()));
}
class Profile {
String name;
SafeData<String> blog;
}
Domain layer
In addition to defining APIs and dictating what’s required for repositories; this layer also host the business logic for the application. For example, in this layer you would compute whether a user is logged in or not.
Pieces of business logic are usually defined in “Use Cases”. A use case may or may not take some input, it will do some computation and return back the result of it. However, how would we represent this result? What would success look like and what would an error look like?
All your use cases need to have some form of unity. The API to execute and get a result of a use case should be constant across all the use cases that you have. This is a place where sealed_unions
is very useful.
The result of a use case can easily be represented by success or error. We can use generic types to define the type of the success data returned at compile time.
class Result<T> extends Union2Impl<Success, UseCaseError> {
static final Doublet<Success, UseCaseError> factory = Doublet();
Result._(Union2<Success, UseCaseError> union) : super(union);
factory Result.success(T data) => Result._(factory.first(Success._(data)));
factory Result.error(dynamic error) {
if (LoggerService.instance != null) {
LoggerService.instance.logError(error, error.stackTrace);
}
if (error is Error) {
return Result._(factory.second(UseCaseError._(error: error)));
} else {
return Result._(factory.second(UseCaseError._(exception: error)));
}
}
}
class Success<T> {
final T data;
Success._(this.data);
}
class UseCaseError {
final Error error;
final Exception exception;
UseCaseError._({this.error, this.exception});
}
This allows us to have the same return type Result<*>
across our use cases. It also makes the result of a use cases predictable which improves the testability of a use case and makes it simple to mock it.
Presentation Layer with BLoC
What is BLoC?
In any Flutter application, the UI is a function of the state. With every change in the state, the widget gets rebuilt to represent changes in the state.
Flutter offers you a StatefulWidget
which has a State
class that you can use to manage the widget state. The State
class offers a setState
function which will update the state and trigger UI re-build.
Using StatefulWidget
to maintain business logic and state computation logic makes the application hard to test. It also breaks the single responsibility principle as the widget is doing more than one thing.
BLoC helps in separating responsibility by removing the logic of computing the state from the widget. A BLoC class will execute the business logic when needed, compute the state then pass the computed state to the widget for rendering.
The widget and its BLoC communicate via Stream
. A state stream is exposed from the BLoC for the widget to listen and react to state changes. A stream sink is exposed which is used by the widget to push events, such as user clicks, to the BLoC for processing.
The advantages of using BLoC even for this sample application are great. First, the widget itself can easily be tested without worrying about the state update logic. The BLoC can be mocked during test to control the test harness which allows a greater control when testing the widget. The business logic itself can be tested in isolation. In the tests, we can increment the counter without the need of triggering onPressed
on the floating action button.
We also managed to convert the widget from Stateful
to Stateless
which reduces the amount of times build gets called. In addition, it gives greater control on what gets rebuilt. For example, the setState
sample builds the entire scaffold while the BLoC
sample builds only the body of the scaffold.
Disposing BLoC
Since we are relying on stream to push state updates and receive events, we will need to close those streams when the BLoC is no longer needed. The BLoC is not needed if its consumer (Widget) is disposed.
Didier Boelens has a great example on how to provide disposable BLoCs. The providing mechanism uses the dispose
function from a Stateful widget to invoke a dispose
function on the provided BLoC.
Another proposal is to use provider package to provide BLoC instances to your widget.
UI Layer
Since all our business logic is now contained in the domain layer and our app state management and computation is in the presentation layer, the UI layer becomes very simple. It has one role which is drawing the state to the user.
Widgets will be connected to BLoCs via streams. This means that you would want to use StreamBuilder
widget to draw your UI every time the state changes. Using StreamBuilder
allows you to have Stateless widgets which makes the UI very easy to reason with.
In addition, your widget test becomes very simple now, all you have to do is provide a mocked version of your BLoC and expose fake state streams to your widget. You can then switch between different states and test how your widget reacts to them.
Final Thoughts
The main goal of any project architecture is to make it easy to test, maintain and scale project. Clean architecture offers all three. The separation of concerns between layers is the key takeaway from it. If you want to a take an extra step, you can package each layer into its own plugin which will clearly outline the boundaries of each layer.