Best Practices โ
Practical guidelines for designing modules, managing dependencies, and avoiding common pitfalls.
Module Granularity โ
One domain = one module. A module should encapsulate a single feature or bounded context.
Good:
class AuthModule extends Module { ... }
class PaymentModule extends Module { ... }
class ProfileModule extends Module { ... }Bad:
// God module -- too many responsibilities
class AppModule extends Module {
@override
void binds(Binder i) {
i.registerLazySingleton<AuthService>(() => AuthService());
i.registerLazySingleton<PaymentService>(() => PaymentService());
i.registerLazySingleton<ProfileService>(() => ProfileService());
i.registerLazySingleton<NotificationService>(() => NotificationService());
// ...20 more registrations
}
}Split large modules when:
- The module has more than 10 registrations.
- Registrations span multiple unrelated domains.
- Different parts of the module have different lifecycles (some route-scoped, some app-scoped).
imports vs Parent Scope โ
Two mechanisms for accessing external dependencies:
| Mechanism | How | When to use |
|---|---|---|
imports | Module lists imported modules in get imports | Sibling modules that export shared services |
| Parent scope | Dependency registered in an ancestor ModuleScope | Parent-child widget tree relationships |
Use imports when:
- The dependency comes from a logically separate module (e.g.
AuthModuleexportsAuthServicethatPaymentModuleimports). - You want explicit, visible dependency declarations.
- Modules are reusable across different parts of the app.
Use parent scope when:
- The dependency is naturally hierarchical (root services available to all children).
- You use
ModuleScopenesting in the widget tree. - The dependency lifecycle is tied to the parent route.
// imports: explicit module dependency
class PaymentModule extends Module {
@override
List<Module> get imports => [AuthModule()];
@override
void binds(Binder i) {
i.registerLazySingleton<PaymentService>(
() => PaymentService(i.get<AuthService>()),
);
}
}
// Parent scope: hierarchical, declared via expects
class SettingsModule extends Module {
@override
List<Type> get expects => [AuthService];
@override
void binds(Binder i) {}
}binds() vs exports() โ
| Method | Scope | Visibility | Use for |
|---|---|---|---|
binds() | Private | Only within the module | Repositories, data sources, use cases, internal services |
exports() | Public | Importing modules and children | Facades, public APIs, shared contracts |
Decision criteria:
- If only this module needs it ->
binds(). - If other modules need it ->
exports(). - If in doubt, start with
binds(). Promote toexports()when another module actually needs it.
class AuthModule extends Module {
@override
void binds(Binder i) {
// Private: implementation details
i.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(),
);
i.registerLazySingleton<TokenStorage>(
() => SecureTokenStorage(),
);
}
@override
void exports(Binder i) {
// Public: only the facade
i.registerLazySingleton<AuthService>(
() => AuthService(
i.get<AuthRepository>(),
i.get<TokenStorage>(),
),
);
}
}Modules that import AuthModule can resolve AuthService but cannot access AuthRepository or TokenStorage.
Error Handling โ
expects for fail-fast validation โ
Declare expected parent dependencies to catch configuration errors at initialization time, not at first use:
class CheckoutModule extends Module {
@override
List<Type> get expects => [AuthService, CartService];
@override
void binds(Binder i) {
// AuthService and CartService are guaranteed to exist
i.registerFactory<CheckoutCubit>(
() => CheckoutCubit(
auth: i.get<AuthService>(),
cart: i.get<CartService>(),
),
);
}
}Without expects, missing dependencies surface as DependencyNotFoundException at an unpredictable time.
Error handling in onInit โ
Use onInit() for async initialization that can fail. Errors are caught by ModuleController and exposed via the errorBuilder in ModuleScope:
class DatabaseModule extends Module {
@override
void binds(Binder i) {
i.registerLazySingleton<Database>(() => Database());
}
@override
Future<void> onInit() async {
final db = binder.get<Database>(); // won't work -- no binder access here
}
}Note: onInit() does not receive the binder. If you need to access registered dependencies during async init, resolve them via a registered service:
class DatabaseModule extends Module {
late final DatabaseInitializer _initializer;
@override
void binds(Binder i) {
_initializer = DatabaseInitializer();
i.registerSingleton<DatabaseInitializer>(_initializer);
i.registerLazySingleton<Database>(
() => _initializer.database,
);
}
@override
Future<void> onInit() async {
await _initializer.connect();
}
}Exception types โ
Modularity throws typed exceptions for different failure modes:
| Exception | When |
|---|---|
DependencyNotFoundException | get<T>() cannot find the type in any scope |
CircularDependencyException | Module import graph has a cycle |
ModuleConfigurationException | Sealed scope violation, duplicate export, wrong binder type |
ModuleLifecycleException | Configure type mismatch, dependent module failure |
All extend ModularityException. Catch ModularityException to handle any framework error.
Performance โ
Lazy singletons vs eager singletons โ
| Registration | Created when | Use for |
|---|---|---|
registerLazySingleton | First get<T>() call | Most services -- defers cost until needed |
registerSingleton | Immediately at binds() time | Pre-built instances, config objects |
registerFactory | Every get<T>() call | Stateless use cases, transient objects |
Prefer registerLazySingleton for most services. Use registerSingleton only when you already have the instance (e.g. config parsed at startup). Use registerFactory for objects that should not be shared.
Module initialization cost โ
onInit() runs after binds() and exports(). Heavy async work here blocks the module from reaching loaded status. The ModuleScope shows the loadingBuilder during this time.
To minimize perceived latency:
- Keep
onInit()light. Move heavy work to lazy initialization inside services. - Use
loadingBuilderto show meaningful skeleton UI instead of a spinner. - For modules with no async setup, omit
onInit()entirely (the default is a no-op).
Naming Conventions โ
| Item | Convention | Example |
|---|---|---|
| Module class | <Feature>Module | AuthModule, PaymentModule |
| Root module | RootModule or AppModule | RootModule |
| Exported service | <Feature>Service or <Feature>Facade | AuthService |
| Private repository | <Feature>Repository / <Feature>RepositoryImpl | AuthRepositoryImpl |
| Config module | <Feature>Module implements Configurable<T> | DetailsModule implements Configurable<String> |
Anti-Patterns โ
God modules โ
A module that registers everything for the entire app. This defeats the purpose of modularization.
Fix: Split into domain-specific modules. Use imports to connect them.
Circular dependencies โ
Module A imports Module B, which imports Module A.
// This will throw CircularDependencyException
class ModuleA extends Module {
@override
List<Module> get imports => [ModuleB()];
// ...
}
class ModuleB extends Module {
@override
List<Module> get imports => [ModuleA()];
// ...
}Fix: Extract the shared dependency into a third module that both A and B import. Or move the shared type to a parent scope.
Leaking internals โ
Exporting implementation classes instead of interfaces:
// Bad: exports the implementation
void exports(Binder i) {
i.registerLazySingleton<AuthRepositoryImpl>(
() => AuthRepositoryImpl(),
);
}
// Good: exports the interface
void exports(Binder i) {
i.registerLazySingleton<AuthService>(
() => AuthServiceImpl(i.get<AuthRepository>()),
);
}Fix: Export abstract types or facades. Keep implementations private.
Resolving during binds() โ
Calling i.get<T>() in binds() to resolve a dependency that is registered later in the same method:
void binds(Binder i) {
// Bad: UserService is not registered yet
i.registerLazySingleton<UserCubit>(
() => UserCubit(i.get<UserService>()),
);
i.registerLazySingleton<UserService>(() => UserService());
}This works at runtime because registerLazySingleton defers creation. But it fails with RecordingBinder during static analysis. Fix: Order registrations so dependencies are registered before dependents, or rely on the fact that lazy resolution defers the get call.
Ignoring expects โ
Resolving parent dependencies without declaring them in expects:
class PaymentModule extends Module {
@override
void binds(Binder i) {
i.registerFactory<PaymentCubit>(
() => PaymentCubit(i.get<AuthService>()), // silent parent lookup
);
}
}This works but fails silently if the parent doesn't provide AuthService. Fix: Declare expects for any type resolved from a parent scope:
class PaymentModule extends Module {
@override
List<Type> get expects => [AuthService];
@override
void binds(Binder i) {
i.registerFactory<PaymentCubit>(
() => PaymentCubit(i.get<AuthService>()),
);
}
}Checklist for Creating a New Module โ
- Define the module class extending
Module. - Decide scope: What is private (
binds) vs public (exports)? - Declare
expectsfor any dependency from parent or imported scopes. - Declare
importsfor any sibling modules this module depends on. - Register dependencies using
registerLazySingleton(shared) orregisterFactory(transient). - Add
onInit()only if async initialization is needed. - Add
onDispose()only if non-DI cleanup is needed (timers, sockets). - Implement
Configurable<T>if the module needs runtime parameters. - Wrap in
ModuleScopeat the route or widget level. - Test in isolation using
ModuleControllerwith aSimpleBinder.
class FeatureModule extends Module implements Configurable<String> {
late String _featureId;
@override
void configure(String args) => _featureId = args;
@override
List<Module> get imports => [SharedModule()];
@override
List<Type> get expects => [AuthService];
@override
void binds(Binder i) {
i.registerLazySingleton<FeatureRepository>(
() => FeatureRepositoryImpl(
id: _featureId,
api: i.get<ApiClient>(),
),
);
i.registerFactory<FeatureCubit>(
() => FeatureCubit(i.get<FeatureRepository>()),
);
}
@override
void exports(Binder i) {
i.registerLazySingleton<FeatureService>(
() => FeatureService(i.get<FeatureRepository>()),
);
}
@override
Future<void> onInit() async {
// Optional async setup
}
@override
void onDispose() {
// Optional cleanup
}
}