Very good layered architecture in Flutter

How to make architectural choices for scalable and maintainable code

Flutter
April 12, 2022
and 
April 12, 2022
updated on
April 12, 2022
By 
Guest Contributor

This article is an extension of my talk: Layering your Flutter App Architecture from Flutter Festival London.

Structuring code is one of the big – if not the most important – challenges when building large-scale applications. The decisions you make regarding architecture will impact how fast you can deliver new features in the future and how well you can maintain the existing functionality with the latest language and SDK requirements.

Architecture

Let’s start with the basics: what is app architecture?

App architecture is the logical way we organize our projects and how the various components interact with each other to fulfill the business requirements. We want to follow standards and make it easy to identify the components that we'll need to develop features in our codebase. The way we establish the relationship and interactions between these components can reduce or add complexity to our projects, which has a significant impact on the team's productivity.

Layers

Now that we know what architecture is, let’s define layers.

Layers are the components that compose your architecture. We can define these by assigning a specific responsibility to them. We should keep these layers simple, yet isolated enough to achieve a maintainable codebase.

Single Responsibility Principle

Single Responsibility is one of the SOLID principles from Object-Oriented Design. To sum it up, it tells us to:

  • Have smaller and simpler classes that have a unique purpose.
  • Better isolate components for testing. We shouldn't have to mock a lot of components to test specific functionality.
  • Maintain specific functionality without affecting other parts of the code. By decoupling code, we make each chunk of less dependent on the other. This way, when we make changes, they will have less of an impact on the overall codebase.

Responsibilities

From my experience on mobile applications at scale, there are three main responsibilities in an app:

  • Data layer: This layer interacts directly with an API (REST API or a device API).
  • Domain layer: This layer transforms or manipulates the data that the API provides.
  • Presentation layer: This layer presents the app content and triggers events that modify the application state.

Depending on the architecture you choose, these components can have different names. In some architectures, there may be multiple layers to handle specific aspects of these responsibilities, but these are implementation details. The essence will remain independent of the architecture.

Architectural Choices

We’re going to mention what we think you should and shouldn’t do to structure your app the best way possible, so you have the foundation to build a scalable and maintainable application. Note that this is an opinionated approach to architecture, as always, feel free to implement the architecture that makes the most sense for you and your team.

Define your layers

As I said before, you can create different layers to handle the three main responsibilities in your app. Defining layers is a critical step, as you may want to keep them as simple as possible without compromising maintainability.

Very Good Architecture

At VGV, we follow an architecture that has four layers.

  • Data layer: This layer is the one in charge of interacting with APIs.
  • Domain layer: This is the one in charge of transforming the data that comes from the data layer.

And finally, we want to manage the state of that data and present it on our user interface, that’s why we split the presentation layer in two:

  • Business logic layer: This layer manages the state (usually using flutter_bloc).
  • Presentation layer: Renders UI components based on state.

The domain layer converts raw data into domain-specific models that are consumed by the business logic layer. The business logic layer keeps an immutable state of the domain models that the repository provides. Also, the business logic layer reacts to the inputs coming from the UI and communicates with the repository when changes need to be made based on the state.

Depending on the implementation of the API, we can manipulate data without creating an actual API package, but we will talk more about APIs and packages in the next sections.

Avoid mixed responsibilities

This is the most common cause of chaos in projects.

Not having a proper separation of concerns provokes errors that are difficult to track. For example, some functionality that would ideally be in a data layer component is located in a presentation layer component, causing an issue that at first sight seems like a presentation issue, but in fact is a data issue.

import 'package:firebase_auth/firebase_auth.dart';

class StartPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StreamBuilder<User?>(
      stream: FirebaseAuth.instance.userChanges(),
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          return const ProfilePage();
        }

        return const SignInPage();
      },
    );
  }
}

TL;DR: don’t leave data or domain interactions within widgets.

Be consistent

Follow the patterns your team has defined for the project. Don’t take shortcuts with the layers. Each layer should have a chain of relation that prevents interactions between layers that are not directly related. For example, the presentation layer should not call or interact in any way directly with the APIs on the data layer.

If we establish that our data layer will be a dependency of our domain layer, we must respect that in all the features we develop.

Things like naming can be key in making it easier to know where things are in a project. Remember naming is just a convention. In the end, some developers call the data layer infrastructure and it’s okay, it’s just a name. But try to keep that naming consistent for your architecture.

If we’re not consistent, the codebase gets messy and more difficult to navigate through. You may also run the risk of teammates getting confused if you're not following the rules you've established in the codebase.

