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.
// 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():
// 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.
// 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:
child.registerLazySingleton<HomeCache>(
HomeCache.new,
dispose: (cache) => cache.close(),
);Shell Page Lifecycle
The lease and the container, step by step:
HomeSDKlazyAuthSDKeagerHttpClientlazy
AuthSDKimportedinitHomeLocalDIlocal
built when the first screen takes a lease
leases 0
class _HomeShellPageState() extends State<HomeShellPage> { late final FeatureLease _lease; // …}The container is leased by the feature's shell page, the StatefulWidget that wraps all nested routes:
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
// 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
| Level | Register | Examples |
|---|---|---|
| Global | App startup (manifests; SDKs lazy, auth eager) | SDKs, network, monitoring, auth state |
| Feature container | Shell page's first lease | Repos, datasources, usecases |
Key Rules
- Feature SDKs are global and lazy: the global module registers a const
FeatureManifest(routes) inFeatureSDKRegistryand the SDK as a lazy singleton. Only boot-critical SDKs like auth are eager - Internal deps are local: repos, datasources, usecases stay in the feature's own container
- 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 - 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
- Feature Manifests and SDKs: how features register
- Two-Phase Initialization: the boot sequence