Skip to content
Back to archipelago.banuacoder.com

Architecture Deep Dives

Dual-GetIt DI Pattern ​

What you'll learn ​

  • Why Archipelago uses two GetIt instances (global + local per feature)
  • How infrastructure SDKs register in the global scope
  • How each feature gets its own child container through its shell page
  • The lifecycle: acquire a lease, use, release, tear down

The Problem ​

In a monorepo with many feature modules, registering everything in a single DI container causes:

  • Naming collisions between features
  • Leaked state when navigating between features
  • Tight coupling: features can accidentally depend on each other's internals

The Solution: Global + Local GetIt ​

Archipelago uses two levels of GetIt:

Global GetIt (GetIt.instance) ​

Shared infrastructure is available everywhere and registered once at app startup.

dart
// apps/template_app/lib/di/injector.dart
final getIt = GetIt.instance;

@InjectableInit(
  externalPackageModulesBefore: [
    ExternalModule(AppUtilitiesPackageModule),
    ExternalModule(NetworkSdkDioPackageModule),
    ExternalModule(MonitoringImplPackageModule),
    ExternalModule(RouterRegistryPackageModule),
    // Feature modules: a const manifest + a lazy SDK (auth stays eager)
    ExternalModule(AuthImplGlobalModule),
    ExternalModule(HomeImplGlobalModule),
  ],
)
Future<void> initializeInjector() => $initGetIt(getIt);

Infrastructure packages expose their module via @InjectableInit.microPackage():

dart
// infrastructure/network_sdk_dio/lib/src/di/injector.dart
@InjectableInit.microPackage()
void initMicroPackage(GetIt getIt) => $initMicroPackage(getIt);

Feature container (one child GetIt per feature) ​

Each feature keeps its internal deps (repos, datasources, usecases) in its own FeatureContainer, a child GetIt. The child never falls back to the root: a feature resolves what it registers plus the root types it imports by name, nothing else.

dart
// features/home/home_impl/lib/src/di/home_imports.dart
// The only file in the feature that reads the root.
void importHomeGlobals(GetIt child, GetIt root) {
  // The dashboard's logout button.
  child.registerFactory<AuthSDK>(() => root<AuthSDK>());
}

// features/home/home_impl/lib/src/di/home_container.dart
final homeContainer = FeatureContainer(
  'home',
  root: GetIt.instance,
  build: buildHomeContainer,
);

void buildHomeContainer(GetIt child, GetIt root) {
  importHomeGlobals(child, root);
  initHomeLocalDI(child); // injectable-generated
}

An import is a factory that delegates to the root, so the root keeps ownership and the container's teardown never disposes it. Two features that import the same port get the same instance.

A singleton the feature registers by hand that holds resources passes dispose:, so the container's teardown releases it:

dart
child.registerLazySingleton<HomeCache>(
  HomeCache.new,
  dispose: (cache) => cache.close(),
);

Shell Page Lifecycle ​

The lease and the container, step by step:

dependency injectionOne root GetIt, and a child container per feature

Step 1 of 5, root: The app root holds what lives for the whole run: SDKs, network, auth. Each feature also has its own container, but it only exists while one of its screens is open.

features/home/home_impl/lib/src/presentation/home_shell_page.dart
class _HomeShellPageState() extends State<HomeShellPage> {  late final FeatureLease _lease;  // …}
features/home/home_impl/lib/src/di/home_imports.dart

The container is leased by the feature's shell page, the StatefulWidget that wraps all nested routes:

dart
class _HomeShellPageState() extends State<HomeShellPage> {
  final _isReady = ValueNotifier<bool>(false);
  late final FeatureLease _lease;

  @override
  void initState() {
    super.initState();
    _lease = homeContainer.acquire();
    // Build the content only once the container is ready.
    unawaited(
      _lease.ready
          .then((_) => _isReady.value = true)
          .catchError(_reportContainerError),
    );
  }

  @override
  void dispose() {
    _isReady.dispose();
    unawaited(_lease.release().catchError(_reportContainerError));
    super.dispose();
  }
}

Leases are ref-counted. The first acquire() builds the container. The last release() tears it down. A same-route replace keeps it, because the new shell mounts before the old one disposes. _reportContainerError forwards to FlutterError.reportError. The shell creates the feature's Cubit, Bloc or ChangeNotifier from _lease.get<T>() and provides it above the AutoRouter. Pages never read GetIt.

auth and paywall use the same shape. auth's AuthSDK and LoginRepository stay eager in the root. The session is restored before the first screen, and tokens refresh with no auth screen mounted. auth_imports.dart forwards both into the container.

Accessing Dependencies ​

dart
// Root: in DI files and global modules
final authSDK = getIt<AuthSDK>();

// Feature: through the shell's lease, then constructor injection
final cubit = ProfileCubit(_lease.get<LoadProfileUseCase>());

When to Use Each Scope ​

LevelRegisterExamples
GlobalApp startup (manifests; SDKs lazy, auth eager)SDKs, network, monitoring, auth state
Feature containerShell page's first leaseRepos, datasources, usecases

Key Rules ​

  1. Feature SDKs are global and lazy: the global module registers a const FeatureManifest (routes) in FeatureSDKRegistry and the SDK as a lazy singleton. Only boot-critical SDKs like auth are eager
  2. Internal deps are local: repos, datasources, usecases stay in the feature's own container
  3. Root types are imported explicitly: one line per type in <name>_imports.dart. A type that is not imported throws instead of resolving another feature's registration
  4. Torn down after the last lease: the container is reset when the last shell releases its lease, so no state leaks between feature visits

Next Steps ​