How the pieces are put together, and what stops them from tangling.

Four layers, dependencies flowing one way, and one rule โ€” _api vs _impl โ€” that the analyzer enforces rather than a reviewer.

Layering

app โ†’ features โ†’ base / core

Nothing downstream ever imports upstream. core and base know nothing about any feature; features know nothing about each other except through another feature's _api.

/app โ€” the only leaf
appcomposition root ยท flavors ยท bootstrap/
wires every _impl ยท configures core ยท supplies the response contract
/features โ€” one _api + _impl pair per domain
counter_api + _impl
posts_api + _impl ยท data layer
localization
/base โ€” cross-feature primitives
app_localizationARB ยท locale scope
app_network_contractAppResponse ยท parser
depends on _api contracts only
/core โ€” infrastructure, each an api/impl pair
state_manager
router
dependency_injection
network
design_system
storage
logger
biometric_auth
app_linter
The one exception app_network_contract (base) depends on network_api (core) โ€” the dashed arrow in the source diagram. It defines this application's envelope on top of a transport-agnostic contract, which is why it lives in base rather than core.

The rule everything else follows from

_api says what a capability can do. _impl says how.

_api is abstract interfaces only โ€” feature code and other _api packages depend on these, never on an _impl. Only app imports _impl packages, and implementation_imports is an analyzer error, so a package's lib/src/ is genuinely unreachable from outside it โ€” not just discouraged.

counter_api

Abstract interfaces only. This is what the rest of the app is allowed to know.

abstract interface class CounterApi {
  CounterLauncher get launcher;
}

abstract interface class CounterLauncher {
  AppRouteRequest counter();
  AppRouteRequest details({required int count});
}
โ†’ resolved with context.locator<CounterApi>() โ‡ข never the reverse

counter_impl

The concrete facade, registered into the container. Its internals โ€” controllers, screens, route addresses โ€” stay in lib/src/.

final class CounterApiImpl implements CounterApi {
  const CounterApiImpl();

  @override
  CounterLauncher get launcher =>
      const CounterLauncherImpl();
}

Every feature, one protocol

Exactly one <Feature>Api per feature, grouping everything other modules may use. Use case protocols belong there as they appear โ€” no second entry point grows next to it.

Route addresses are private

<Feature>RouteInfo holds plain static const AppRouteInfo values and is never exported from the _impl barrel โ€” modular doctor checks this by name.

The data layer is invisible too

DTOs, repositories and error types live in _impl/lib/src/. The boundary is about the package, not about what's inside it โ€” see posts on the modules page.

State management

State/Event/Effect, with no third-party dependency.

state_manager is written from scratch โ€” not a thin wrapper over bloc or riverpod. A controller extends AppStateController<S, E, F>: widgets dispatch events in, read state back, and react to one-shot effects โ€” navigation chief among them, since a controller must never hold a BuildContext.

dispatch(E)from a widget's onTap
โ†’
onEvent(E)the only place logic runs
โ†’
emit(S)replayable, read via state
AppControllerBuilderrebuilds on every state change
โ†บ
emitEffect(F)one-shot โ€” not replayed
โ†’
onEffectnavigation, dialogs, toasts
!

Never write AppStateProvider's type arguments

All four are inferred from create โ€” naming only the controller is a compile error, since Dart has no partial type arguments.

!

Give onEffect a named method, not a closure

A closure's parameter infers as Object?, which silently turns an exhaustive switch non-exhaustive. A named method void _onEffect(BuildContext, CounterEffect) is the only lint-clean, correct form.

Effects are not replayed โ€” emit them from a dispatched event or from onInit, never from a constructor, where nothing is listening yet. AppStateProvider subscribes to the effect stream before calling onInit, so an effect emitted during startup is never dropped.

Dependency injection & startup

One list, in order, and order matters.

dependency_injection_api defines DependencyLocator / DependencyContainer / DependencyModule independent of any DI library; dependency_injection_impl implements it over get_it. Every core capability and every feature registers itself through the same protocol.

app/lib/bootstrap/dependency_injection_configuration.dart

The single list of modules registered at startup. A feature missing from it compiles fine and fails at runtime โ€” which is what modular doctor exists to catch.

modules: [
  // โ”€โ”€ Core โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  LoggerModule(),
  StorageModule(),
  AppLocalizationModule(...),
  NetworkModule<AppResponse>(...),
  BiometricAuthModule(),

  // โ”€โ”€ Features โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  CounterModule(),
  PostsModule(),
  // <generated:feature-modules>
]

Startup sequence

  1. 1

    WidgetsFlutterBinding.ensureInitialized()

  2. 2

    DependencyInjectionConfiguration.initialize()

    CompositionRoot runs every module in order โ†’ DependencyContainer

  3. 3

    RouterConfiguration.initialize(container)

    Resolves feature facades, assembles routers, then registers AppNavigationService

  4. 4

    runApp(...)

    DependencyScope โ†’ RouterScope โ†’ AppThemeScopeWrapper โ†’ AppLocaleScopeWrapper โ†’ RootApp

Order is a dependency graph you maintain by hand There's no declared dependency between modules โ€” StorageModule precedes anything that reads from storage purely because it's listed first. Keep a new entry below whatever it resolves from the locator.

Design system

Tokens in full. Components on purpose kept small.

core/design_system owns colour, spacing, radius, size and typography tokens, plus theming and six example components. The token set is deliberately small โ€” add a step when a layout genuinely needs one, both in light() and dark().

Always tokens, never raw values

// yes
padding: EdgeInsets.all(context.spacing.spacingXl)
color: context.textColors.textPrimary

// never
padding: EdgeInsets.all(16.0)
color: Color(0xFF...)

Assets are generated, never a raw path

Drop a file in assets/{vectors,rasters}/, run modular gen assets, and reference it through AppVectorAssets / AppRasterAssets. CI checks the generated files are current โ€” see the CLI page.