Flutter App Architecture  –  a modular approach

Inhaltsverzeichnis

In the world of Flutter app development, a solid architectural foundation is essential to ensure an application’s longevity, scalability, and maintainability. When it comes to choosing the right architecture in software development one has to admit that there is no “one fits it all” — solution. It depends one several factors and questions, that each developer team has to discuss such as timing, budget, team composition, framework, security requirements, just to name a few.

While clean architecture has been a popular approach for structuring complex applications, we believe in simplifying it further for real-world use cases — particularly those geared towards large, growing applications

In this blog post, we’ll walk you through our simplified Flutter architecture. We’ve based this approach on core principles of layered architecture that’s optimized for large projects.

A key feature of our architecture is modularity. Every module (also called feature) is designed to work independently, ensuring no module depends on another.

One important caveat is that this architecture is not suited for small projects. Implementing a layered, modular structure for a simple app can easily lead to over-engineering. If your project doesn’t anticipate rapid growth or complexity, you’re better off keeping the architecture simpler.Remember: one of the core principles of software development is to keep it simple. Use the right tool for the right job. 

Flutter modular architecture

Clean architecture is an effective way to separate concerns across different parts of an app, but it can sometimes be overwhelming. Our simplified version of clean architecture keeps the essential structure but removes unnecessary complexities, making it easier to implement while still retaining the core benefits.

We focus on two main layers: Presentation and Data. As shown in the diagram, each layer has a specific role and communicates with the next in a clear, direct flow:

  • Presentation Layer: Handles the UI, manages the module’s logic and state
  • Data Layer (Repositories): Manages data flow and acts as a bridge between the app and external data sources (like APIs or databases).

To help you better understand the architecture, we’ve created a sample repository available at GitHub. In this example, we demonstrate how to build a simple to-do app using a modular architecture. (Remember: While this approach might seem over-engineered for a basic to-do app, the purpose is to provide you with a clear understanding of how the architecture works in practice)

Let’s dive into the architecture

The project is organized into three main components:

📦 flutter_modular_architecture
├─ *
└─ lib
   ├─ shared
   ├─ feature
   ├─ app
   └─ main.dart
  1. Shared:

This section contains resources that are accessible by all modules. The Shared component is completely independent and does not depend on the existence of any module or the app. It serves as a central repository of shared functionality, but it remains unaware of the modules or app that use it.

  1. Module/Feature: 

Each module in this section operates independently and is unaware of the existence of other modules. While these modules do not interact with each other, they all rely on the resources provided by the Shared component.

  1. App

The App is the entry point to the application, where routes and app settings are defined. It has access to both the Modules and Shared components, orchestrating the application’s behavior. However, neither the Modules nor the Shared components have access to the App itself.

Another key aspect of this architecture is that the navigation logic is centralized in the App component. In each module and the Shared component, abstract classes are defined with abstract methods for navigation specific to those modules. The actual implementations of these abstract methods are provided in the App component. This approach ensures that all navigation code is organized and contained within the App component.

In brief, the App depends on the Feature modules and Shared components. Each Feature module only relies on the Shared component, without any knowledge of the other modules or the App. Finally, the Shared component remains entirely independent and does not need to be aware of any modules or the App.

Now, let’s discuss each of these three main components in more detail.

Shared

The Shared component serves as a core part of the architecture, containing resources that are accessible to all modules and the App component. This component includes shared widgets, models, app themes, translations, and various shared services. Additionally, it encompasses both the Data Layer and Presentation Layers. In the next section, where we discuss the Feature component, we will delve deeper into these elements.

📦 flutter_modular_architecture
├─ *
└─ lib
   ├─ app
   ├─ feature
   ├─ shared
   │  ├─ error
   │  │  ├─ exceptions.dart
   │  │  └─ failures.dart
   │  ├─ data
   │  │  ├─ model
   │  │  └─ repo
   │  │     ├─ *
   │  │     └─ todo_repo.dart
   │  ├─ presentation
   │  │  ├─ bloc
   │  │  │  ├─ *
   │  │  │  └─ app_user
   │  │  │     ├─ app_user_bloc.dart
   │  │  │     ├─ app_user_event.dart
   │  │  │     └─ app_user_state.dart
   │  │  ├─ widget
   │  │  └─ dialog
   │  ├─ service
   │  │  ├─ *.dart
   │  │  └─ shared_route_service.dart
   │  └─ util
   └─ main.dart

