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
│   └── 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 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 → 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.

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 (monitoring, debugger). The _impl package provides the real implementation, while _noop provides a no-op version.

Example: monitoring_noop
dart
@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.

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(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.

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) from release binaries.

Configuration

Define swaps in build_prepare.yaml at the workspace root:

yaml
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/debugger

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.

How It Works

  1. Reads build_prepare.yaml for swap definitions
  2. For each swap, modifies the target package's pubspec.yaml
  3. Replaces the debug_dep path with release_dep (or vice versa)
  4. Both _impl and _noop packages implement the same _api contract, 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.

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