The error was always the same: Provider<GameService> not found. But it only happened when entering the game module from a notification tap. Every other entry point — the home screen, the lobby button, a deep link — worked fine. The provider was registered. The widget tree looked correct. I even printed the GetIt instance right before navigation: the service was there.

I spent four hours debugging. I rewrote the DI setup, added fallbacks, checked the widget tree with the Flutter inspector. Nothing worked. Then I changed one character — push()to go() — and the error disappeared.

The fix was one method call. Understanding why it worked took another two days.

The Setup: ShellRoutes and Module DI

Cabo uses GoRouter with ShellRoutes for per-game navigation isolation. Each game module has its own nested navigator and its own Provider scope. When the user enters a game, the module’s DI bootstraps — services register in GetIt, and a ShellRoute wrapper injects them into the widget tree via MultiProvider.

The key architectural constraint: providers inside a ShellRoute are only available to screens that live inside that shell.If you navigate outside the shell, the providers vanish. This is by design — it’s what gives modules their isolation.

cabo_router.dart
dart
// Each game gets its own ShellRoute with an isolated navigator
List<RouteBase> getCaboRoutes() => [
  ShellRoute(
    navigatorKey: _caboNavigatorKey,
    builder: (context, state, child) => CaboProviders(child: child),
    routes: [
      GoRoute(path: CaboRoutes.home, builder: ...),
      GoRoute(path: CaboRoutes.game, builder: ...),
    ],
  ),
];

CABO providers wraps the entire ShellRoute. GameService, SessionManager, CaboHomeController— they’re all injected here, and they’re all torn down when the user leaves the module.

The Bug: notification taps landed outside the shell

When a player taps a “come play” push notification, the FCM handler routes them into the Cabo module. The handler had this code:

fcm_handler_service.dart (broken)
dart
// When the user taps a room invite notification...
final invite = InviteParameters(roomId);
appRouter.router.push(CaboRoutes.game, extra: invite);

Looks harmless. But push() uses the root navigator, not the ShellRoute’s nested navigator. The route lands on the root stack — outside CaboProviders, outside the shell’s Provider scope. The game screen tries to read context.read<GameService>()and crashes because the provider doesn’t exist on this navigator’s widget tree.

Regular in-app navigation used go(), which replaces the entire route stack and enters the ShellRoute correctly. The notification handler used push(), which stacks a new route on top — outside the shell.

go() replaces the stack. push() adds on top. Inside a ShellRoute, that difference is the line between working and crashing.

The Fix: go() instead of push()

fcm_handler_service.dart (fixed)
dart
// Fixed: use go() to enter the ShellRoute properly
final invite = InviteParameters(roomId);
appRouter.router.go(CaboRoutes.home, extra: invite);

One method swap. go() replaces the entire route stack, which means GoRouter processes the redirect chain, bootstraps the ShellRoute, and the game screen lands inside CaboProviders. The provider is found. The game loads.

But this revealed a second problem.

The Second Trap: lazy Provider creation

Even after fixing push() to go(), I hit a different crash on fast navigation sequences — tapping a notification, backing out immediately, and tapping again.

The module’s DI uses lazy bootstrapping. Services register in GetIt the first time someone navigates to the module. When the user leaves, reset() unregisters everything. The next entry re-boots the module.

cabo_module.dart
dart
class CaboModule implements GameModule {
  bool _isBooted = false;

  @override
  Future<void> ensureBooted() async {
    if (_isBooted) return;
    await CaboInjection.register(deps);
    _isBooted = true;
  }

  void reset() {
    if (!_isBooted) return;
    locator<GameService>().dispose();
    locator.unregister<GameService>();
    // ... unregister everything
    _isBooted = false;
  }
}

The Provider declarations looked like this:

cabo_providers.dart (problematic)
dart
ChangeNotifierProvider<GameService>(
  create: (_) => locator<GameService>(),
  child: child,
)

Here’s the trap: Provider(create:) is lazy. It doesn’t call the create function until a descendant widget first asks for the provider. If the user navigates in and out fast enough — before the game screen builds — reset() unregisters the service from GetIt before create ever runs. The next access throws: Object not found.

The fix: use Provider.value() instead, which resolves the instance eagerly at provider-build time:

cabo_providers.dart (fixed)
dart
ChangeNotifierProvider.value(
  value: locator<GameService>(),
  child: child,
)