├── lib
|   ├── posts
│   │   ├── bloc
│   │   │   └── post_bloc.dart
|   |   |   └── post_event.dart
|   |   |   └── post_state.dart
|   |   └── models
|   |   |   └── models.dart
|   |   |   └── post.dart
│   │   └── view
│   │   |   ├── posts_page.dart
│   │   |   └── posts_list.dart
|   |   |   └── view.dart
|   |   └── widgets
|   |   |   └── bottom_loader.dart
|   |   |   └── post_list_item.dart
|   |   |   └── widgets.dart
│   │   ├── posts.dart
│   ├── app.dart
│   ├── simple_bloc_observer.dart
│   └── main.dart
├── pubspec.lock
├── pubspec.yaml

Build with reusability in mind

This advice is for the data layer specifically. Since it is a raw implementation, it can be used in many domains for different purposes. Components in the data layer are also often excellent candidates for open source because they are not tied to the specific product/application and could benefit the larger community.

That’s why we isolate our data and domain layers in packages. This allows us to import them into other apps or publish them on pub.dev if desired.

Sometimes we build these layers tied to a specific use case, for example, adding a local storage solution. We can create a generic API to store encoded JSON strings instead of expecting a specific model, so we can reuse that API as a local storage API in other domains.

You can see how to implement a local storage package with an abstract declaration and concrete implementation in the flutter_todos example from the bloc library.

Use the right amount of abstraction

When we develop a new feature that uses an external package as API for the data layer, we have two choices:

  • Create a repository that has that API as a direct dependency.
  • Create an API wrapper for the package.

I suggest you go for the first approach if the package or plugin you’re using as a dependency:

  • Has a clean and simple API.
  • Does not need too many dependencies to test it.
  • Does not need any configuration for the classes it uses for the functionality you have.
  • Solves a specific use case and you are willing to have it as the unique implementation of your API.

A good example of a simple API that can be used directly with a repository is package:stream_chat_flutter. A full architecture example with this dependency can be seen in our chat_location application, which also has a full tutorial.

Otherwise, it’s better to create an API package with a class that wraps that dependency to avoid having all the implementation details in the repository. You can see an example of a custom API package in our spacex_demo application.

Don’t over-engineer

This is related to the previous choice, we don’t want to abstract less than we should, but over abstracting or over-engineering can be time-consuming and increase complexity.

An example of this can be with models. We usually create JSON serialization and deserialization functionality for models, but we know we can change to an API with a different format.

To be honest, how often are we using anything other than JSON to parse data? In our case, rarely. In this case, abstracting the base properties of the model so we can later add the JSON implementation to it sounds like a waste of time if we never change the current format.

To use or not to use packages

Okay, speaking of over-engineering, here is an opinionated take:

If your project is small, maintaining it with packages may be a bit overkill. I personally always use packages because I’m obsessed with component isolation and things like migrations come easier.

This way, you can have one teammate migrating the accounting repository to Very Good Analysis 2.4.0 and another migrating the retail API to Dio 4.0.0 at the same time, so you can tackle fixing lints and null safety refactors without stepping on each other’s toes.

Choose the right tools

Let’s be honest, we can’t do it all by ourselves.

It’s important to have strict criteria to decide on which package or tool you are going to use to solve a problem in your app.

I suggest taking into consideration things like activity on the GitHub repository, pub.dev metrics, test coverage, who’s maintaining it, and other factors that are important to you and your team.

You can check out our favorite tools in our Top Flutter and Dart Packages blog.

See what the experts are doing

One thing that helps you get your projects to the next level is to check out repositories of remarkable developers from the community. Many Flutter community members have a lot of public repositories on GitHub that you can study and criticize to learn from and also improve.

We at VGV have some interesting ones that expose our best practices for how we build apps, and we’re open to answering your questions about them! Be sure to check out I/O Photo Booth (a great production app that uses Flutter on the web), chat_location (a good example of bootstrapping data providers and repositories), and flutter_todos in the bloc library repo (pay attention to the architecture in this one).

Refactor

Because we’re humans, we make mistakes. Recognizing and learning from those mistakes is as important as layering your code.

Your first approach might not be the best one, and you need to analyze every idea or practice you’re following to see if it’s necessary.

After all, remember you don’t need to be perfect to be consistent and make your project successful. I strongly believe we should do our best when building apps, but there’s always a level of good that will be enough to help you succeed.

If you need to refactor, don’t be hard on yourself!

Share what you learn!

I’m sure you have a lot of new ideas from this blog post, and you will have even more if you keep investigating. We’re excited to see what new pattern or practice you discover, so make sure you do some type of tutorial about it so the community can learn too! Or just tag us on a Twitter thread where you explain it, that works too.

Additional Contributors

More Stories