As a completely agnostic framework, Flutter is great when it comes to let one free to choose whatever design pattern and architecture it desires. You are free to do all the spaghetti code you want, you can even do all you project in just one file if you wish (although I strongly advise you not to do that). So, when it comes to building up a new project, questions like, ‘what architectural decisions are we going to make?’, ‘which libraries are we going to use?’, ‘what are we going to implement ourselves?’ are going to pop up at the beginning of any exciting new project. In my experience, MVP can be so overwhelming sometimes, tempting not to waste time answering these initial questions, or even not to think about them or patterns to follow in the project, and that is ok, if you are aiming to build a proof of concept, something to show a client and boost a project negotiation or help solving product related issues. But if you need to project which aims to be enduring and maintained by your team or other company, I really believe that an architecture thought to fit to real-life situations, will really help you to build a more reliable, testable and maintainable piece of software.
Sometimes we take an architecture as a role model and follow it blindly just because someone promised that you were going to avoid a bunch of problems that may never occur during your project’s lifecycle. Here we dared to follow another approach, we’ve started with a robust, well tested and well used architecture, named the Clean Architecture proposed by Uncle Bob, and we decided to just shape it and fit it to our real projects technical demands. And we came up with our version of implementation, that may or may not resemble the original ideia, but for us, the important thing was to try to use just as much complexity and abstraction as our projects needed. A disclaimer is important here, far from trying to make a self-contained explanation or to make a pitch of a new revolutionary architecture, the aim here is to document our process, and motivations that moved us towards the decisions we’ve made.
Starting with a good initial implementation
We thoroughly followed the path of Reso Coder and saw the implementation he proposed to the clean architecture and decided it was a good place to start with. This is his schema for his clean architecture’s implementation:
From here we’ve started coding and experimenting to try to get a feel of things by ourselves. As described below.
We’ve started with three layers Domain, Data and Presentation like in the original proposal. The Domain layer is the most stable one, and represents components of the system that define the business logic of the application and is as independent as it can be of other layers components. Initially this layer was thought to have Usecases, Entities, and Repository Interfaces. But then, after some coding and experimentation, I’ve seen that the repository interface wasn’t really necessary, it looked like we were just trying to keep the purity of the concept of domain not having dependencies from other layers. We’ve decided to not use interfaces, create repositories in the Data layer and just use them!
The Data layer is the application outmost end, is the place where it communicates with device’s APIs, remote APIs, local databases and so on. Initially it was thought to have Repository implementations, Models and Datasources. As said before, we’ve replaced the implementation of a contract (abstract class interface in the Dart’s context) defined in the Domain layer by an independent implementation of repositories, and it seems to satisfy our needs for now. We then realized that datasources shared the same issue of Repositories Interfaces, it looked to us as they were implemented just for the sake of purity of concept, so we abandoned their use.
We then have the Presentation layer that is responsible for building the views to interact with the user and receive user’s interactions incomes. This is the most framework dependent layer, it has a multiplicity of implementation options to follow. We’ve started with Views communicating directly with Usecases, then we went for Views that didn’t communicate with Usecases directly, and instead we had Controllers that communicate with Usecases. But it felt that we were adding unnecessary complexity and finally we’ve decided to follow a pattern with SmartViews, and DummyViews, both listening to state changes from the Usecase and emitting events (Kind of a spoiler here! But hold on!).
As the navigation, showing modals, toast, snackbars and so on, are natively dependent on the context in Flutter, we’ve decided to split the feature/module flow into these two parts: SmartViews are responsible for the navigation, and it can show feature related notifications, but the actual job of drawing the view was decided to be of DummyViews responsibility, and they are also responsible for showing view related notifications (i.e. related with requests that are triggered at the DummyView). They received these names because a SmartView actually knows the entire flow of the feature, it coordinates the navigation following orders from the usecase, and a DummyView doesn’t know anything about the actual flow.
We felt like a change in the point of view when it comes to where is the fundamental source of truth of the entire system, and we understood that the database and the backend of the system hold that title, and then the concept of Entity would be more fit to live in the Data Layer as it communicates with backend, and conversely the concept of Model would be more fit to be in the Domain layer. (Another disclaimer, this point is just about semantics, and we believed that this made more sense to us).
At the beginning, there was 8 to 9 architectural elements, and we’ve decided to keep 6 of them, by fusing different elements and their responsibilities or just by not using some elements.
To materialize the architectural decisions and to make it more useful to our team, we’ve started to choose flutter packages to suit our needs, following the philosophy of using as fewer third-party packages as possible.
- To implement Usecases as the Business Logic Components of the application, we felt that the natural decision was the Bloc package, as it is a mature, robust, and well-known solution for state management in Flutter context, and it is easy to test as well.
- To represent Bloc states and events, Models and Entities, we’ve decided to use Freezed package, as it is an elegant and easy solution to implement sealed classes, union types and data classes as it is not available by default in Dart (yet!). It’s based in code generation and it has ready-to-use integration with Json_serializable package that we’ve decided to use to deal with serialization.
- To deal with dependency injection we’ve decided to use a simple service locator: GetIt and define a simple set of rules-of-thumb to keep consistency over project’s development and maintenance lifecycles. Rules-of-Thumb:
— If a class depends on another, it must be passed at instantiation by constructor and the instance control should be made by the service locator.
— Dependency injection setup for a module/feature should be split in each feature (each feature/module will contain its own file explicitly defining its dependency injection setup).
— The service locator should never be referenced in a place other than a constructor call.
- To deal with internationalization we’ve decided to use pure Object Oriented Programming, and keep all Strings of the application as static constants of an implementation of an abstract class to be defined.
- To deal with navigation we’ve decided to use the Flutters native Navigator, as it poses as a complete solution, and with the arrival of Navigator 2.0 api, we think that it is an elegant declarative solution to deal with navigation.
- To deal with http requests we’ve decided to use Dio package as it is a better option than the native Flutter solution presenting good features like Interceptors, base options like headers, base url etc., and is a well known and popular solution. Then we made a wrapper over the client to model errors and responses into our system context.
- To persist sensitive local data we’ve decided to use the package flutter_secure_storage because it is a popular and performatic solution when there isn’t the need of storing complex data.
We’ve defined helper classes/types to deal with the Result of actions, the Result type (generic Freezed union type that has two types: a Success and a Failure), the RequestStatus type (generic Freezed union type that has four types: Idle, Loading, Succeeded, Failed) to help dealing with the visual response of requests, the AppError (abstract class that is implemented in each relevant particular error type), and to help with forms, the Maybe type (generic Freezed union type that has two types: Nothing and Just) used in the definition of FormField type (generic Freezed dataclass containing the name of the field and the Maybe instance representing the actual possible inputted value to the formfield).
As we understand, software development is an always evolving craft, and software Architecture seems to evolve with it. We certainly did evolve during the past 2 and a half years of work with this amazing framework that Flutter is, and our understanding and way of doing things with it did evolve too, this article is a consequence of that. The main goal of this article was to document our current architectural implementation with the hope that this might inspire and help others to develop design patterns that would best fit their needs. Currently this implementation seems to satisfy our needs very well, so don’t be shy, try it too!