.value() resolves the GetIt lookup immediately, before the provider enters the tree. If reset()hasn’t run yet, the service is still registered, and the lookup succeeds. If reset() has run and the user tries to navigate back in, ensureBooted() re-registers everything first.

The Complete Pattern for ShellRoute Modules

These two bugs — push() vs go() and lazy vs eager provider creation — are the same architectural problem viewed from different angles. In both cases, code that runs outside the ShellRoute is trying to use things that only exist inside it.

The complete pattern I now use for every game module:

  1. Navigate with go(), never push()push() stacks on the root navigator. go() replaces the stack and enters the ShellRoute correctly. The only exception is within a module itself — there, push()is fine because you’re already inside the shell.
  2. Use Provider.value() for GetIt singletons — eagerly resolve the instance at provider-build time. Never use create: for services that might be unregistered during fast navigation.
  3. Boot lazily, clean up eagerly — the module’s ensureBooted() is called by the router redirect on first entry. reset() is called when the user exits. Both are idempotent.
  4. The router doesn’t know about any game’s internals — it only knows GameModule.ensureBooted() and the route list. No service imports, no DI references, no game-specific logic.

Why GoRouter Doesn’t Warn You

GoRouter is doing exactly what you asked it to do. push() pushes a route onto the current navigator. If you’re outside a ShellRoute, the current navigator is the root. The route renders. The widget tree is valid. There’s no compile-time error, no assertion failure — just a runtime crash from a provider that doesn’t exist in this part of the tree.

ShellRoutes create a separate navigator for their children. This is what gives modules their isolated back-stack — pressing back inside Cabo doesn’t pop you to the home screen, it pops within the game’s own stack. But it also means providers declared inside the shell’s builder are scoped to that navigator. Cross the boundary incorrectly, and they’re invisible.

The GoRouter docs mention ShellRoutes for bottom navigation — a valid pattern. What they don’t mention is what happens when you push() from outsideany shell into one. The behavior is silently wrong. The route appears to work if you don’t use any module-scoped providers. The crash only surfaces when a descendant widget reaches for GetIt or reads a Provider that lives inside the shell.

If your ShellRoute providers ever crash with “not found,” check whether the route was pushed from outside the shell. If it was, that’s your bug.

The redirect trick for lazy module boot

Once I had the navigation fixed, the next question was: where does the module’s DI boot happen? I didn’t want to eagerly boot all four game modules on app startup. Cabo’s WebSocket service shouldn’t be in memory if the user is playing Trek.

The solution: a GoRouter redirectthat checks if the destination belongs to a module, and boots it if it hasn’t been booted yet.

app_router.dart
dart
Future<String?> _handleModuleBoot(
    BuildContext context, GoRouterState state) async {
  final path = Uri.parse(state.location).path;

  for (final module in _modules) {
    final base = module.basePath;
    if (path == base || path.startsWith('${base}/')) {
      if (!module.isBooted) await module.ensureBooted();
    }
  }
  return null; // null = proceed with navigation as-is
}

The redirect returns null, meaning navigation proceeds unchanged. The boot is a side effect. If the module is already booted, the check is O(1). If it isn’t, the redirect pauses navigation, boots the module’s GetIt registrations, and then lets the route render.

This pattern — redirect as a hook for lazy DI boot — is the only reason Cabo, Trek, Recall, and Wordgrid can coexist in the same app without each other’s services in memory. The router doesn’t know what ensureBooted()does. It just knows that modules need booting before they’re visited. Clean contract, zero coupling.

What I’d Tell My Past Self

  1. Never push() into a ShellRoute from outside. Always use go(). The only safe push()is within a module’s own nested routes.
  2. Never use Provider(create:) with GetIt singletons that can be unregistered. Use Provider.value() to eagerly resolve them. The laziness of create: is the enemy of modules that boot and teardown on demand.
  3. Navigator keys are load-bearing.Each ShellRoute gets its own navigator key. Use the wrong one, and your route lands on the wrong stack. This isn’t documented well, but it’s the fundamental architectural constraint.
  4. Test notification entry points. The bug only showed up from notification taps because that was the only code path that used push() instead of go(). Automated tests navigating with go() never caught it.

Four hours for one method call. Two more days understanding why. One character difference between a shipping app and a crash loop. That’s the GoRouter + Provider trap. And nobody warned me about it — so now I’m warning you.