A key aspect of the Shared component is the use of abstract classes for facilitating communication between modules. Here, we define abstract classes that specify parameters and return types, allowing for clear contracts between components. These abstract classes are then implemented in the corresponding modules through dependency injection. Common examples include repositories and state management abstractions (like Bloc or Provider), with their concrete implementations residing within the respective modules.

abstract class IAppUserBloc extends Bloc<AppUserEvent, AppUserState> {
  IAppUserBloc(super.initialState);
}
abstract class ITodoRepo {
  Future<Either<Failure, List<Todo>>> getTodos();

  Future<Either<Failure, void>> deleteTodo({required Todo todo});
}

Furthermore, a navigation service is defined as an abstract class within the Shared component. This service is used for navigation originating from shared resources, such as widgets, with its implementation provided in the App component.

Note: In the example project, you won’t find this service implemented because there are currently no navigation actions occurring from the shared components. However, if such navigation is needed in the future, we will need to add this functionality.

Module/Feature

The project is structured into multiple logically grouped modules. Each module operates independently, ensuring that no module directly communicates with or is aware of the existence of other modules. The only way to access resources from other modules is through the interfaces specified in the Shared component. These interfaces are made available via dependency injection.

Each module contains the following:

📦 flutter_modular_architecture
├─ *
└─ lib
   ├─ app
   ├─ feature
   │  ├─ *
   │  └─ todo
   │     ├─ data
   │     │  ├─ model
   │     │  ├─ repo
   │     │  └─ local_storage
   │     ├─ presentation
   │     │  ├─ bloc
   │     │  ├─ page
   │     │  ├─ widget
   │     │  └─ dialog
   │     ├─ service
   │     └─ util
   ├─ shared
   └─ main.dart

Data

This layer handles the models , repositories and local storage of the module.

📦 flutter_modular_architecture
├─ *
└─ lib
   ├─ app
   ├─ feature
   │  ├─ *
   │  └─ user
   │     ├─ data
   │     │  ├─ model
   │     │  │  ├─ *
   │     │  │  └─ auth_result.dart
   │     │  ├─ repo
   │     │  │  ├─ *
   │     │  │  └─ user_repo.dart
   │     │  └─ local_storage
   │     │     ├─ *
   │     │     └─ user_local_storage.dart
   │     ├─ presentation
   │     ├─ service
   │     └─ util
   ├─ shared
   └─ main.dart

Model

All module-specific models, including methods for JSON parsing, are defined here. These models are also used in the presentation layer.

Local Storage:

This section manages local data specific to the module, such as caching data, persisting user preferences, or saving to local SQLite. It handles reading from and writing to local storage.

Repository:

The repository layer is responsible for handling data operations, whether it’s making API calls, fetching data from Local Storage, or managing other data-related tasks. Each method in the repository should accept only a single parameter or none. If multiple values need to be passed, a model should be used to encapsulate them.

class AuthRepo {
  ...
  Future<Either<Failure, void>> login(AuthParam param) async {
   ...
  }

  Future<Either<Failure, void>> signup(AuthParam param) async {
   ...
  }
}

One important aspect of repositories is that they should not raise errors directly or cause exceptions. Instead of throwing exceptions, any errors should be caught using try-catch blocks, and an appropriate Failure object should be returned. Different types of Failure corresponding to various exceptions are defined in the Shared folder. To streamline error handling, we’ve created an extension in Shared that maps exceptions to failure classes, making it easier to handle errors in a consistent and controlled manner.

  @override
  Future<Either<Failure, String>> getToken() async {
    try {
      return Right(await _userLocalStorage.getToken());
    } catch (e) {
      _log.e('Failed to get token: $e');

      return const Left(AuthenticationFailure());
    }
  }

A key feature of the repository is that it uses the Either type to return results from the either_dart package. Either can hold one of two values: typically, the right value represents success, and the left value indicates failure. This ensures that the result is always predictable — either you get a success or a failure, but never both. On the success side, the repository can return various types of responses, such as a model, a boolean value, or even void, depending on the case.

Presentation

This layer contains the user interface elements, including:

