SOFTWARE ARCHITECTURE

Modular Architecture in Flutter Projects

Smart development through small modules

Felipe Emídio
7 min readMar 10, 2023

Building scalable applications with unknown future requirements is challenging. Modularizing your code into small independent parts to improve maintainability is an intelligent move.

The secret of getting ahead is getting started. The secret of getting started is breaking your complex overwhelming tasks into small manageable tasks, and starting on the first one. — Mark Twain

A typical life lesson we all learn is to break complex problems into small tasks and work with them one by one.

We can apply the same idea to software development! It is a clean code principle to have small files with no more than one responsibility and construct a system seeking low coupling.

What is Modular Software Architecture?

Modular Architecture is a system design strategy that splits the application functionalities into independent modules.

A graphic of a system divided in feature with some of them shared and a common one to all the system

That said, we can add, remove or switch modules without effect the overall system behavior.

A module is a logical entity with everything (programming code and its dependencies) to execute a desired functionality.

The communication through modules is made by a common and clear contract (interface classes).

The Modular Architecture can be applied in Flutter applications by using the flutter_modular package.

How to work with flutter_modular?

This package defines the structure of a module and gives us interfaces to manage our dependencies and interact with other modules.

In addition to the modular architecture design, the flutter_modular also comes with tools for Navigation and Dependency Injection.

Let me show some basic concepts, and after that, we will gather everything to create an example project.

Configuration

We can configure our project to work with flutter_modular features just by adding the ModularApp widget as the root widget of our app.

void main(){
return runApp(ModularApp(module: MyModule(), child: MyApp()));
}

It will instantiate a Modular class (we’ll talk more about it later) and provide information for the widget tree through the app’s context.

A module structure

class MyModule extends Module {
@override
List<Module> get imports => const [];

@override
List<Bind> get binds => const [];

@override
List<ModularRoute> get routes => const [];
}

A module can be built defining values for these three lists:

  • Routes: Your screens and the route’s name to reach them.
  • Binds: Instances of classes that the module is dependent on.
  • Imports: Other modules to which you want the dependencies to be imported.

Navigation

We can define all module screens by setting a value to the routes list.

class MyModule extends Module {
@override
List<ModularRoute> get routes => [
ChildRoute('/login', child: (context, args) => LoginPage()),
ChildRoute('/home', child: (context, args) => HomePage()),
ModuleRoute('/settings', module: SettingsModule()),
];
}

And we can move between screens with static values of the Modular class:

navigateToHome() {
Modular.to.pushNamed('/home');
}

Dependency injection

By defining an Object in the binds list, we can reach it in a couple of ways.

class MyModule extends Module {
@override
List<Bind> get binds => [
Bind((i) => UserService())
];

@override
List<ModularRoute> get routes => [
ChildRoute('/home', child: (context, args) => HomePage()),
];
}

Each bind can be obtained by the Modular class just specifying its type, like:

final userService = Modular.get<UserService>();

If there is no Bind with the type you defined, Modular will throw a BindNotFoundException.

If your dependency class has other dependencies, you also can catch them through the constructor.

@override
List<Bind> get binds => [
Bind((i) => HttpClient())
Bind((i) => UserService( i<HttpClient>() ))
];

The parameter i is equivalent to the Modular.get instance.

Module inside Module

class ModuleA extends Module {
@override
List<ModularRoute> get routes => [
ChildRoute('/first', child: (context, args) => FirstPage()),
ModuleRoute('/moduleB', module: ModuleB()),
];
}

class ModuleB extends Module {
@override
List<ModularRoute> get routes => [
ChildRoute('/second', child: (context, args) => SecondPage()),
];
}

In this case, we can reach the SecondPage screen with:

navigateToHome() {
Modular.to.pushNamed('/moduleB/second');
}

We also can turn a dependency as exportable using the parameter export of the Bind class and importing it through the imports list.

class ModuleA extends Module {
@override
List<ModularRoute> get binds => [
Bind((i) => UserService(), export: true),
];
}

class ModuleB extends Module {
@override
List<Module> get imports => [
ModuleA(),
];
}

Now ModuleB can reach all exportable dependencies of ModuleA.

A practical example

Let’s give it a try!

I designed a simple project of a Pokedex with a few pages to be routed. I used the PokeAPI, which you can know more about here.

5 pages of my app. Splash Screen, pokemons list, pokemon detail, berries list, and berry detail.
Pages of my app

You can check the project’s source code here!

The plot twist is that you see 5 pages, but there are 6 of them. The home screen follows the concept of nested navigation. It means, our home is just a page with a bottom navigation bar that show two other independent pages for each bottom button.

You can easily create nested pages with an awesome feature of flutter_modular called RouterOutlet.

Here is the build function of our home page.

