Some insights on how to implement a production-quality app using Dart + Flutter
Recently, I received a task to build a shopping app prototype using Dart + Flutter. The main requirements were to use the real-world e-commerce API of free choice, implement lazy products' loading and caching, and build products and their details page. All the other implementation details were left up to me, hence I had to decide on the app architecture, state management solution, third-party packages, etc. So in this article, I will reflect on my choices, challenges and other details while implementing a production-quality prototype.
Overview
Flutter Shopping App prototype is a simple open-source application implementing the following features:
- State management using BLoC;
- Immutable state, models;
- Lazy loading implemented as an infinite list;
- Loaded products' caching;
- Material Design UI (not great, not terrible);
- Clean code structure: code is separated into modules by feature, commonly used components, utils, and constants are extracted;
- Using a real-world Best Buy API client, which is implemented as a separate Dart package;
- Unit/widget tests covering pretty much the whole code.
The application is implemented using the following dependencies:
Also, the following DEV dependencies are used for faster development and testing purposes:
As already mentioned, the code is open-source and could be found here.
App architecture
One of the first things to decide on any new project is the architecture of the application. In the beginning, it could be hard to decide on the perfect solution, but here are some tips:
- Consider the structurization of your app as an iterative task. If you made some decision at the beginning of a project, you could always adapt it later. Specifically, some developers (including me, but I am working on it) try to plan every single possibility, which may or may not occur in the future. Hence, when something unexpected happens, e.g., you need to rework your code because a component must be reused elsewhere, it causes a lot of stress - it is not going according to the plan, right? Well, it's not a big deal - start small and grow your application's architecture together with the project and its changing requirements.
- In Flutter, the architecture of your app strongly depends on the state management solution you will use. For instance, if you choose BLoC, you know that your business logic will be separated from the UI and extracted to the BLoC classes, you will also need to create the related event and state classes, etc. Then, if you choose something like a change notifier/provider, it makes sense to structure your app similarly as in MVVM. Therefore, structure your app having a specific state management solution in mind, it would eventually save you some time in the future.
- Consistency is the key. If you or/and your team decides on the specific app architecture, please, stick to it. Consistent code and file structure help to find a specific component, widget or any other file in the project faster which results in easier maintenance. I would also recommend defining a decent set of static analysis rules in your project by adding the
analysis_options.yaml
file. If you are not sure of which rules should be included, you can use thepedantic
package to enable the list of linter rules that Google uses in its own Dart code. More info about the static analysis options could be found here.
File structure
As far as I know, there are no official guidelines on how to structure the project files for your application. Usually, Flutter developers choose the layered architecture, hence they structure their project files like this:
This kind of file structure works completely fine in small to medium size projects, but I also see some disadvantages there:
- Features become hard to decouple. For instance, you are implementing authentication for your app. By having this file structure, the UI logic goes to the view (presentation) layer, business logic goes to the corresponding data layer and so on. Now, that you are building one another project, you could reuse the same authentication logic, but it is scattered across different layers of the app. Hence, it requires a lot of effort to extract all the authentication-related logic to a separate package and reuse it later.
- Layers could become overwhelming. Since you put all the UI logic in one layer and all the business logic in another one, the number of files in a single layer starts to grow very fast while implementing one feature after another. Later, for instance, you could end up having a dozen different services in the same
services
folder which makes it difficult to find the specific file, and maintain a clear structure.
For the Shopping App prototype, I decided to split my codebase into modules:
├───android
├───assets
│ └───fonts
├───ios
├───lib
│ ├───config
│ ├───constants
│ ├───modules
│ │ ├───cart
│ │ │ ├───bloc
│ │ │ ├───models
│ │ │ ├───pages
│ │ │ └───widgets
│ │ ├───products
│ │ │ ├───bloc
│ │ │ ├───models
│ │ │ ├───pages
│ │ │ ├───repositories
│ │ │ └───widgets
│ │ └───product_details