Page

Each page in the module is placed in its own folder. Sub-widgets related to each page are grouped within these folders.

Dialog

Similar to pages, dialogs are placed in separate folders, each containing their respective sub-widgets. The logic to display dialogs is defined in the App component (covered later).

Widget

This section contains reusable widgets within the module. These widgets are shared across pages and dialogs, functioning as local common widget components.

Bloc/Other State Management:

This folder holds all state management solutions, such as Blocs or Providers, used within the module. It also contains the implementation of the abstract Bloc defined in the Shared component.

📦 flutter_modular_architecture
├─ *
└─ lib
   ├─ app
   ├─ feature
   │  ├─ *
   │  └─ todo
   │     ├─ data
   │     ├─ presentation
   │     │  ├─ bloc
   │     │  │  ├─ *
   │     │  │  └─ edit_todo
   │     │  │     ├─ edit_todo_bloc.dart
   │     │  │     ├─ edit_todo_event.dart
   │     │  │     └─ edit_todo_state.dart
   │     │  ├─ page
   │     │  │  ├─ *
   │     │  │  └─ add_new_todo_page
   │     │  │     ├─ *
   │     │  │     └─ add_new_todo_page.dart
   │     │  ├─ widget
   │     │  └─ dialog
   │     │     └─ todo_detail_dialog
   │     │        └─ todo_detail_dialog.dart
   │     ├─ service
   │     └─ util
   ├─ shared
   └─ main.dart

Service

Module-specific services are defined here. Any abstract service defined in the Shared component that is relevant to this particular module should be implemented here.

📦 flutter_modular_architecture
├─ *
└─ lib
   ├─ app
   ├─ feature
   │  ├─ *
   │  └─ todo
   │     ├─ data
   │     ├─ presentation
   │     ├─ service
   │     │  ├─ *
   │     │  └─ todo_route_service.dart
   │     └─ util
   ├─ shared
   └─ main.dart

A key service to define in this module is the route service (e.g., todo_route_service.dart), which outlines methods for handling navigation and dialogs specific to this module. This service is defined as an abstract class, with the actual implementation provided in the App component. By injecting this service into the app’s route widget and adding a context extension, it becomes easily accessible throughout the module’s widgets.

extension ITodoRouteServiceExtension on BuildContext {
  ITodoRouteService get todoRouteService =>
      RepositoryProvider.of<ITodoRouteService>(this);
}

abstract class ITodoRouteService {
  void goToHome({BuildContext? context});
}

// which is then called liked this in the widget
IconButton(
  onPressed: () => context.todoRouteService.goToHome(),
  icon: const Icon(Icons.arrow_back),
)

Util

The Util folder contains static utility classes with helper methods for calculations, formatting, and other non-business logic tasks. This section must not include API calls, services, or any business logic.

App

The App component serves as the entry point to the application. It orchestrates module routing, configuration (such as language settings and dependency injection), and overall app settings.

📦 flutter_modular_architecture
├─ *
└─ lib
   ├─ app
   │  ├─ route_guard
   │  ├─ app_router
   │  ├─ app_dialog
   │  ├─ route_service
   │  ├─ app_di
   │  └─ widget
   ├─ feature
   ├─ shared
   └─ main.dart

Route Guard

Route Guard specifies the conditions for route access, functioning as a redirection mechanism. In our example app, we have two guards: AuthRoute and UnAuthRoute.

📦 flutter_modular_architecture
├─ *
└─ lib
   ├─ app
   │  ├─ route_guard
   │  │  ├─ auth_route.dart
   │  │  └─ un_auth_route.dart
   │  ├─ app_router
   │  ├─ app_dialog
   │  ├─ route_service
   │  ├─ app_di
   │  └─ widget
   ├─ feature
   ├─ shared
   └─ main.dart

For example, AuthRoute is accessible only to authenticated users. If an unauthenticated user attempts to access it, they will be redirected to the login page.

abstract class AuthRoute extends GoRouteData {
  const AuthRoute();

  @override
  FutureOr<String?> redirect(BuildContext context, GoRouterState state) async {
    if (!(await AppDI.instance<IUserRepo>().isUserLoggedIn)) {
      return LoginPageRouter.path;
    }

    return super.redirect(context, state);
  }
}

App Router

