Architecture
Archipelago generates a Flutter modular monorepo with a strict layered architecture. Each feature is a self-contained "island" connected by shared infrastructure — the water between islands.
Monorepo Structure
A generated project follows this directory layout:
your_app/
├── apps/ # Host applications
│ ├── your_app/ # Main app — orchestrator, no business logic
│ └── your_app_widgetbook/ # Widgetbook for UI component catalog
├── features/ # Feature modules (business logic lives here)
│ ├── auth/ # auth_api + auth_impl
│ ├── home/ # home_api + home_impl
│ ├── introduction/ # Onboarding
│ └── paywall/ # paywall_api + paywall_impl
├── infrastructure/ # SDK wrappers and vendor implementations
│ ├── network_sdk_api/ # HTTP client contract
│ ├── network_sdk_dio/ # Dio implementation (or network_sdk_http)
│ ├── analytics_api/ # Analytics contract
│ ├── analytics_impl/ # Default analytics orchestrator
│ ├── monitoring_api/ # Monitoring/crash reporting contract
│ ├── monitoring_impl/ # Vendor fanout behind the consent gate (debug + release)
│ ├── monitoring_noop/ # No-op monitoring (tests / opt-out)
│ ├── app_launch_tracker_impl/ # Launch timing with native channel (debug + release)
│ ├── feature_flag_api/ # Feature flag contract (when feature flags are enabled)
│ └── feature_flag_impl/ # Default feature flag implementation
├── shared/ # Cross-cutting shared packages
│ ├── app_config/ # Environment config (envied, flavors)
│ ├── consent_api/ # Telemetry consent contract
│ ├── consent_impl/ # Consent implementation
│ ├── core_infrastructure_sdk/ # Core infrastructure API/Impl
│ ├── dependencies/ # Centralized dependency re-exports
│ ├── feature_sdk/ # FeatureManifest, FeatureSDK, registry, FeatureContainer
│ ├── identifiers/ # Shared identifiers
│ ├── locale_core/ # i18n/l10n setup
│ └── router_registry/ # Route registration abstraction
├── packages/ # App-specific packages
│ ├── your_app_asset/ # Generated asset constants
│ ├── your_app_ui_kit/ # Design system, theme, widgets
│ └── asset_core/ # Asset generation tooling
├── utilities/ # Generic utilities
│ ├── app_lints/ # Custom lint rules
│ └── app_utilities/ # Logger, extensions, helpers
├── devtools/ # Development tooling
│ ├── monorepo_toolkit/ # The monorepo_toolkit CLI
│ └── scripts/ # build_prepare, env_sync, flavor_manager, ...
├── build_prepare.yaml # Debug tooling swap by flavor
├── flavors.yaml # Flavor definitions
└── pubspec.yaml # Dart workspace root + melos scriptsThe exact set of features and infrastructure packages depends on the options you generate with (starterFeatures, networkLibrary, analyticsVendors, monitoringVendors, and so on). Melos is configured in the root pubspec.yaml, there is no separate melos.yaml.
TIP
The apps/your_app/ is an orchestrator only — it wires up DI, registers routes, and provides the app shell. All business logic lives in features/.
Dependency Flow
Dependencies flow strictly downward. A layer can only depend on layers below it — never upward or sideways within the same layer.
flowchart TD
apps["<b>apps/</b><br/>Orchestrator: DI wiring, app shell"]
features["<b>features/</b><br/>Business logic, UI, use cases"]
infra["<b>infrastructure/</b><br/>SDK wrappers, vendor implementations"]:::water
packages["<b>packages/</b><br/>App-specific libraries: UI kit, assets"]:::water
shared["<b>shared/ + utilities/</b><br/>Cross-cutting contracts, helpers"]:::water
apps -->|depends on| features
features -->|depends on| infra
infra -->|depends on| packages
packages -->|depends on| shared
WARNING
Packages within the same layer must never depend on each other directly. Use shared contracts in a lower layer for communication.
Package Split Decision Tree
Not every package needs an API/Impl split. Use this decision tree:
flowchart TD
q1{"Does this package need<br/>multiple implementations?"}
q1 -->|YES| s1["API/Impl split<br/>(e.g. network_sdk_api + network_sdk_dio)"]
q1 -->|NO| q2{"Does any other package<br/>consume its contracts?"}
q2 -->|YES| s2["API/Impl split"]
q2 -->|NO| q3{"Does it need build-time stripping<br/>(production flavor)?"}
q3 -->|YES| q4{"Do other packages<br/>reference the contract?"}
q4 -->|YES| s3["API/Impl/Noop split<br/>(monitoring pattern)"]
q4 -->|NO| s4["Impl/Noop split, unified class name<br/>(debug tooling pattern)"]
q3 -->|NO| s5["Single package<br/>(e.g. app_utilities)"]
API/Impl Split
Used when a capability is consumed by multiple packages or needs vendor swapping. The _api package defines contracts (abstract classes), and _impl packages provide concrete implementations.
// infrastructure/network_sdk_api/lib/src/client/http_client.dart
abstract interface class HttpClient {
Future<HttpResponse> get(String path, {Map<String, dynamic>? queryParameters, RequestOptions? options});
Future<HttpResponse> post(String path, {Object? body, Map<String, dynamic>? queryParameters, RequestOptions? options});
// put, patch, delete, …
}// infrastructure/network_sdk_dio/lib/src/client/dio_http_client_adapter.dart
class DioHttpClientAdapter implements api.HttpClient {
// Dio-specific implementation
}The same split is what lets you change vendors. Feature code depends on network_sdk_api; the app picks Dio or package:http:
dependencies: network_sdk_api: path: ../../infrastructure/network_sdk_api # … network_sdk_dio: path: ../../infrastructure/network_sdk_dioimport 'package:network_sdk_dio/network_sdk_dio.dart';// … externalPackageModulesBefore: [ ExternalModule(NetworkSdkDioPackageModule),class DioHttpClientAdapter(this._dio) implements api.HttpClient { @override Future<api.HttpResponse> post(String path, {Object? body, /* … */}) => _send('POST', path, body: body, /* … */);}Switching a vendor in a generated app
A generated app wires one implementation of a single-implementation contract, such as HttpClient. A brick for such a contract switches the vendor when you run it again with another vendor. Its post_gen hook calls shared_hooks unwireVendorImpl for the implementation it replaces. That call removes the old package from the app pubspec, and its import and ExternalModule from the app injector. Then the hook wires the new package. A run with the vendor that is already wired changes nothing. The old package directory is never deleted automatically, because it may contain your changes. The hook prints the command to delete it.
network_sdk and websocket_sdk support this now. See Switch the HTTP client and Switch the client. The analytics and monitoring vendors are not single-implementation contracts. An app can wire several of them, so they are added or removed one at a time.
Impl + Noop (Build-Prepare Swap)
For packages that must not ship in the production flavor. There are two variants depending on whether other packages need to reference the contract.
dependencies: # … app_debugger: path: ../../features/app_debugger/app_debuggerimport 'package:app_debugger/app_debugger.dart';// … externalPackageModulesBefore: [ // … ExternalModule(AppDebuggerPackageModule),class AppDebuggerSDKImpl(/* … */) implements AppDebuggerSDK { @override bool get isEnabled => true; @override Widget overlay({required Widget child}) => DebuggerOverlay(child: child); @override Future<void> openMenu() async { // Opens the debugger panel. }}With _api — when other packages reference the contract
Use this when feature code, other infrastructure packages, or the host app needs to import and call methods through an abstract type. monitoring_api is the canonical example: feature analytics code calls AppMonitoring.recordError(...) without knowing which vendor is wired in.
// infrastructure/monitoring_api/lib/src/app_monitoring.dart
abstract class AppMonitoring {
Future<void> recordError(dynamic error, StackTrace stack);
Future<void> log(String message);
Future<void> recordTransaction(String name, Future<void> Function() block);
}// infrastructure/monitoring_impl/lib/src/crashlytics_monitoring.dart
@LazySingleton(as: AppMonitoring)
class CrashlyticsMonitoring implements AppMonitoring { ... }
// infrastructure/monitoring_noop/lib/src/consent_aware_monitoring.dart
// same class name as monitoring_impl's AppMonitoring binding
class ConsentAwareMonitoring implements AppMonitoring {
@override
Future<void> recordError(dynamic error, StackTrace stack) async {}
@override
Future<void> log(String message) async {}
@override
Future<void> recordTransaction(String name, Future<void> Function() block) => block();
}A build_prepare mapping could swap monitoring_impl → monitoring_noop in pubspec.yaml, and because both export ConsentAwareMonitoring and MonitoringImplPackageModule bound as AppMonitoring, the host code would need no changes. Generated projects do not map monitoring by default: crash reporting ships in release, gated by consent.
Without _api — unified class name pattern
Use this when only the host app uses the package and no other code references an abstract type. Both _impl and _noop export the same class name. build_prepare rewrites the import path in .dart files; host code never changes.
A hypothetical my_tool_impl / my_tool_noop pair looks like this:
// infrastructure/my_tool_impl/lib/src/my_tool.dart
@lazySingleton
class MyTool {
void record(String step) { /* real work */ }
}
// infrastructure/my_tool_noop/lib/src/my_tool.dart
class MyTool {
void record(String step) {}
}The host app always imports package:my_tool_impl/my_tool_impl.dart. When build-prepare --flavor production runs, it rewrites that import to package:my_tool_noop/my_tool_noop.dart in every .dart file under the target path's lib/ and test/. The class name MyTool is identical in both packages, so compiled host code is valid with either.
The noop's DI module registers nothing: call sites resolve the type through a GetIt.I.isRegistered<MyTool>()-guarded nullable field and skip the work when it is absent, rather than the noop registering an always-inert stub instance. This is the generally-preferred default for any Impl + Noop pair.
The app launch tracker used to be the canonical example. It now ships in release (no noop package, no mapping), because its spans go through the consent-gated AppMonitoring.
TIP
Rule of thumb: if you would write @LazySingleton(as: SomeAbstractClass), you need _api. If you write @lazySingleton with no as: argument, you don't.
Single Package
When no isolation or swapping is needed. Use a single package without API/Impl split.
Examples: app_utilities, app_ui_kit, app_asset
Dual-GetIt Dependency Injection
Archipelago uses a two-scope DI pattern with GetIt + Injectable:
Global Scope
Registered once at app startup. Contains SDKs and infrastructure that live for the entire app lifetime.
// Eager/lazy singletons — always available
@LazySingleton(as: HttpClient)
DioHttpClientAdapter apiHttpClient(Dio dio) => DioHttpClientAdapter(dio);
@LazySingleton(as: AppMonitoring)
class CrashlyticsMonitoring implements AppMonitoring { ... }Registered via: @InjectableInit.microPackage() in each infrastructure package, then composed in the app's injector.dart.
// apps/your_app/lib/di/injector.dart
@InjectableInit(
externalPackageModulesBefore: [
ExternalModule(AppLaunchTrackerPackageModule),
ExternalModule(AppUtilitiesPackageModule),
ExternalModule(NetworkSdkDioPackageModule),
ExternalModule(MonitoringImplPackageModule),
ExternalModule(RouterRegistryPackageModule),
],
)
Future<void> initializeInjector() => $initGetIt(getIt);Local Scope (Per Feature)
Created when a feature launches, dropped when it exits. Contains repos, datasources, and use cases scoped to that feature.
// features/auth/auth_impl/lib/src/di/injector.dart
@InjectableInit.microPackage()
void initAuthModule(GetIt getIt) => $initGetIt(getIt);Each feature has its own container, auth and paywall included. No feature pushes a named scope onto the shared GetIt.instance any more.
Feature Container (per-feature child GetIt)
feature_sdk ships FeatureContainer. A generated feature with business logic gets three DI files:
<name>_container.dartholds<name>Container, whosebuildruns the imports and then the generatedinit<Name>LocalDI(child);<name>_imports.dartis the only file that reads the app root, one forwarded type per line;<name>_global_module.dartregisters the manifest and the lazy SDK in the root, plus any port the feature owns (annotated// di-global:).
The shell page holds a lease and passes _lease.get<T>() results into its state holder. No feature code outside lib/src/di/ reads GetIt.
- The container owns a child
GetIt.asNewInstance(). The child never falls back to the root. A feature resolves only what itsbuildcallback registered, plus the globals it imports by name. - An import is a factory that delegates to the root:
child.registerFactory<HttpClient>(() => root<HttpClient>()). The root keeps ownership, so the child's teardown never disposes it. acquire()returns aFeatureLeaseat once. The first lease builds the child;lease.readycompletes when the build is done. The lastrelease()awaitsreset(dispose: true).- Leases are ref-counted, so a same-route replace (new shell mounts before the old one disposes) keeps the child.
- An
acquire()during a running teardown waits for it, then builds a fresh child. - A failed
buildthrows to every lease'sreadyand resets the partial child; the nextacquire()retries. Teardown errors are thrown fromrelease().
final authContainer = FeatureContainer(
'auth',
root: GetIt.instance,
build: (child, root) {
child.registerFactory<HttpClient>(() => root<HttpClient>());
initAuthLocalDI(child);
},
);
// Shell page
late final _lease = authContainer.acquire(); // build after _lease.ready
@override
void dispose() {
unawaited(_lease.release().catchError(_report));
super.dispose();
}sequenceDiagram
participant New as New shell
participant Old as Old shell
participant C as FeatureContainer
participant Child as Child GetIt
Old->>C: acquire() (leases = 1)
C->>Child: asNewInstance() + build(child, root)
C-->>Old: lease.ready completes
Note over New,Old: Same-route replace: new shell mounts first
New->>C: acquire() (leases = 2)
C-->>New: lease.ready (same child, no rebuild)
Old->>C: release() (leases = 1, child kept)
New->>C: release() (leases = 0)
C->>Child: reset(dispose: true)
Note over C: Teardown in flight
New->>C: acquire() during teardown (leases = 1)
C-->>C: waits for teardown
Child-->>C: teardown done
C->>Child: fresh asNewInstance() + build
C-->>New: lease.ready completes
Archipelago rejected asNewInstance() in 2026-08 because the child could not reach global types. FeatureContainer keeps the child disconnected on purpose and solves that with explicit imports, so the feature's dependency on the root is a short, visible list instead of everything in the app.
TIP
The dependencies package re-exports common DI annotations (@singleton, @lazySingleton, @injectable, etc.) so individual packages don't need to depend on injectable directly.
Background Task Container (worker isolate)
A background task runs on an isolate the OS starts without the app, so GetIt.instance is empty there. A class that reads it works in the UI isolate and fails only when the OS fires the task, in release. The background_sdk brick gives every task its own container instead (see background_sdk):
BackgroundWorker.runbuilds a freshGetIt.asNewInstance()for each run, through the app'ssetupBackgroundDi. Nothing from the app'sgetItexists in it, and there is no root to import from.- The handler resolves its collaborators from that container and passes them on through constructors.
- The container is reset in a
finally. Everydispose:callback runs, so a database opened for the task is closed even when the handler throws. - Handlers are found in a
BackgroundTaskRegistrythat the app fills inregisterBackgroundTasks. Each feature exports its own registration function, so the dispatcher never imports a feature.
sequenceDiagram
participant OS as WorkManager (OS)
participant D as backgroundDispatcher
participant W as BackgroundWorker
participant R as BackgroundTaskRegistry
participant DI as Task container
participant H as Feature handler
OS->>D: start isolate, executeTask(task, input)
D->>W: run(task, input)
W->>R: registerBackgroundTasks(registry)
W->>R: handlerFor(task)
alt no handler
W-->>D: failure (error reported)
else handler found
W->>DI: asNewInstance() + setupBackgroundDi(di)
W->>H: handler(di, input)
H-->>W: success / retry / failure (a throw is failure)
W->>DI: reset() in finally, dispose callbacks run
W-->>D: result
end
D-->>OS: true = success, false = retry, throw = failure
This is the one other place that calls asNewInstance() directly. FeatureContainer does not fit: there is no root to import from, and a run has one holder, so leases add nothing.
Feature Manifest and SDK
A feature contributes two objects, registered at two different times:
FeatureManifest: what the host needs before the first frame. Its routes (registerRoutes), page identifiers (pageConfigurations), enable flag (isEnabled) and, if it has one, its initial-path claim. It isconstand depends on nothing. It goes intoFeatureSDKRegistry.- The SDK: what the feature does. Navigation (
launch()and its destinations), state and capabilities, behind the feature's_apiport. It is a lazy singleton, built the first time something resolves the port.
// 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([...]);
}
// 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);
}
}HomeSDK (in home_api) is a plain interface with launch() and its destinations. It does not extend FeatureSDK, so lifecycle members never leak onto the port other features import. A port method that hands data back returns Future<T?>, like auto_route's push<T>(); null means cancelled.
One rule keeps manifests cheap: a manifest never resolves from the service locator in its constructor, isEnabled or registerRoutes. That would put the feature's graph on the boot path. A route that needs a collaborator (a guard, say) gets a provider closure, so the lookup runs on navigation. initialize(), dispose() and an initial-path claim's resolve run later and may resolve freely; introduction's claim reads IntroductionSDK.isCompleted through a closure passed in by its global module. The app_lints rule no_service_locator_in_manifest enforces this.
FeatureSDK extends FeatureManifest and adds launch(). A feature that must exist at boot registers its SDK object itself, eagerly:
- auth:
initialize()restores the session before the first route resolves, and the token provider the network layer uses comes from the same object; - debug tooling and passive service SDKs that wire themselves up at startup (connectivity, shorebird, blackbox, push_notification, ...).
Passive SDKs with no startup work (autoscroll, biometric_auth, rating_review, feedback, social_auth, webview) are plain lazy ports with no manifest.
Everything else is lazy. apps/template_app/test/di/injector_test.dart asserts that HomeSDK, PaywallSDK, AiChatSDK, FeedbackSDK and SocialAuthSDK are not built by initializeInjector().
WARNING
Manifests must be registered before the router is created: register them from the feature's global DI module. The pre-launch sweep seals the registry, and a later register() trips a debug assert.
Initial Route Resolution
The app's startup route is resolved through the same registry, not hardcoded feature lookups. A feature that wants a say in where the app launches implements an opt-in InitialPathProvider interface on its manifest and contributes a priority-ordered claim:
// shared/feature_sdk
class InitialPathClaim {
const InitialPathClaim({required this.priority, required this.resolve});
/// Lower value wins. Convention: onboarding 10, auth 20, default home 100.
final int priority;
/// Returns the path to launch at, or null to pass to the next claim.
final String? Function() resolve;
}
abstract interface class InitialPathProvider {
InitialPathClaim get initialPathClaim;
}At pre-launch, FeatureSDKRegistry sorts every registered feature's claim by priority and resolves the first non-null path:
- Introduction (priority 10) — resolves its root path while onboarding is incomplete,
nullonce done. - Auth (priority 20) — resolves the login route when unauthenticated,
nullwhen a session exists. - Home (priority 100) — always resolves its root as the terminal fallback.
A bare scaffold with no starter features falls back to '/'. Adding or removing a feature never requires hand-editing app_router.dart — the router simply calls FeatureSDKRegistry.instance.resolveInitialPath().
TIP
This resolution runs once at startup, after every manifest's initialize(), so eager SDKs have restored persisted state. A claim's resolve may read a lazy SDK; that builds it then. Mid-session redirects (logout, deep links, auth gating) stay the job of router guards, not initial path claims.
Two-Phase Initialization
App startup is split into two phases to keep launch time fast:
Phase 1: Pre-Launch (Blocking)
Must complete before the app renders its first frame. Includes:
- DI container setup (global scope)
- Route registration (all features register routes)
- Critical SDK initialization
Future<void> preLaunch() async {
// 1. Initialize DI
await initializeInjector();
// 2. Every feature's global module registered its manifest.
// Register their routes, then run their initialize().
final registry = FeatureSDKRegistry.instance..seal();
for (final manifest in registry.enabledManifests) {
manifest.registerRoutes(getIt<RouteRegistry>());
}
getIt<RouteRegistry>().lock();
for (final manifest in registry.enabledManifests) {
await manifest.initialize();
}
}Phase 2: Post-Launch (Non-Blocking)
Initialized after the first frame. Includes analytics, feature flags, remote config — anything that is not needed for the initial render.
Future<void> postLaunch() async {
// Non-blocking: analytics, feature flags, remote config
await Future.wait([
getIt<AnalyticsOrchestrator>().initialize(),
getIt<FeatureFlagService>().initialize(),
]);
}TIP
Post-launch services use explicit lazy initialization, not microPackage registration. This keeps them out of the critical startup path.
Build-Prepare
The build_prepare tool swaps debug dependencies with no-op implementations for production-flavor builds. This removes development-only code (debug overlays, the Blackbox recorder and its native plugins) from the store binary. Internal flavors (internal_flavors, default development, staging, alpha, beta) keep it, also in release mode. Monitoring and the launch tracker are not swapped: they ship in every flavor, gated by consent.
build_prepare performs two passes:
- pubspec.yaml swap — replaces debug package names with noop package names in all
pubspec.yamlfiles undertarget_paths - Dart import rewrite — replaces
package:import URIs in.dartfiles underlib/andtest/inside eachtarget_path
The import rewrite is only needed for the unified class name pattern (no _api). When an _api package is in the middle (like monitoring_api), host code imports from _api — not from _impl — so no import rewrite is necessary.
Configuration
Define swaps in build_prepare.yaml at the workspace root:
build_prepare:
internal_flavors: [development, staging, alpha, beta]
mappings:
- debug: app_debugger
release: app_debugger_noop
target_paths:
- apps/your_appUsage
# Before building the production flavor
dart run monorepo_toolkit build-prepare --flavor production
flutter build appbundle --flavor production --release
dart run monorepo_toolkit build-prepare --check-leaks --flavor production# After building, restore the debug dependencies
dart run monorepo_toolkit build-prepare --restoreTIP
Fastlane's before_all hook runs build-prepare --flavor <flavor> with the build's own flavor, flutter_build runs the leak guard, and after_all/error restore. You don't need to run these manually in CI. Never run the production swap before an internal build.
Cross-Feature Communication
Features are isolated islands. They communicate through these patterns:
1. SDK Method Calls
Features expose methods through their FeatureSDK interface. Other features call these through the DI container.
// From any feature that needs auth info
final authSDK = getIt<AuthSDK>();
final isLoggedIn = authSDK.isAuthenticated;2. Route-Based Navigation
Features register routes via RouteRegistry. Navigation happens through named routes — no direct import of another feature's pages.
// Navigate to auth feature's login page
context.router.pushNamed('/login');3. Event Bus (optional, not generated)
Generated projects do not ship an event bus. If you need loosely-coupled communication where features react to events from other features without direct dependencies, add one yourself in a shared/ package. For example:
// Feature A publishes
eventBus.fire(OrderCompletedEvent(orderId: '123'));
// Feature B subscribes
eventBus.on<OrderCompletedEvent>((event) {
// React to order completion
});WARNING
Prefer SDK method calls for direct queries and route-based navigation for UI transitions. Use the event bus only when you need true decoupling (publisher doesn't know or care who listens).
File Structure per Package
Every package follows a consistent internal structure:
lib/
<package_name>.dart # Barrel export file
src/
di/
injector.dart # @microPackageInit annotation
injector.module.dart # Generated MicroPackageModule
register_module.dart # @module abstract class for external deps
<domain>/ # Domain-specific code
<name>.dart
test/
src/
<domain>/
<name>_test.dartTIP
Generated files such as *.g.dart, *.gr.dart and *.config.dart are gitignored by default (includeGenerated: false), so run melos run build after cloning and after adding new injectable classes. Injectable's injector.module.dart micro-package files are always committed, because the app's build cannot regenerate a dependency's module file. Generate with includeGenerated: true if you want to commit everything.