A modular project approach for Flutter

Nino Handler
Flutter Community
Published in
9 min readSep 18, 2023

Or: Guide to a package-based modular app architecture

A future-proof app design — build your app with feature packages

Motivation

At uRyde we are developing mobility services for companies, schools and communities. A lot of the largest companies in Germany are already our partners.

We are a small team and keeping up the pace is crucial for us. We therefore develop features as fast as possible. We are truly agile when it comes to listening to our customers’ and users’ wishes and needs. It’s therefore extremely important to prevent common development problems already from the beginning which come naturally in such a fast-paced environment.

One of those problems is definitely spaghetti code. Although it sounds yummy, it definitely turns your code into a demon, into something like Tatanga the final opponent Mario faces in Super Mario Land. He says:

> I am not interested in what Mario would do! Tatanga does as he pleases!

And that’s exactly what your code will say

> I am not interested in what Nino would do! Spaghetti code does as it pleases!

Your code will become Tatanga or in other words a spaghetti monster. At one point it will do as it pleases. A point of no return (dramatic music playing).

I slowly had become a pirate of the Church of the Flying Spaghetti Monster. And although the beginning of this relationship was wrong it was after a while that I received a holy gift in deep prayer.

Depiction of the Flying Spaghetti Monster giving the “I’d Really Rather You Didn’ts” tablets to Captain Mosey. → https://commons.wikimedia.org/wiki/File:MEVyMosey.png

This gift was that I received the 8 I’d Really Rather You Didn’ts of code. One of those states that it is not going to last long until your code can get irreversibly unmaintainable without the right measures. And that’s what I would like to write about today.

The fast-paced environment in which we work simply leads us as developers to quickly drag in dependencies without thinking about where they actually belong. This is something also good testing cannot prevent. I did things I would have sworn a few years ago I would never ever do again. Yes, dear reader, hear my confession. And all this came from the good old feature folder-based approach → For me definitely one “I’d Really Rather You Didn’ts”.

Apart from that we have several Flutter projects (Flutter for Android & iOS and Flutter Web). There are a few functionalities we simply don’t want to write again and again.

They all for example depend on the same database. So we want to reuse this database connection in the other projects. We also want to reuse certain other functionality or widgets.

So how can we easily achieve such things?

  • Clear separation of concerns and visibility of boundaries
  • Reusability of single functionality

Approach

This modular project structure tries to keep it as simply as possible while separating concerns and functionality as much as possible.

It is actually a Flutter implementation inspired by an article I read already a few years ago. It suggests a modular Android project approach. I used the original gradle implementation already for big-scale projects meant for millions of users on Android. Find out how Jeroen Mols suggested to build an Android app.

The original idea is to separate your code into features and libraries and put those again into their own gradle modules. In Flutter / Dart we don’t have gradle modules but instead packages. And we can achieve the exact same thing with a slightly different technical tooling. So what are feature and library packages?

Feature Package

A feature package is a module that defines a certain functionality. In our case this is for example a ridesharing package or a mobility_budget package. They cannot implement themselves but any or multiple library packages.

A feature package contains in our case

  • Screens (whole-page widgets with Scaffold )
  • Pages (whole-page widgets without Scaffold )
  • Widgets
  • BLoCs
  • Repositories

Library Package

A library package is a module with functionality that is used in multiple other packages. This could be for example a database, a payment or a common_ui package.

A database connection is used in nearly all feature packages, the payment functionality in a few of them and common_ui also in nearly all of them.

The database package contains

  • DataSources
  • Entities (DTOs)

The common_ui package

  • Reusable widgets

Although in these examples it is not necessary the library packages also can implement each other.

Implementation

To create a package simply run dart create -t package <PACKAGE_NAME> .

After that your package will have the following structure:

initial structure after creating package

Deep-Dive more into packages by checking out the official documentation about packages.

In the end your package is nothing else than the big project but in a miniature version. This also means you can add regular packages to your pubspec.yaml, you can write tests for its functionality, you can define how your linter works via analysis_options.dart .

The only thing you have to think about is which dependencies you expose to other modules. This can be achieved by exporting the corresponding files in the main library, e.g. payment.dart file like:

library payment;

export 'src/entities/entities.dart';
export 'src/extensions/extensions.dart';
export 'src/services/services.dart';

Using your packages

Now how do you use those packages in the main lib folder? The main project is where all those separated features come together.

Let’s have a look at a typical pubspec.yaml with your own feature and library packages:

name: uryde
description: Connect Your Ryde.
version: 1.1.1+111

environment:
sdk: '>=3.0.0 <4.0.0'

publish_to: none

dependencies:
flutter:
sdk: flutter

# local packages
ridesharing:
path: modules/features/ridesharing
mobility_budget:
path: modules/features/mobility_budget
common_ui:
path: modules/libraries/common_ui
database:
path: modules/libraries/database
payment:
path: modules/libraries/payment

dev_dependencies:

flutter:

assets:

A feature package pubspec.yaml could look similar but it would only implement libraries .

And that’s already it. Now run pub get and the code from the other packages is present in your main project.

Apply approach to existing projects

If you have — like we had — an existing spaghetti project you might want to consider your existing code legacy code right away. Refactoring all at once is part of our own The ‘I’d Really Rather You Didn’ts’.

But try it if you are one of those brave. But beware: It’s like mikado, once you slurp out one spaghetto it might break the rest of your bolognese.

