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.
_impl ยท configures core ยท supplies the response contractapp_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}); }
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.
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
WidgetsFlutterBinding.ensureInitialized()
- 2
DependencyInjectionConfiguration.initialize()
CompositionRootruns every module in order โDependencyContainer - 3
RouterConfiguration.initialize(container)
Resolves feature facades, assembles routers, then registers
AppNavigationService - 4
runApp(...)
DependencyScopeโRouterScopeโAppThemeScopeWrapperโAppLocaleScopeWrapperโRootApp
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.