The App Router is where all the application routes are defined, and module pages are connected. To manage routes effectively, we create a separate file for each module in the App Router directory, using the module name as a prefix.

📦 flutter_modular_architecture
├─ *
└─ lib
   ├─ app
   │  ├─ route_guard
   │  ├─ app_router
   │  │  ├─ app_router.dart
   │  │  ├─ home_router.dart
   │  │  ├─ todo_router.dart
   │  │  └─ user_router.dart
   │  ├─ app_dialog
   │  ├─ route_service
   │  ├─ app_di
   │  └─ widget
   ├─ feature
   ├─ shared
   └─ main.dart

Each file defines classes for the module’s pages, with each class managing the routing for its respective page. These classes extend GoRouteData, and if a route requires protection (e.g., for authenticated users), you can apply custom guards by extending the appropriate guard from the Route Guard setup. If a page requires a specific Bloc, it can be wrapped with a BlocProvider to limit the Bloc’s scope to that page.

@TypedGoRoute<HomePageRoute>(
  path: HomePageRouter.path,
)
@immutable
class HomePageRoute extends AuthRoute {
  static const String path = '/home';

  @override
  Widget build(BuildContext context, GoRouterState state) {
    return BlocProvider<HomeBloc>(
      create: (context) => AppDI.instance<HomeBloc>(),
      child: const HomePage(),
    );
  }
}

Finally, there is a main app_router.dart file, which serves as the central routing class, connecting all the individual page routes from the modules into a unified routing system.

class AppRouter {
  static final GoRouter _router = GoRouter(
    debugLogDiagnostics: true,
    navigatorKey: AppDI.instance<ContextTracker>().rootKey,
    initialLocation: SplashPageRouter.path,
    routes: [
      $splashPageRoute,
      $loginPageRoute,
      $homePageRoute,
      $signupPageRoute,
      $addNewTodoPageRoute,
    ],
  );

  static GoRouter get router => _router;
}

AppDialog

Similar to the App Router, the App Dialog directory and its files are organized according to each module.

Each file contains a class named with the module’s prefix (matching the filename), and within each class, static methods are defined to manage dialog display and integrate Blocs where scoped functionality is required.

class TodoDialog {
  static void showTodoDetail({
    required BuildContext context,
    required Todo todo,
  }) {
    showDialog<dynamic>(
      context: context,
      builder: (_) {
        return Center(
          child: Material(
            color: Colors.transparent,
            child: TodoDetailDialog(
              todo: todo,
            ),
          ),
        );
      },
    );
  }
}

Route Service

In this section, we implement route services for each module by creating a separate file for every module, including the shared component.

📦 flutter_modular_architecture
├─ *
└─ lib
   ├─ app
   │  ├─ route_guard
   │  ├─ app_router
   │  ├─ app_dialog
   │  ├─ route_service
   │  │  ├─ *
   │  │  └─ home_route_service.dart
   │  ├─ app_di
   │  └─ widget
   ├─ feature
   ├─ shared
   └─ main.dart

Each module’s file contains the implementation of the abstract class for navigation specific to that module. These navigation service classes utilize the methods defined in the App Router for routing and the static methods in the App Dialog for displaying dialogs.

class HomeRouteService extends IHomeRouteService {
  HomeRouteService({required ContextTracker contextTracker})
      : _contextTracker = contextTracker;

  final ContextTracker _contextTracker;

  @override
  void goToLogin({BuildContext? context}) {
    LoginPageRoute().go(context ?? _contextTracker.context);
  }

  @override
  void goAddNewTodo({BuildContext? context}) {
    const AddNewTodoPageRoute().go(context ?? _contextTracker.context);
  }

  @override
  void showTodoDetailDialog({required Todo todo, BuildContext? context}) {
    TodoDialog.showTodoDetail(
      context: context ?? _contextTracker.context,
      todo: todo,
    );
  }
}

App DI

The App DI (Dependency Injection) manages dependencies for all modules and shared components. Each module and shared component has its own dedicated file for better maintainability. Additionally, a router dependency management file is included to handle route services across all modules. The main app DI file consolidates calls to the static methods from these individual DI files.