Rather: Start creating new features as feature packages and think of existing functionality for library packages. You will see that as soon as you start creating your first feature package you will need functionality from your legacy code and you will have to extract it anyway to another package. This is a natural process and separates the necessary work for the whole transition into multiple steps.

Melos

So that’s already everything you need to know for a well working and structured project. Now a bit of a tooling that makes your life easier: Melos. Find out more about all the possibilities it gives you in the official documentation. Just as a starter: Melos is the glue between all the things our clearly separated modules might have in common. If your code would be made of spaghetti it would be the tomato sauce of your dish — but it’s not made of spaghetti any more! It now clearly separates between vegetables, potatoes and all the other yummy food features on your plate. What do they have in common? For example

  • a build runner
  • tests
  • code analysis
  • and more

It also enables you to define custom scripts for the whole project. Let’s dig into that a bit more.

Melos Setup

Starting point for all your configuration is a yaml file. The melos.yaml defines your modules and links them together. It can furthermore define custom scripts for your code base.

Here’s an example melos.yaml file:

promptname: your_app_name

packages:
- .
- modules/features/*
- modules/libraries/*

scripts:
analyze:
exec: dart analyze --fatal-infos .
description: Run `dart analyze` in selected or all packages. Includes prompt for packages.
packageFilters:
dirExists:
- lib

test:
exec: flutter test
description: Run `flutter test` in a selected package. Includes prompt for packages.
packageFilters:
dirExists:
- test

pub:get:
exec: flutter pub get
description: Run `flutter pub get` in selected or all packages. Includes prompt for packages.
packageFilters:
dirExists:
- lib

pub:upgrade:
exec: flutter pub upgrade
description: Run `dart pub upgrade` in selected or all packages. Includes prompt for packages.
packageFilters:
dirExists:
- lib

build_runner:
exec: dart run build_runner build --delete-conflicting-outputs
description: Run `dart run build_runner build --delete-conflicting-outputs` in selected or all packages. Includes prompt for packages.
packageFilters:
dependsOn: "build_runner"

# CI Jobs
ci:quality:
run: |
melos run analyze --no-select
melos run test --no-select
description: Run tests and analyze in all modules available without prompting for modules.

# Custom Scripts
build_ios_dev:
run: |
source ./prepare_for_build.sh
cd ios
bundle install
bundle exec fastlane update_plugins
bundle exec fastlane deployDev --env appdev
cd ..
description: Build dev version for ios and upload to firebase app distribution.

build_android_dev:
run: |
source ./prepare_for_build.sh
cd android
bundle install
bundle exec fastlane update_plugins
bundle exec fastlane deployDev
cd ..
description: Build dev version for android and upload to firebase app distribution.

You see that with the exec keyword any possible command can be run for all modules. There’s also the run command to run a command independently of your yummy packages.

A description makes it easy to explain to others what this script does.

And the packageFilters let us configure for which kinds of packages a script should run. Quite straight forward:

  • dirExists :
    In a lot of cases we only want to run the command on packages with an existing lib folder. Tests should only run for packages with test folders.
  • dependsOn
    Looking at the example of the build_runner script you can see that there are also other kinds of filters. In this case dependsOn: "build_runner" defines to run a build runner task only for those packages with a build runner dependency.

More about scripts in melos can be found here.

Keeping packages in sync

First approach

If you simply want to use the latest possible version for the current major it’s very easy to keep them in sync. The nature of dart pub upgrade already takes care of that.

According to the official documentation it

gets the latest versions of all the dependencies listed in the pubspec.yaml.

it also

writes a lockfile to ensure that dart pub get will use the same versions of those dependencies. […] If a lockfile already exists, dart pub upgrade ignores it and generates a new one from scratch, using the latest versions of all dependencies.

So in our case — considering the above mentioned melos file — we don’t need to do anything else than running melos pub:upgrade and the script will take care of deleting old pubspec.lock files, creating new ones and upgrading the packages to the latest possible version. Just make sure to use the caret syntax like here:

collection: ^1.17.0 // the ^ is important

Also make sure to check those generated lockfiles into version control so that your CI pipeline or any other developer uses the same package versions. That’s the easy way and the way I recommend.

Second approach

Also, and that might be the better approach for now as you use melos anyway: melos recently added a way to define the packages inside the melos.yaml. You can simply define the common packages like that:

# melos.yaml
# ...
command:
bootstrap:
environment:
sdk: ">=3.0.0 <4.0.0"
flutter: ">=3.0.0 <4.0.0"
dependencies:
collection: ^1.18.0
integral_isolates: any
uni_links2:
uni_links_macos:
git: https://github.com/SamJakob/uni_links_macos.git

dev_dependencies:
build_runner: ^2.3.3
# ...

Example taken from the official melos docs.

Wrap up

So that’s it so far. We at uRyde are super happy with this approach and tooling and invite you to try it out as well. Feel free to leave a comment or find me on Twitter, Mastodon or LinkedIn for any longer lasting conversation or discussion.

I hope this helps you to start your next project on solid ground or to clean up your existing code with a future-proof structure.

There are definitely other solution out there but for our purpose and maybe for some of you this is a great middle way between over-engineering and spaghetti code. For me it truly is a revelation.

By Niklas Jansson — Android Arts, Public Domain, https://commons.wikimedia.org/w/index.php?curid=48906232

P.S.: To prevent the question right away: The naked guy up there is not me!

P.P.S: I’m also not the spaghetti monster. Don’t even think of it. It would be Blasphemy.

--

--

Nino Handler
Flutter Community

CTO & Co-Founder uryde.de | Organizer & Founder GDG Nuremberg | @luckyhandler