Skip to content

๐Ÿงญ Routing Integration โ€‹

Wrap each route in a ModuleScope so modules follow navigation lifecycle automatically. Modularity is router-agnostic -- the same pattern works with GoRouter, AutoRoute, or Navigator 1.0.

Scope Nesting with Routes โ€‹

Core Pattern โ€‹

One route = one ModuleScope. The scope creates a ModuleController, runs binds() / exports() / onInit(), and disposes the controller when the route is removed.

dart
ModuleScope(
  module: ProfileModule(),
  child: const ProfilePage(),
)

ModuleScope handles:

  • Creating a Binder scoped to this module
  • Running binds() / exports() / onInit()
  • Disposing the controller when the route leaves the stack

Scope Chaining โ€‹

INFO

Nested ModuleScope widgets form a parent-child chain. Child scopes can resolve dependencies registered by any ancestor scope via get<T>():

ModularityRoot
  โ””โ”€โ”€ ModuleScope<RootModule>       <- registers AuthService
        โ””โ”€โ”€ MaterialApp.router
              โ””โ”€โ”€ ModuleScope<HomeModule>  <- get<AuthService>() resolves from parent

Configurable Modules โ€‹

Pass route parameters into modules using the Configurable<T> interface and the args parameter:

dart
class DetailsModule extends Module implements Configurable<String> {
  late String id;

  @override
  void configure(String args) {
    id = args;
  }

  @override
  void binds(Binder i) {
    // id is available here -- configure() runs before binds()
  }
}
dart
ModuleScope(
  module: DetailsModule(),
  args: routeParams['id'],
  child: const DetailsPage(),
)

Expected Dependencies โ€‹

Declare parent dependencies a module requires with expects. Initialization fails fast if a listed type is missing from the parent scope:

dart
class SettingsModule extends Module {
  @override
  List<Type> get expects => [AuthService];

  @override
  void binds(Binder i) {}
}

Observer Registration โ€‹

The routeBound retention policy relies on a RouteObserver to detect route pops. Create an observer externally and pass it to both ModularityRoot and your router so module controllers dispose when their route is popped.

For classic MaterialApp:

dart
final observer = RouteObserver<ModalRoute<dynamic>>();

ModularityRoot(
  observer: observer,
  child: MaterialApp(
    navigatorObservers: [observer],
    home: ...,
  ),
)

WARNING

Without an observer passed to ModularityRoot, routeBound retention cannot detect route pops. Controllers will only dispose when the widget itself is unmounted. Always create an observer and register it at both ModularityRoot and the router level.

GoRouter vs AutoRoute Integration Points โ€‹

App Setup โ€‹

dart
void main() => runApp(const MyApp());

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

  @override
  Widget build(BuildContext context) {
    return ModularityRoot(
      child: ModuleScope(
        module: RootModule(),
        child: Builder(
          builder: (context) {
            return MaterialApp.router(
              routerConfig: AppRouter.router,
            );
          },
        ),
      ),
    );
  }
}
dart
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ModularityRoot(
      child: ModuleScope(
        module: RootModule(),
        child: Builder(
          builder: (context) {
            final authService =
                ModuleProvider.of(context).get<AuthService>();
            final appRouter = AppRouter(authService);

            return MaterialApp.router(
              routerConfig: appRouter.config(
                navigatorObservers: () => [ModularityRoot.observerOf(context)],
              ),
            );
          },
        ),
      ),
    );
  }
}

RootModule sits above the router so every route inherits its dependencies through scope chaining. The Builder creates a context that already has access to RootModule dependencies.

AutoRoute passes resolved dependencies into the router constructor so guards can use them, and registers the observer through config(navigatorObservers: ...) rather than the MaterialApp constructor.

GoRouter โ€‹

Route Definitions โ€‹

Wrap the builder return value in a ModuleScope:

