Skip to content

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
│   └── template_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)
│   ├── app_launch_tracker_impl/ # Launch timing with native channel (debug)
│   ├── app_launch_tracker_noop/ # Zero-cost launch tracker stubs (release)
│   ├── 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 root

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.

┌─────────────────────────────────┐
│            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 → Do other packages reference the contract?
                │          ├── YES → API/Impl/Noop split (monitoring pattern)
                │          └── NO  → Impl/Noop split, unified class name (launch tracker pattern)
                └── 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.

dart
// 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});
}
dart
// 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. There are two variants depending on whether other packages need to reference the contract.

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.

dart
// 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);
}
dart
// infrastructure/monitoring_impl/lib/src/crashlytics_monitoring.dart
@LazySingleton(as: AppMonitoring)
class CrashlyticsMonitoring implements AppMonitoring { ... }

// infrastructure/monitoring_noop/lib/src/noop_monitoring.dart
@LazySingleton(as: AppMonitoring)
class NoopMonitoring 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();
}

build_prepare swaps monitoring_implmonitoring_noop in pubspec.yaml. Because both register as: AppMonitoring, the host code needs no changes.

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.

app_launch_tracker_impl / app_launch_tracker_noop is the canonical example:

dart
// infrastructure/app_launch_tracker_impl/lib/src/app_launch_tracker_impl.dart
@lazySingleton
class AppLaunchTracker {
  void start() { /* captures native timestamp via platform channel */ }
  void markPreLaunchCompleted() { /* records timing */ }
  void markFirstFrameRendered() { /* records timing */ }
  void startPostLaunch() { /* starts post-launch stopwatch */ }
  void markPostLaunchCompleted() { /* pushes OTLP spans via AppMonitoring */ }
  void recordStep(String step) { /* named milestone */ }
  void recordDuration(String name, int durationMs) { /* named duration */ }
}

// infrastructure/app_launch_tracker_noop/lib/src/app_launch_tracker_noop.dart
@lazySingleton
class AppLaunchTracker {
  void start() {}
  void markPreLaunchCompleted() {}
  void markFirstFrameRendered() {}
  void startPostLaunch() {}
  void markPostLaunchCompleted() {}
  void recordStep(String step) {}
  void recordDuration(String name, int durationMs) {}
}

The host app always imports package:app_launch_tracker_impl/app_launch_tracker_impl.dart. When build_prepare release runs, it rewrites that import to package:app_launch_tracker_noop/app_launch_tracker_noop.dart in every .dart file under the target path's lib/ and test/. The class name AppLaunchTracker is identical in both packages, so compiled host code is valid with either.

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.

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

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.

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

dart
// 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);
}
dart
// 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.

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 alongside its FeatureSDK and contributes a priority-ordered claim:

dart
// 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, null once done.
  • Auth (priority 20) — resolves the login route when unauthenticated, null when 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 initialize(), so SDKs have restored persisted state. 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
dart
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.

dart
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, launch profiling) from release binaries.

build_prepare performs two passes:

  1. pubspec.yaml swap — replaces debug package names with noop package names in all pubspec.yaml files under target_paths
  2. Dart import rewrite — replaces package: import URIs in .dart files under lib/ and test/ inside each target_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:

yaml
build_prepare:
  mappings:
    - debug: monitoring_impl
      release: monitoring_noop

    - debug: app_launch_tracker_impl
      release: app_launch_tracker_noop

  target_paths:
    - apps/your_app

Usage

bash
# Before building for production
melos run build-prepare:release
flutter build appbundle --release
bash
# After building, restore debug dependencies
melos run build-prepare:debug

TIP

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.

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.

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

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

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

TIP

Generated files (*.g.dart, *.module.dart) should be committed to version control. Run melos run build after adding new injectable classes.

Built by Banua Coder