Clean Architecture and state management in Flutter: a simple and effective approach
At tappr.dev, we develop high-quality mobile apps for iOS and Android devices using Flutter. We use a simple and practical architecture that helps us deliver exceptional quality to our clients in short periods of time.
What is Clean Architecture, and why is it important?
Clean Architecture is a term popularized by Robert C. Martin that describes a good architecture for the structure of the different parts that make up the source code of an application, among the necessary connections between these parts.
And what do we mean by "good architecture"? It should allow the developers of an application to easily understand its behavior and therefore let them modify and evolve the application quickly, in a sustainable way over time, and without introducing defects during the process.
Besides Clean Architecture, other very similar approaches define their implementation of what a good software architecture is (Hexagonal Architecture or Onion Architecture, for example). But the main attribute of all of them is the separation of concerns between the business logic and the user interface, preventing the former from having any knowledge of the latter’s details.
Next, we are going to apply these principles to define a good architecture for our Flutter applications, without increasing the accidental complexity of our software. With this, we will obtain an uncomplicated base that will allow us to build and evolve our applications over time.
How to implement user interfaces with Flutter?
As we just saw, a good architecture must separate two main concerns: the business logic and the user interface. In the case of developing applications with Flutter, it will be our user interface framework, so we have to understand how it works and what its traits are.
Flutter is a declarative framework for designing graphical interfaces, like React, Swift UI or Jetpack Compose (as opposed to imperative options like UIKit or Android SDK). This means that Flutter draws the user interface reflecting the "state" of the application.
For example, when a user presses a button that changes the state of the application, the interface automatically redraws to reflect this new state. This declarative approach to interface design has many benefits, but it especially highlights the fact that there is only one path in the code to define how the interface is displayed.
This feature aligns beautifully with our requirement to delimit communication between the business logic and the user interface. Although using a declarative framework is not a requirement to implement a good architecture, since we can abstract any approach to satisfy our needs, this simplifies the implementation of our architecture quite a bit.
How to manage state in Flutter?
We can distinguish two types of states we will need to handle in Flutter. A "ephemeral" state, or user interface state, and a "persistent" state, or business state. As you can see, each state type belongs unequivocally to one of the responsibilities defined by a good architecture.
Examples of UI state could be the current page number in a listing, the progress of an animation, the tab selected in the navigation, etc. On the other hand, examples of business state can be user preferences, the content of a shopping cart, product reviews, etc. We also have to take into account that what for one application may be interface state, for another may be business state (for example, if we want the selected navigation tab to remain after closing the application, or even synchronize between all the user’s devices).
Any change in the application state, whether interface or business, can cause a redraw of the user interface if we need it.
Here, we will see an example of a simple interface state change. It is an ephemeral state that lasts in memory only until the application is closed. To do it we are going to use Flutter's "setState" primitive:
We can implement this same logic as business state in case it is a functional requirement of our application, for example, by retrieving and persisting the counter using an HTTP API. To update the user interface, we will continue using Flutter's "setState" primitive:
In this case, we have also added a simple loading screen, since communication with the API is asynchronous, and an error message in case a problem occurs in the process.
How to implement Clean Architecture in Flutter?
So far, we have seen two simple examples of how to manage the application state in Flutter, and how to trigger UI updates to reflect the changes.
In these examples, the business logic is limited to just two calls to our API client (which abstracts all the communication, authentication, and parsing details). But the entire business flow happens in our interface code (in a Flutter widget, in this case). For small applications, this separation might seem enough, but we can end up mixing business and interface code quickly, making our implementation more fragile and difficult to evolve.
That is why one of the objectives of this architecture, and it should be of any good architecture, is that it should be effective from the first moment (regardless of whether we foresee that the application will be smaller or larger, or more or less successful). It has to be easy to start developing any application without any additional complexity beyond what is essential for the business, and allow us to evolve it sustainably over time.
So finally, we are going to define where to execute the business logic, and how to communicate with the interface. To do this, we will use a widespread pattern in the development of user interfaces, which receives different names and slight changes in its implementation depending on the framework or architecture in which it is outlined. We will call it "interaction", since it encapsulates a concrete and unitary action or interaction of the user with the application. This interaction is equivalent to the "presenter" in Model-View-Presenter or Humble View, the "view model" in Model-View-ViewModel or the "use case" in Clean Architecture. The most important thing when applying this pattern is to respect the separation of responsibilities and the direction of communication.
Let's modify the last example by extracting the interactions with our business logic:
Now, the interface code is only responsible for executing the interaction that the user requires at any time and updating the interface with the result. The interaction carries out the entire business flow (making use of the API client, processing the response and possible errors, and notifying the result to the interface).
This way, we have managed to clearly separate the different responsibilities of the code, and we have also gained clarity about the interactions a user of the application has access to, easing the developers' understanding of the application.
In addition to the patterns we have seen here, we will also apply other software design patterns commonly used in the rest of the architectures mentioned above, such as the repository pattern for data access.
Why not use an external state management library in Flutter?
With this architecture, the use of external libraries for state management, like Riverpod or Bloc, stops making sense since the state of the application is simplified as much as possible, and the existing primitives in the framework itself are more than enough.
By themselves, these libraries tend to be counterproductive to achieve a good architecture (Riverpod flaws, Riverpod or Bloc), since they introduce their own complex concepts that require a steep learning curve, such as “Cubit”, “Provider”, “Reference”, etc. They often require more repetitive code than without them to define and manage the state of the application, so they are also ineffective for smaller applications. Finally, they may not support more complex scenarios, and they represent a large vector of errors by introducing bugs, updates without compatibility, security flaws, etc.
It is true that here we have seen a very simple example of updating state, but it is very easy to apply this architecture to use data streams, or other data structures, and update the interface in real-time without having to make any queries, for example.
Conclusion
You may have noticed that we have left aside the typical jargon that we usually hear when talking about software architecture, and we have focused on the fundamental concepts to understand what a good architecture is. Terms such as layers, entities, gateways, ports, adapters, etc., often make the understanding of a clean architecture more complex, but it is without any doubt also necessary to study and apply them when appropriate.
In the example repository you can see some of these concepts applied, which makes it easier to understand them. In it, you can also find a modern testing strategy that aligns with the fundamentals of a good architecture.
If you found it interesting and would like to delve deeper into everything related to a good software architecture, app testing, and high-quality app development, you can contact us and we will help you apply it in your company.