dart
final router = GoRouter(
  initialLocation: '/home',
  observers: [AppRouter.observer],
  routes: [
    GoRoute(
      path: '/login',
      builder: (context, state) => ModuleScope(
        module: AuthModule(),
        child: const AuthPage(),
      ),
    ),
    GoRoute(
      path: '/home',
      builder: (context, state) => ModuleScope(
        module: HomeModule(),
        child: const HomePage(),
      ),
      routes: [
        GoRoute(
          path: 'details/:id',
          builder: (context, state) {
            final id = state.pathParameters['id']!;
            return ModuleScope(
              module: DetailsModule(),
              args: id,
              child: DetailsPage(id: id),
            );
          },
        ),
      ],
    ),
  ],
);

ShellRoute (Tab Layout) โ€‹

A ShellRoute keeps a shared layout alive while child routes swap. Wrap the shell builder in its own ModuleScope:

dart
ShellRoute(
  builder: (context, state, child) {
    return ModuleScope(
      module: DashboardModule(),
      child: DashboardPage(child: child),
    );
  },
  routes: [
    GoRoute(
      path: '/home',
      builder: (context, state) => ModuleScope(
        module: HomeModule(),
        child: const HomePage(),
      ),
    ),
    GoRoute(
      path: '/settings',
      builder: (context, state) => ModuleScope(
        module: SettingsModule(),
        child: const SettingsPage(),
      ),
    ),
  ],
)

DashboardModule's scope becomes the parent for HomeModule and SettingsModule. Dependencies registered in the dashboard are available to both child scopes.

Redirect with ModuleProvider โ€‹

Access dependencies registered by an ancestor ModuleScope inside redirect:

dart
GoRouter(
  redirect: (BuildContext context, GoRouterState state) {
    try {
      final authService =
          ModuleProvider.of(context).get<AuthService>();
      final isLoggedIn = authService.isLoggedIn;
      final isLoggingIn = state.uri.path == '/login';

      if (!isLoggedIn && !isLoggingIn) return '/login';
      if (isLoggedIn && isLoggingIn) return '/home';
    } catch (_) {
      // AuthService not yet available during first build
    }
    return null;
  },
  // ...
);

TIP

The redirect callback receives a BuildContext that sits below ModularityRoot and the root ModuleScope. This is why the Builder wrapper in the app setup is important -- it creates a context that already has access to RootModule dependencies.

AutoRoute โ€‹

Route Pages with ModuleScope โ€‹

With AutoRoute, wrap ModuleScope inside @RoutePage() widgets:

dart
@RoutePage()
class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return ModuleScope(
      module: HomeModule(),
      child: Scaffold(
        appBar: AppBar(title: const Text('Home')),
        body: const HomeContent(),
      ),
    );
  }
}

TIP

Co-locate ModuleScope with its route page. Place it inside the page widget rather than in route configuration so module ownership stays next to the page that uses it.

Router Configuration and Guards โ€‹

dart
@AutoRouterConfig()
class AppRouter extends RootStackRouter {
  AppRouter(this.authService);
  final AuthService authService;

  @override
  List<AutoRoute> get routes => [
    AutoRoute(page: AuthRoute.page, path: '/login'),
    AutoRoute(
      page: DashboardRoute.page,
      path: '/',
      guards: [AuthGuard(authService)],
      children: [
        AutoRoute(page: HomeRoute.page, path: 'home'),
        AutoRoute(page: SettingsRoute.page, path: 'settings'),
      ],
    ),
    AutoRoute(
      page: DetailsRoute.page,
      path: '/details/:id',
      guards: [AuthGuard(authService)],
    ),
  ];
}

class AuthGuard extends AutoRouteGuard {
  AuthGuard(this.authService);
  final AuthService authService;

  @override
  void onNavigation(NavigationResolver resolver, StackRouter router) {
    if (authService.isLoggedIn) {
      resolver.next(true);
    } else {
      router.push(const AuthRoute());
    }
  }
}

The guard receives AuthService from the root scope at router creation time.

Tab Navigation โ€‹

Use AutoTabsScaffold inside a ModuleScope for tabbed layouts:

dart
@RoutePage()
class DashboardPage extends StatelessWidget {
  const DashboardPage({super.key});

