Understanding the nuances of a modular codebase in Flutter can sometimes feel like uncharted territory, even for seasoned developers. In this blog post, we’re about to dive deep into this concept, shedding light on its potential advantages, strategies for implementation, and the challenges you might encounter. We’ll explore key aspects, including breaking your project into manageable chunks, package dependencies, and managing multiple packages with tools such as Melos. To add a dose of reality, we’ll share examples of real-world applications built using a modular approach in Flutter. Whether you’re a beginner dipping your toes into Flutter or an experienced developer, this blog post provides a clear roadmap for navigating the terrain of modular codebases within Flutter.
What modular codebase actually means?
Outside of Flutter, like in general scope, we have monolithic applications. And modular applications are something our colleagues on back-end development have been doing for some time already. They split their project into microservices, and they set up different boundaries for these microservices.
For example, it can be a completely different programming language or a completely different framework we created for the microservice. However, in our case, it will usually be just a Flutter or Dart package.
So this is what I understand by modular code base – it’s when you divide the project into smaller projects and don’t have one big project.
How do Flutter packages work?
Before we jump into modularization itself, let’s talk about the packages first. There are two files here; it’s pubspec.yaml, and it’s a hello.dart file. Is this a valid Dart dependency?
Yes. it’s because this can be imported into a different dependency. It can be used as a package in another dependency, and also you can add other packages to this dependency.
How do we usually do that? We just give the dependencies and the name of our package. We don’t even have to set up a version of the package – we can just leave it like that, and it should work. Flutter will figure out what version of the package it should use.
But what’s happening under the hood when we are executing dart pub get? If you are working on a Dart project, you don’t have to run the flutter pub get we use in our everyday workflow. We can just do a dart pub get.
What happens under the hood of your codebase?
The first thing that gets created is a pub cache directory. This directory is outside of the project of your repository. Whenever you use a package from a pub.dev, this package needs to be downloaded into your computer, which is how it is being used. In the case of Mac .pub-cache will be stored in the user’s leading directory. On Windows, the directory defaults to %LOCALAPPDATA%\Pub\Cache, though its exact location may vary depending on the Windows version. And there, you will find all of the Flutter and Dart packages you use across different projects
The second thing that is getting generated is the pubspec.lock. Do you remember how you don’t have to specify the package version you are using? Well, Flutter has to figure it out somehow and save it somewhere. This is the content of this file, and one of the things that you can find here is the version number. If you are wondering what version of the package is being used in my project, you can find it in this file, and also, you can find some other exciting information about the package here.
And the third thing that is getting generated is the whole directory that you see here. .dart-tool directory contents are not being checked into your git repository. The reason for that is straightforward.
In this file, you will find the path of your packages on your computer. So if you are ever offline and need to fork a package, you don’t have to do it from a git repository. You can find it on your computer, assuming you already use it in any project. This is where you will find it. As I said, you don’t commit that to your git repository.
But this will not work for us because we would need to keep all of our modules on pub.dev
Starting your modular codebase in Flutter
Usually, we would need to get the package, publish it to pub.dev, and somehow access that. When we create a private repository, we probably don’t want to do that. So, how do we solve it?
We will host our own pub.dev. Like any software related to Flutter and Dart, pub is open-source software. The pub code is available on GitHub, so you don’t even have to reverse engineer pub in any kind of way. This documentation encourages you to write your back-end for the custom repository with packages.
For example, you can see the server’s response from the API when you run flutter pub get. Fortunately, we don’t have to write our backend code because open-source alternatives are available. One exciting option is unpub made by Bytedance, the company that created TikTok.
If you want to avoid self-hosted solutions, many cloud-based solutions are available. One of these is JetBrains Space, which is easy to set up from the UI level without any programming.
Authorization with token
When working with private package repositories, you want to keep them hidden from the outside world. You can create authentication for the pub through your terminal by entering the secret token that authenticates the package repository. You can also set this up as an environmental variable, which will work with any CI/CD solution.
Use your hosted package
To use our hosted packages, we can add a parameter to our dependency with the URL Flutter should use to get the package. You can also set up a version here if you want to. However, setting this up for each package might be tedious, so there is a way to do it globally by setting an environmental variable.
If you don’t want a custom repository because it’s a lot of work, you can access the packages locally by providing a path to your package. You can also do it directly from git and even target a particular branch that you want to use.
Examples of modular apps in Flutter
Now that we know how to source our packages differently let’s talk about some examples of modular apps. A prime example is the eBay Motors application. They created a blog post stating that they needed to divide the logic and domain of the application with clear boundaries. These boundaries for them are separate Dart and Flutter packages.
Another example is the FlutterFire repository. Each package related to Firebase is a separate Dart package. These are stored in a package directory within the git repository, showcasing another example of how modular organization is being leveraged.
This brings us to strategies for organizing your codebase into modules. As mentioned earlier, each package related to Firebase is a separate Dart project. This is not a surprise, but it’s interesting to note how this is stored in the git repository. They have a package directory listing all the packages, which is not the root of the git repository. This is another example of how modular organization is used in large-scale projects.
Strategies for organizing code into modules
In the case of the eBay application, they divided their application per feature. This means that they identified certain features, such as authentication, payments, or user profiles, and each of these became a separate Flutter project with clear boundaries indicating where something starts and ends.
Another approach is the division per layer. Here, we can see that our project is divided into different layers. In this case, the presentation layer would likely be a Flutter project, while the domain and data packages would be pure Dart dependencies.
I appreciate this strategy because it prevents us from making architectural mistakes at the compilation level. For instance, if someone tries to import some classes directly related to Flutter in the domain package, or vice versa, they won’t be able to do that because there is no such dependency in the pubspec.yaml. This prevents errors usually caught in code review, but you can avoid them at the compilation level this time.
You can also combine these two strategies. Here, we have a division between the Dart packages and Flutter packages. For example, we use a local database in all of our features across the app, but authentication is only used in one element or package. This division can break our codebase into smaller pieces if that’s something we want.
The file structure in modular apps
Regarding file structure, this is an example taken from the Firebase package. We have a pubspec.yaml file here at the top level, and inside, we have packages. Each one of these would be a separate Flutter or Dart project. Additionally, there is usually one Flutter project that brings everything together.
Let’s look at an example of how we leveraged this at our company to save time and create a working solution. We were developing a food delivery app, which included a customer app for people to order food, a business app for restaurants, and a Flutter web admin panel used only by the employees of the food delivery company.
As we started working on all three front ends, we quickly realized they all used similar endpoints, data models, and authentication methods. If we approached this with a classic monolithic application style, we would do a lot of copying and pasting between different projects. So, instead, we decided to use the combined strategy for modularization.
We divided the presentation layer into three parts using a domain package that handled the whole logic. In terms of the file structure, we didn’t have a pubspec.yaml outside at the root of our repository. Each of the admin, business, and customer sections is a separate Flutter project that uses a package, in this case, a domain. We can target the local path for the dependency.
Managing all of these packages is tricky. If you have to execute a build runner or do something like flutter pub get in all of the projects, you might find yourself either executing multiple commands for the same order for different projects or creating a bash script that will do it for you. Fortunately, there is a tool that allows us to manage multiple packages.
Managing multiple packages with Melos
The tool we’re discussing is called Melos, and while I’d love to dive deep into this tool, I’ll highlight the essential information for now. As mentioned earlier, executing commands across different packages simultaneously is helpful if you work with many packages. Additionally, Melos offers automatic versioning and publishing, which will be incredibly useful if you work on open-source software. If you’re using a lot of packages, you should also list the packages and see what’s being used under the hood, allowing you to create a dependency graph.
Now, let’s talk about some benefits of this approach. Sometimes, it leads to easier project management and a simplified codebase. When your project is divided into smaller packages, it’s easier to manage, primarily if you work in large teams. This leads to another point: enhanced team collaboration. You can assign developers to work on certain parts of the app. For example, in large corporations, you might have a team that works only on authentication, another on payments and checkout, and so on. This approach can be helpful when many groups are working on one codebase.
As I mentioned in the case of our food delivery app, this approach also leads to reusability and reduced redundancy. We didn’t have to copy and paste a lot of code across many different projects; we had it in one place.
However, like any solution or approach, it has some challenges and downsides. One of these is the initial setup complexity. You have to make difficult decisions at the very beginning of your project. Do we divide by feature or by layer? How do we want to approach this? It’s also hard to test because the benefits of this approach are visible only at a specific scale.
Another challenge is module interdependency issues. If you find yourself in a situation where you need to use a particular data model in a specific layer, and then it needs to be moved or transported into another layer, you must figure out a way to do that. Will you do it through some kind of dependency injection solution? By the way, the injectable package has an API called micro packages, and it’s a perfect dependency injection for this kind of approach we’re discussing here.
So, who is this approach for? It’s for large teams working on large modular codebases. As I already mentioned, in corporations or companies where you have teams with expertise in specific topics and they just want to work within the scope of their own thing, this would be very useful. If you’re working on a project alone, you can try it, but it might be challenging to see the advantages.
Interested in implementing modular codebase approach in your Flutter mobile application?
Harnessing the power of modular codebase in Flutter
To wrap up, implementing a modular codebase in Flutter offers many advantages and introduces a new way of project organization that promotes efficiency and team collaboration. While the initial setup might present challenges, the long-term benefits of code reusability, reduced redundancy, and simpler project management are substantial.
The concept of a modular codebase in Flutter might initially seem daunting, but with time, understanding, and the right approach, it’s an invaluable asset to any developer’s toolkit. Whether working in a large team on a complex project or just starting your Flutter journey, harnessing the power of a modular codebase can revolutionize your development process. So, start small, experiment, learn, and watch your efficiency soar.