📦 flutter_modular_architecture
├─ *
└─ lib
   ├─ app
   │  ├─ route_guard
   │  ├─ app_router
   │  ├─ app_dialog
   │  ├─ route_service
   │  ├─ app_di
   │  │  ├─ app_di.dart
   │  │  ├─ shared_di.dart
	 │- route_service_di.dart
   │  │  ├─ *
   │  │  └─ user_di.dart
   │  └─ widget
   ├─ feature
   ├─ shared
   └─ main.dart

Each individual class contains different static methods that manage the injection of Blocs, repositories, and other necessary elements. By separating the injection of Blocs and repositories into distinct functions, the code becomes more maintainable and organized.

class UserDI {
  static Future<void> inject(GetIt instance) async {
    await _injectBloc(instance);
    await _injectRepo(instance);
    await _injectLocalStorage(instance);
  }

  static Future<void> _injectBloc(GetIt instance) async {
    instance
      ..registerFactory<AuthBloc>(
        () => AuthBloc(authRepo: instance()),
      )
      ..registerFactory<AppUserBloc>(
        () => AppUserBloc(userRepo: instance(), toastService: instance()),
      );
  }

  static Future<void> _injectRepo(GetIt instance) async {
    instance
      ..registerLazySingleton<AuthRepo>(
        () => AuthRepo(
          // networkConnection: instance(),
          userLocalStorage: instance(),
          log: instance(),
        ),
      )
      ..registerLazySingleton<UserRepo>(
        () => UserRepo(
          userLocalStorage: instance(),
          log: instance(),
        ),
      );
  }

  static Future<void> _injectLocalStorage(GetIt instance) async {
    instance.registerLazySingleton<UserLocalStorage>(
      () => UserLocalStorage(sharedPreferences: instance()),
    );
  }
}

Widget

This section includes three main widget files: App Bloc Wrapper, App Service Wrapper, and App, which collectively function as the routing widget for our application.

📦 flutter_modular_architecture
├─ *
└─ lib
   ├─ app
   │  ├─ route_guard
   │  ├─ app_router
   │  ├─ app_dialog
   │  ├─ route_service
   │  ├─ app_di
   │  └─ widget
   │     ├─ app_bloc_wrapper.dart
   │     ├─ app_service_wrapper.dart
   │     └─ app.dart
   ├─ feature
   ├─ shared
   └─ main.dart
  • App Bloc Wrapper

This widget encapsulates the global Blocs of the app, defining their scope for the entire application.

class AppBlocWrapper extends StatelessWidget {
  const AppBlocWrapper({
    required this.child,
    super.key,
  });

  final Widget child;

  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        BlocProvider<IAppUserBloc>(
          create: (_) => AppDI.instance.get<IAppUserBloc>(),
        ),
      ],
      child: child,
    );
  }
}
  • App Service Wrapper

This widget injects essential services into the widget tree, including services like NavigationService and others, making them accessible within the widgets through RepositoryProvider from Bloc. Additionally, we created extensions for the context, allowing easy access to these services using syntax like context.xyzService

class AppServiceWrapper extends StatelessWidget {
  const AppServiceWrapper({required this.child, super.key});

  final Widget child;

  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        ...
        ...
        ...
        RepositoryProvider<ITodoRouteService>(
          create: (context) => AppDI.instance<ITodoRouteService>(),
        ),
      ],
      child: child,
    );
  }
}
  • App

The App widget acts as the root of the application, where we define the MaterialApp and connect the app’s routes. It wraps both the App Bloc Wrapper and App Service Wrapper around the MaterialApp, ensuring that Blocs and services are readily accessible throughout the app and finally uses the AppRouter.router as corresponding routerConfig.

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return AppServiceWrapper(
      child: AppBlocWrapper(
        child: MaterialApp.router(
          title: 'Todo',
          debugShowCheckedModeBanner: false,
          routerConfig: AppRouter.router,
        ),
      ),
    );
  }
}

Finally, we arrive at the main method, where our app’s journey begins. Before launching the application, it’s crucial to initialize the Dependency Injection (DI) setup. This is also the perfect place to configure any additional settings necessary for the app’s operation.

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final appConfig = AppConfig.fromEnvironment();

  await AppDI.inject(appConfig);

  runApp(const App());
}

That’s it! If you have feedback or any questions, feel free to contact me here matthias.raaz@deep5.io.