  @override
  Widget build(BuildContext context) {
    return ModuleScope(
      module: DashboardModule(),
      child: AutoTabsScaffold(
        routes: const [HomeRoute(), SettingsRoute()],
        bottomNavigationBuilder: (_, tabsRouter) {
          return BottomNavigationBar(
            currentIndex: tabsRouter.activeIndex,
            onTap: tabsRouter.setActiveIndex,
            items: const [
              BottomNavigationBarItem(
                icon: Icon(Icons.home), label: 'Home',
              ),
              BottomNavigationBarItem(
                icon: Icon(Icons.settings), label: 'Settings',
              ),
            ],
          );
        },
      ),
    );
  }
}

Configurable Route Pages โ€‹

Pass path parameters via args:

dart
@RoutePage()
class DetailsPage extends StatelessWidget {
  const DetailsPage({required this.id});
  final String id;

  @override
  Widget build(BuildContext context) {
    return ModuleScope(
      module: DetailsModule(),
      args: id,
      child: Scaffold(
        appBar: AppBar(title: Text('Details $id')),
        body: const DetailsContent(),
      ),
    );
  }
}

Retention Policies โ€‹

ModuleScope defaults to ModuleRetentionPolicy.routeBound. Three policies are available:

PolicyBehaviour
routeBound (default)Controller disposed when the route pops.
strictController disposed on every widget unmount.
keepAliveController cached in ModuleRetainer, survives widget unmount.
dart
ModuleScope(
  module: ExpensiveModule(),
  retentionPolicy: ModuleRetentionPolicy.keepAlive,
  retentionKey: 'expensive-module',
  child: const ExpensivePage(),
)

TIP

Use keepAlive for modules with expensive initialization (network calls, database setup). Use strict for modules that must reinitialize on every visit.

Retention Key for Route Parameters โ€‹

When the same module type is used on different routes with different parameters, set a retentionKey to avoid cache collisions:

dart
GoRoute(
  path: '/users/:id',
  builder: (context, state) {
    final userId = state.pathParameters['id']!;
    return ModuleScope(
      module: UserModule(),
      args: userId,
      retentionPolicy: ModuleRetentionPolicy.keepAlive,
      retentionKey: 'user-$userId',
      child: const UserPage(),
    );
  },
)

Without an explicit key, the identity is derived from (moduleType, route, args). Set retentionKey when you need deterministic cache identity independent of route metadata.

WARNING

overrideScope does not affect the retention key. Two scopes with the same key but different overrides share one cached controller. Include override identity in the key if needed.

Loading and Error States โ€‹

ModuleScope renders loading/error UI while the module initializes. Use per-scope builders or fall back to ModularityRoot defaults:

dart
ModuleScope(
  module: PaymentModule(),
  loadingBuilder: (_) => const PaymentSkeleton(),
  errorBuilder: (_, error, retry) => PaymentErrorView(
    message: error.toString(),
    onRetry: retry,
  ),
  child: const PaymentPage(),
)

Fallback order: scope builder -> ModularityRoot defaults -> built-in placeholder.

Debug Logging โ€‹

Enable lifecycle logging during development to trace module creation and disposal:

dart
void main() {
  runApp(
    ModularityRoot(
      lifecycleLogger: ModularityRoot.defaultDebugLogger,
      child: const MyApp(),
    ),
  );
}

Output:

[Modularity] CREATED HomeModule key=HomeModule@/home
[Modularity] DISPOSED HomeModule key=HomeModule@/home

Checklist โ€‹

ConcernSolution
Per-route DI scopeWrap page content in ModuleScope
Route parametersConfigurable<T> + ModuleScope.args
Route-aware disposalModularityRoot(observer: ...) + routeBound policy
Cross-tab cachingkeepAlive policy + retentionKey
Scope chainingNested ModuleScope widgets
GoRouterPass observer to GoRouter(observers: [...])
AutoRoutePass observer to appRouter.config(navigatorObservers: () => [ModularityRoot.observerOf(context)])