Skip to content
Back to archipelago.banuacoder.com

Architecture Deep Dives

Understanding Feature Manifests and SDKs ​

What you'll learn ​

  • What a FeatureManifest declares and why it is registered at boot
  • Why a feature's SDK is a lazy singleton behind its _api port
  • When a feature stays an eager FeatureSDK (auth)
  • How RouteRegistry.lock() prevents late route additions
feature registrationRegister at boot, seal, add routes, lock

Step 1 of 7, register: Each feature has a global DI module. Its init registers a const manifest with the registry and the SDK as a lazy singleton. Nothing builds the SDK yet.

features/home/home_impl/lib/src/di/home_global_module.dart
// 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 ​

dart
// 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);
}
MemberPurpose
registerRoutesAdds the feature's AutoRoute entries to the app router
pageConfigurationsPage identifiers for deep links, notifications and analytics
isEnabledfalse skips the feature (per-flavor stripping)
initialize / disposeOptional 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 ​

dart
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 ​

dart
// 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:

dart
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:

dart
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 claims

HomeSDKImpl and PaywallSDKImpl do not exist yet at step 8. They are built the first time something resolves HomeSDK or PaywallSDK.

Adding a new feature ​

  1. Create the feature packages (feature_api/, feature_impl/), or run archipelago generate feature_monorepo_skeleton
  2. Add a const manifest with the routes
  3. Implement the port in XSDKImpl with a plain constructor
  4. Create a global module that registers the manifest and the lazy SDK
  5. Add the module to the app injector (monorepo_toolkit generate host-registry)

No router file to update, no startup sequence to modify.

Next steps ​