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:

text
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 scripts

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

dart
// 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, …
}
dart
// 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:

API/Impl splitSwap the HTTP vendor without touching feature code

Step 1 of 2, Dio: Code that needs HTTP, like the Flagr feature-flag provider, depends only on the HttpClient contract in network_sdk_api. The app picks Dio in its pubspec and its injector.

infrastructure/flagr_feature_flag_impl/lib/src/flagr_feature_flag_provider.dart
apps/my_app/pubspec.yaml
dependencies:  network_sdk_api:    path: ../../infrastructure/network_sdk_api  # …  network_sdk_dio:    path: ../../infrastructure/network_sdk_dio
apps/my_app/lib/di/injector.dart
import 'package:network_sdk_dio/network_sdk_dio.dart';// …  externalPackageModulesBefore: [    ExternalModule(NetworkSdkDioPackageModule),
infrastructure/network_sdk_dio/lib/src/client/dio_http_client_adapter.dart
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.

build-prepareA production build swaps the package, not your code

Step 1 of 3, debug: In development and the other internal flavors (staging, alpha, beta) the app depends on app_debugger. App code names only AppDebuggerPackageModule and the AppDebuggerSDK contract, and both packages define exactly those names.

apps/template_app/pubspec.yaml
dependencies:  # …  app_debugger:    path: ../../features/app_debugger/app_debugger
apps/template_app/lib/di/injector.dart
import 'package:app_debugger/app_debugger.dart';// …  externalPackageModulesBefore: [    // …    ExternalModule(AppDebuggerPackageModule),
apps/template_app/lib/app.dart
features/app_debugger/app_debugger/lib/src/…_sdk_impl.dart
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.

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

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

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

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);

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.dart holds <name>Container, whose build runs the imports and then the generated init<Name>LocalDI(child);
  • <name>_imports.dart is the only file that reads the app root, one forwarded type per line;
  • <name>_global_module.dart registers 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 its build callback 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 a FeatureLease at once. The first lease builds the child; lease.ready completes when the build is done. The last release() awaits reset(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 build throws to every lease's ready and resets the partial child; the next acquire() retries. Teardown errors are thrown from release().
dart
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.run builds a fresh GetIt.asNewInstance() for each run, through the app's setupBackgroundDi. Nothing from the app's getIt exists 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. Every dispose: callback runs, so a database opened for the task is closed even when the handler throws.
  • Handlers are found in a BackgroundTaskRegistry that the app fills in registerBackgroundTasks. 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 is const and depends on nothing. It goes into FeatureSDKRegistry.
  • The SDK: what the feature does. Navigation (launch() and its destinations), state and capabilities, behind the feature's _api port. It is a lazy singleton, built the first time something resolves the port.
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([...]);
}

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

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

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

  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:
  internal_flavors: [development, staging, alpha, beta]
  mappings:
    - debug: app_debugger
      release: app_debugger_noop

  target_paths:
    - apps/your_app

Usage ​

bash
# 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
bash
# After building, restore the debug dependencies
dart run monorepo_toolkit build-prepare --restore

TIP

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.

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

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