Widget build(BuildContext context) {
return Scaffold(
bottomNavigationBar: BottomNavigationBar(
currentIndex: currentIndex,
onTap: _onChangeTab,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.list), label: 'Pokemons'),
BottomNavigationBarItem(icon: Icon(Icons.fastfood), label: 'Berries'),
],
),
body: const RouterOutlet(),
);
}

You can define the content of a RouterOutlet setting the children of a route.

ChildRoute('/home', child: (_, __) => const HomePage(), children: [
ModuleRoute('/pokemons', module: PokemonsModule()),
ModuleRoute('/berries', module: BerriesModule()),
]),

Pages

The pages created are:

  • A splash screen shows our logo and redirects the user to home after some time.
  • A home page, which is the bottom nav bar with a RouterOutlet widget.
  • A pokemon list. It is a Pokedex app, after all.
  • A berries list. Berries are fruits with some cure effect in the games.
  • Detail pokemon page.
  • Detail berry page.

Besides showing the API info, the user can favorite pokemon and berries. The id of their favorite items will be stored in a local database built with the hive package.

To build this app, five modules were used. And no! It’s not one module per page (without counting the home).

Flow chart showing all the modules and the page in each.

First, we need a main module to be the root of every route and store the shared dependencies. Two of our lists will have the same favorite feature, so the local database service is inside this module.

class MainModule extends Module {
@override
List<Module> get imports => [
SharedModule()
];

@override
List<ModularRoute> get routes => [
ModuleRoute('/', module: InitialModule()),
ChildRoute('/home', child: (_, __) => const HomePage(), children: [
ModuleRoute('/pokemons', module: PokemonsModule()),
ModuleRoute('/berries', module: BerriesModule()),
]),
];
}

The shared module is where is stored every cross-module dependency. “Could you have set the shared dependencies directly in the MainModule?” Yes, it would make no difference! But I like a clean main module.

The initial module is where we store anything needed to start the app and has no more use after it. In this simple app, only the splash screen is there.

The pokemons module and berries module aims to gather related pages and dependencies. The pokemons module collects anything connected with pokemons and the berries module for berries.

class PokemonsModule extends Module {
@override
List<Bind> get binds => [
Bind<PokemonRepository>((i) => PokemonRepository(i<Dio>())),
];

@override
List<ModularRoute> get routes => [
ChildRoute('/', child: (_, __) => const PokemonsListPage(), transition: TransitionType.fadeIn),
ChildRoute('/detail', child: (_, args) => PokemonDetailPage(pokemon: args.data), transition: TransitionType.downToUp),
];
}

Dependencies

Flow chart showing all the modules and the dependencies in each.

Pokemons and Berries modules save the favorite user items in the local storage. And our HTTP client (a dio instance in this case) is required for the repositories dependencies to fetch the API. In other words, these two dependencies are shared between the pokemons and the berries modules.

class SharedModule extends Module {
@override
List<Bind> get binds => [
Bind<LocalStorageService>((i) => LocalStorageService(), export: true),
Bind<Dio>((i) => Dio(), export: true),
];
}

Our PokemosRepository gathers the endpoints related to pokemons and the same logic is applied to the BerriesRepository. So they are kept in the modules focused on their specific goal.

What about tests?

One time I heard:

You know how good your architecture is when you build test for it. — Someone

I do agree with it. Tests provide quality and predictability. They are gold!

For tests, you need to install as a dev dependency the modular_test package.

flutter pub add -d modular_test

You can override your module’s dependencies by replacing its binds with some mock class. You can use packages like mockito or mocktail to support building mock instances.

import 'package:mocktail/mocktail.dart';
import 'package:modular_test/modular_test.dart';

class MockPokemonRepository extends Mock implements PokemonRepository {}

setUp((){
initModule(PokemonsModule(), replaceBinds: [
Bind.instance<PokemonRepository>(mockPokemonRepository),
]);
});

If you aren’t familiarized with the Mock strategy, read this.

Also, if we are writing integration tests, we would like to check our navigation stack. We can query the navigator history as follows.


class NavigatorHelper {
String getFirstRouteHistory(String path) {
var history = Modular.to.navigateHistory;

return history.first.name;
}
}

What’s next?

The flutter_modular package also has more features not covered here. Like RouteGuards to redirect the user when some condition is met. Also, WildCards shows a page when the user goes to a route that doesn’t exist (like a 404 page).

Working with modularity is a great achievement but far from enough to have good code. Apply Clean architecture to your project, and define a clear folder structure.

Seek some practices that can work well with your project and team. Apply agile methodologies like Scrum or Extreme Programming.

Finally, keep studying software architecture, clean code, design patterns, and related stuff. Knowledge is our friend! =)

Again, you can check the source code of the project here.

Thanks for reading! If you want to read more of my thoughts and programming tips, then check out my articles and follow or subscribe for upcoming content.

--

--

Felipe Emídio

Front-end developer searching for a better version of my code.