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
│ └── app_widgetbook/ # Widgetbook for UI component catalog
├── features/ # Feature modules (business logic lives here)
│ ├── auth/
│ │ ├── auth_api/ # Contracts (abstract classes)
│ │ └── auth_impl/ # Concrete implementation
│ ├── home/
│ └── settings/
├── infrastructure/ # SDK wrappers and vendor implementations
│ ├── network_sdk_api/ # HTTP client contract
│ ├── network_sdk_dio/ # Dio implementation
│ ├── network_sdk_http/ # http package implementation
│ ├── analytics_api/ # Analytics contract
│ ├── analytics_impl/ # Default analytics orchestrator
│ ├── monitoring_api/ # Monitoring/crash reporting contract
│ ├── monitoring_impl/ # Crashlytics-based monitoring (debug)
│ ├── monitoring_noop/ # No-op monitoring (release builds)
│ ├── feature_flag_api/ # Feature flag contract
│ └── feature_flag_impl/ # Default feature flag implementation
├── shared/ # Cross-cutting shared packages
│ ├── app_config/ # Environment config (envied, flavors)
│ ├── dependencies/ # Centralized dependency re-exports
│ ├── feature_sdk/ # Base FeatureSDK contract + Registry
│ ├── locale_core/ # i18n/l10n setup
│ └── router_registry/ # Route registration abstraction
├── packages/ # App-specific packages
│ ├── app_asset/ # Generated asset constants
│ ├── 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
│ └── scripts/
│ └── build_prepare/ # Build-time dependency swapper
├── build_prepare.yaml # Debug/release swap configuration
├── flavors.yaml # Flavor definitions
├── melos.yaml # Melos workspace configuration
└── pubspec.yaml # Dart workspace rootTIP
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.
┌─────────────────────────────────┐
│ apps/ │ ← Orchestrator (DI wiring, app shell)
├─────────────────────────────────┤
│ features/ │ ← Business logic, UI, use cases
├─────────────────────────────────┤
│ infrastructure/ │ ← SDK wrappers, vendor implementations
├─────────────────────────────────┤
│ packages/ │ ← App-specific libraries (UI kit, assets)
├─────────────────────────────────┤
│ shared/ + utilities/ │ ← Cross-cutting contracts, helpers
└─────────────────────────────────┘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:
Does this package need multiple implementations?
├── YES → API/Impl split (e.g., network_sdk_api + network_sdk_dio)
└── NO
│
Does any other package consume its contracts?
├── YES → API/Impl split
└── NO
│
Does it need build-time stripping (debug/release)?
├── YES → Impl + Noop split (e.g., monitoring_impl + monitoring_noop)
└── NO → Single package (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/network_client.dart
abstract class NetworkClient {
Future<Response> get(String path, {Map<String, dynamic>? queryParams});
Future<Response> post(String path, {dynamic body});
}// infrastructure/network_sdk_dio/lib/src/dio_network_client.dart
@LazySingleton(as: NetworkClient)
class DioNetworkClient implements NetworkClient {
// Dio-specific implementation
}Impl + Noop (Build-Prepare Swap)
For packages that should be stripped from release builds (monitoring, debugger). The _impl package provides the real implementation, while _noop provides a no-op version.
Example: monitoring_noop
@LazySingleton(as: AppMonitoring)
class NoopMonitoring implements AppMonitoring {
@override
Future<void> recordError(dynamic error, StackTrace stack) async {}
@override
Future<void> log(String message) async {}
}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: NetworkClient)
class DioNetworkClient implements NetworkClient { ... }
@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(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);TIP
The dependencies package re-exports common DI annotations (@singleton, @lazySingleton, @injectable, etc.) so individual packages don't need to depend on injectable directly.
FeatureSDK Self-Registration
Each feature implements the FeatureSDK contract and registers itself with FeatureSDKRegistry. This enables the app to discover features without hardcoded references.
// shared/feature_sdk/lib/src/feature_sdk.dart
abstract class FeatureSDK {
String get name;
void registerRoutes(RouteRegistry registry);
Future<void> launch(BuildContext context);
}
class FeatureSDKRegistry {
static final instance = FeatureSDKRegistry._();
FeatureSDKRegistry._();
final List<FeatureSDK> _features = [];
List<FeatureSDK> get features => List.unmodifiable(_features);
void register(FeatureSDK sdk) => _features.add(sdk);
}// features/auth/auth_impl/lib/src/auth_sdk_impl.dart
@LazySingleton(as: AuthSDK)
class AuthSDKImpl implements AuthSDK {
AuthSDKImpl() {
FeatureSDKRegistry.instance.register(this);
}
@override
String get name => 'auth';
@override
void registerRoutes(RouteRegistry registry) {
registry.addRoute('/login', (context) => const LoginPage());
registry.addRoute('/register', (context) => const RegisterPage());
}
@override
Future<void> launch(BuildContext context) async {
// Initialize feature-specific resources
}
}WARNING
Features must register with FeatureSDKRegistry before the router is created. This is enforced by the two-phase initialization pattern.
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. All FeatureSDKs are now registered via DI constructors
// Register their routes
for (final sdk in FeatureSDKRegistry.instance.features) {
sdk.registerRoutes(getIt<RouteRegistry>());
}
// 3. Build router from registered routes
getIt<RouteRegistry>().freeze();
}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 builds. This strips development-only code (monitoring dashboards, debug overlays) from release binaries.
Configuration
Define swaps in build_prepare.yaml at the workspace root:
swaps:
- package: apps/your_app
debug_dep: monitoring_impl
release_dep: monitoring_noop
dep_path: infrastructure/monitoring
- package: apps/your_app
debug_dep: app_debugger
release_dep: app_debugger_noop
dep_path: utilities/debuggerUsage
# Before building for production
melos run build-prepare:release
flutter build appbundle --release# After building, restore debug dependencies
melos run build-prepare:debugTIP
Fastlane's before_all hook automatically runs build-prepare release before production builds and restores debug mode afterward. You don't need to run these manually in CI.
How It Works
- Reads
build_prepare.yamlfor swap definitions - For each swap, modifies the target package's
pubspec.yaml - Replaces the
debug_deppath withrelease_dep(or vice versa) - Both
_impland_nooppackages implement the same_apicontract, so the app compiles with either
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
For loosely-coupled communication where features need to react to events from other features without direct dependencies.
// 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 (*.g.dart, *.module.dart) should be committed to version control. Run melos run build after adding new injectable classes.