Architecture Deep Dives
Understanding Feature Manifests and SDKs
What you'll learn
- What a
FeatureManifestdeclares and why it is registered at boot - Why a feature's SDK is a lazy singleton behind its
_apiport - When a feature stays an eager
FeatureSDK(auth) - How
RouteRegistry.lock()prevents late route additions
HomeManifestconst
no routes yet
HomeSDKregistered, not built
// features/home/home_impl/lib/src/di/home_global_module.dartclass HomeImplGlobalModule() extends MicroPackageModule { @override FutureOr<void> init(GetItHelper gh) { FeatureSDKRegistry.instance.register(const HomeManifest()); gh.lazySingleton<HomeSDK>(HomeSDKImpl.new); }}The problem
In a modular monorepo, adding a new feature typically requires touching the app layer in multiple places: adding routes to the router, wiring up DI, adding initialization calls. With many features, this creates a central bottleneck.
A second problem: the router needs every route before the first frame (deep links depend on it), but most features are never opened in a given session. Building every feature at boot to get its routes wastes startup time.
The solution: a manifest per feature
Each feature registers a small, const FeatureManifest with FeatureSDKRegistry from its global DI module. The host reads the manifests during pre-launch and builds the router. The feature's behaviour lives in its SDK, registered as a lazy singleton, so it is built only when something resolves it.
The FeatureManifest contract
// shared/feature_sdk/lib/src/feature_manifest.dart
abstract class const FeatureManifest() {
bool get isEnabled => true;
Map<PageIdentifier, PageConfig> get pageConfigurations => const {};
void registerRoutes(RouteRegistry registry);
Future<void> initialize() async {}
Future<void> dispose() async {}
}
// shared/feature_sdk/lib/src/feature_sdk.dart
abstract class const FeatureSDK() extends FeatureManifest {
Future<void> launch(BuildContext context);
}| Member | Purpose |
|---|---|
registerRoutes | Adds the feature's AutoRoute entries to the app router |
pageConfigurations | Page identifiers for deep links, notifications and analytics |
isEnabled | false skips the feature (per-flavor stripping) |
initialize / dispose | Optional pre-first-frame work and its cleanup; default no-op |
A manifest never resolves from the service locator in its constructor, isEnabled or registerRoutes. If a route needs a collaborator (a guard), pass a provider closure so the lookup runs on navigation.
The registry
class FeatureSDKRegistry {
static final FeatureSDKRegistry instance = FeatureSDKRegistry._();
List<FeatureManifest> get manifests;
List<FeatureManifest> get enabledManifests;
FeatureManifest? findManifestForPage(PageIdentifier id);
String resolveInitialPath({String fallback = '/'});
void register(FeatureManifest manifest); // throws on a duplicate
void seal(); // called by the pre-launch sweep; a later register() asserts
}Implementing a feature
// features/home/home_impl/lib/src/home_manifest.dart
class const HomeManifest() extends FeatureManifest
implements InitialPathProvider {
@override
InitialPathClaim get initialPathClaim =>
InitialPathClaim(priority: 100, resolve: () => '/home');
@override
void registerRoutes(RouteRegistry registry) {
registry.register([
AutoRoute(
page: HomeShellRoute.page,
path: '/home',
children: [AutoRoute(page: HomeRoute.page, path: '', initial: true)],
),
]);
}
}
// features/home/home_api/lib/src/home_sdk.dart: a plain port
abstract interface class HomeSDK() {
Future<void> launch(BuildContext context);
Future<void> goToSection(BuildContext context, String sectionId);
}
// features/home/home_impl/lib/src/di/home_global_module.dart
class HomeImplGlobalModule() extends MicroPackageModule {
@override
FutureOr<void> init(GetItHelper gh) {
FeatureSDKRegistry.instance.register(const HomeManifest());
gh.lazySingleton<HomeSDK>(HomeSDKImpl.new);
}
}The port does not extend FeatureSDK, so other features see only navigation, never lifecycle methods.
The eager exception
Auth must exist before the first frame: its initialize() restores the session, and the network layer's token provider comes from the same object. So AuthSDKImpl implements FeatureSDK, registers itself from a static create(), and its global module registers it eagerly:
GetIt.instance.registerSingleton<AuthSDK>(AuthSDKImpl.create(...));Other SDKs stay eager when their constructor or initialize() does startup work: push_notification (it attaches message listeners), connectivity, shorebird, webview_advanced, app_blackbox and the debug tooling. Never register such an SDK lazily. It would join the registry after the sweep has sealed it.
Passive SDKs are lazy ports that no longer extend FeatureSDK and are not in the registry: autoscroll, biometric_auth, rating_review, feedback, social_auth and webview. Each does its work on first use.
RouteRegistry lock pattern
RouteRegistry uses a lock mechanism to prevent routes from being added after the router is built:
abstract class RouteRegistry {
void register(List<AutoRoute> routes);
List<AutoRoute> get routes;
bool get isLocked;
void lock();
}During pre-launch, after every manifest has called registerRoutes(), the registry is locked. Any later registration throws a StateError.
Registration flow
1. bootstrap.dart calls initializeInjector()
2. Global DI runs each module's init() in order:
|- infrastructure modules
|- AuthImplGlobalModule.init() -> registerSingleton<AuthSDK>(create()) (eager, registers itself)
|- HomeImplGlobalModule.init() -> register(const HomeManifest()); lazySingleton<HomeSDK>
\- PaywallImplGlobalModule.init() -> register(const PaywallManifest()); lazySingleton<PaywallSDK>
3. runApp(HostAppWidget(...))
4. PreLaunchInitializer seals the registry and reads enabledManifests
5. Calls manifest.registerRoutes(routeRegistry) for each
6. routeRegistry.lock()
7. Calls manifest.initialize() for each (auth and the eager SDKs do real work)
8. Returns InitializerResult with AppRouter; initialPath runs the claimsHomeSDKImpl and PaywallSDKImpl do not exist yet at step 8. They are built the first time something resolves HomeSDK or PaywallSDK.
Adding a new feature
- Create the feature packages (
feature_api/,feature_impl/), or runarchipelago generate feature_monorepo_skeleton - Add a const manifest with the routes
- Implement the port in
XSDKImplwith a plain constructor - Create a global module that registers the manifest and the lazy SDK
- Add the module to the app injector (
monorepo_toolkit generate host-registry)
No router file to update, no startup sequence to modify.
Next steps
- GetIt Dependency Injection -- How global DI and microPackage modules work
- Two-Phase Initialization -- How the boot sequence orchestrates registration and launch