A modular architecture framework for Flutter applications based on Clean Architecture & SOLID principles. Designed for enterprise-scale apps requiring strict isolation, testability, and a predictable lifecycle.
Repository: github.com/cherrypick-agency/modularity_dart
- Packages
- Key Features
- Comparison
- Getting Started
- Advanced Features
- Router Integration
- CLI Visualization
- DI Container Adapters
- Testing
- Diagnostics
- Binder API Reference
- Retention Key vs Override Scope
| Package | Version | Pub Points | Description |
|---|---|---|---|
| modularity_contracts | Zero-dependency interfaces | ||
| modularity_core | DI container and state machine logic | ||
| modularity_flutter | Flutter widgets and RouteObserver integration | ||
| modularity_test | Unit testing utilities (testModule) | ||
| modularity_cli | Graph visualization tools | ||
| modularity_get_it | GetIt adapter for Modularity | ||
| modularity_injectable | Optional injectable + GetIt integration |
- Strict Dependency Injection: Dependencies are explicitly
importedandexported. No hidden global access. - Deterministic Lifecycle: Modules pass through a formal state machine (
initial→loading→loaded→disposed).onInitruns only after all imports are ready. - Retention Policies: Formal
ModuleRetentionPolicyenum (routeBound,keepAlive,strict) with pluggable strategies and cache-backed retainer. - Framework Agnostic: Works with GoRouter, AutoRoute, or Navigator 1.0.
- Observability: Built-in interceptors and Graphviz visualization support.
How Modularity compares to other popular approaches in the Flutter ecosystem:
| Feature | Modularity (This Framework) | Flutter Modular | Provider / Riverpod / BLoC |
|---|---|---|---|
| Module Definition | Pure Dart class + State Machine. Encapsulated logic decoupled from UI. | Class with routes & binds. Strongly coupled to the router and UI navigation. | Folder structure / Providers list. No formal module concept; usually just a list of global or scoped providers. |
| Initialization | Automatic (DAG). onInit is guaranteed to run after all imports are resolved and initialized. Solves "Initialization Hell". |
Lazy or Navigation-based. initialization happens when the route is accessed or the bind is called. | Lazy or Widget-mount. Initialization happens when the widget tree is built or the provider is first read. |
| Dependency Management | Explicit. Uses imports, exports, and binds. Modules cannot access what they don't strictly import. |
Module Tree / Global. Hierarchical scoping, but often allows accessing parent scopes implicitly. | Global / Scoped. ProviderScope or MultiBlocProvider in the widget tree. Dependencies often implicit via context.read. |
| Lifecycle | Formal State Machine. Strict states (initial, loading, loaded, disposed) managed by the core engine. |
Bound to Router. Lifecycle is tied to Modular's internal router and navigation stack. | Bound to Widget Tree. Lifecycle is tied to the BuildContext (StatefulWidget) or Provider's auto-dispose logic. |
| Routing Coupling | Loose. Works with any router (GoRouter, AutoRoute, etc.). Routing is an implementation detail. | Strong. The router is a core part of the framework. Hard to use with other routing solutions. | Indirect. No direct coupling, but state management often gets entangled with navigation arguments. |
| Testing | Unit-first. testModule isolates logic completely. You can test the entire wiring without Flutter. |
Integration mainly. Focuses on testing the module with the router mocked or real. | Widget tests required. Often requires pumpWidget to test the DI integration properly. |
import 'package:modularity_contracts/modularity_contracts.dart';
class AppModule extends Module {
@override
List<Module> get imports => [ /* SharedModule() */ ];
@override
void binds(Binder binder) {
// Private: Implementation details
binder.singleton<AuthRepository>(() => AuthRepositoryImpl());
}
@override
void exports(Binder binder) {
// Public: Exposed API
binder.singleton<AuthService>(() => AuthService(binder.get()));
}
}Prefer code generation? If you'd rather use
injectablefor auto-wiring, see modularity_injectable. It's entirely optional — manual registration works great for most projects.
final observer = RouteObserver<ModalRoute<dynamic>>();
void main() {
runApp(ModularityRoot(
observer: observer,
child: MaterialApp(
navigatorObservers: [observer], // Required for routeBound policy
home: ModuleScope(
module: AppModule(),
child: HomePage(),
),
),
));
}Important: Create a
RouteObserverand pass it to bothModularityRoot(observer: ...)andnavigatorObserversto enable automatic module disposal when routes pop.
Need to keep modules alive across tab switches or background navigation layers? Set the retention policy explicitly:
ModuleScope( module: ProfileModule(), retentionPolicy: ModuleRetentionPolicy.keepAlive, child: ProfilePage(), );
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Type-safe dependency resolution
final authService = ModuleProvider.of(context).get<AuthService>();
return Text('Logged in: ${authService.isLoggedIn}');
}
}Use onInit for async initialization after all imports are ready:
class AuthModule extends Module {
@override
List<Module> get imports => [ConfigModule()];
@override
void binds(Binder binder) {
binder.lazySingleton<AuthService>(() => AuthServiceImpl(
binder.get<Config>(), // From ConfigModule
));
}
@override
Future<void> onInit() async {
// Called AFTER binds() and all imports are loaded
// Access dependencies via controller if needed
}
@override
void onDispose() {
// Cleanup resources when module is disposed
}
}When a module needs runtime parameters (e.g., entity ID from route arguments), implement Configurable<T>:
class ProductDetailsModule extends Module implements Configurable<String> {
late String productId;
@override
void configure(String id) {
productId = id;
}
@override
void binds(Binder binder) {
binder.singleton<ProductRepository>(() => ProductRepositoryImpl());
binder.singleton<ProductBloc>(() => ProductBloc(binder.get(), productId));
}
}Pass arguments via ModuleScope:
ModuleScope(
module: ProductDetailsModule(),
args: productId, // Passed to configure() before binds()
child: ProductDetailsPage(),
)Imported modules are cached by module type, optional identityKey, and override scope. If two imports use the same module class with different constructor state, override identityKey so they do not accidentally share one controller:
class TenantApiModule extends Module {
TenantApiModule(this.tenantId);
final String tenantId;
@override
Object get identityKey => tenantId;
@override
void binds(Binder i) {
i.registerLazySingleton<ApiClient>(() => ApiClient(tenantId));
}
}For route or widget arguments, prefer Configurable<T> and pass values through ModuleScope.args. Use identityKey only for imported modules that truly carry constructor state.
Declare required dependencies from parent scope for fail-fast initialization:
class ProfileModule extends Module {
@override
List<Module> get imports => [/* ... */];
@override
List<Type> get expects => [UserService, AnalyticsService];
@override
void binds(Binder binder) {
// Safe to use — framework validates presence before binds()
binder.singleton<ProfileBloc>(
() => ProfileBloc(binder.get<UserService>()),
);
}
}If any expects type is missing, initialization throws with a descriptive error.
Intercept lifecycle events for logging, analytics, or debugging:
class AnalyticsInterceptor implements ModuleInterceptor {
@override
void onInit(Module module) {
analytics.track('module_init', {'type': module.runtimeType.toString()});
}
@override
void onLoaded(Module module) {
analytics.track('module_loaded', {'type': module.runtimeType.toString()});
}
@override
void onError(Module module, Object error) {
crashlytics.recordError(error, reason: 'Module ${module.runtimeType} failed');
}
@override
void onDispose(Module module) {
analytics.track('module_disposed', {'type': module.runtimeType.toString()});
}
}
// Register globally via ModularityRoot
void main() {
runApp(ModularityRoot(
interceptors: [AnalyticsInterceptor()],
child: MyApp(),
));
}Override dependencies in imported modules without modifying them — perfect for testing and feature flags:
// Override AuthModule's internal dependency when used by DashboardModule
final overrides = ModuleOverrideScope(children: {
AuthModule: ModuleOverrideScope(
selfOverrides: (binder) {
binder.lazySingleton<AuthApi>(() => MockAuthApi());
},
),
});
ModuleScope(
module: DashboardModule(),
overrideScope: overrides,
child: DashboardPage(),
)In tests:
await testModule(
DashboardModule(),
(module, binder) {
// AuthApi is now MockAuthApi
expect(binder.get<AuthService>().api, isA<MockAuthApi>());
},
overrideScope: overrides,
);Declare structural relationships for visualization tools (no runtime effect):
class AppModule extends Module {
@override
List<Module> get imports => [CoreModule()];
@override
List<Module> get submodules => [
AuthFeatureModule(),
ProfileFeatureModule(),
SettingsFeatureModule(),
];
}Submodules appear in the dependency graph generated by modularity_cli.
Modules support Flutter's hot reload out of the box. Override hotReload for custom refresh logic:
class DashboardModule extends Module {
@override
void binds(Binder binder) {
binder.lazySingleton<DashboardBloc>(() => DashboardBloc());
binder.factory<ChartRenderer>(() => ChartRenderer()); // Always fresh
}
@override
void hotReload(Binder binder) {
// Called on hot reload — refresh factories without losing singleton state
binder.factory<ChartRenderer>(() => ChartRenderer());
}
}During hot reload:
- Singletons are preserved (same instance)
- Factories are re-registered with new code
ModuleOverrideScopeis re-applied automatically
For simple constructor identity, override Module.identityKey; the default
retention key includes it automatically. For advanced caching scenarios that
need route/context data, implement RetentionIdentityProvider:
class UserProfileModule extends Module with RetentionIdentityProvider {
final String userId;
UserProfileModule(this.userId);
@override
Object? buildRetentionIdentity(ModuleRetentionContext context) {
// Cache by userId, not just module type
return 'user-profile-$userId';
}
}Customize framework behavior at the root level:
ModularityRoot(
// Custom Binder factory (e.g., GetItBinderFactory for GetIt integration)
binderFactory: SimpleBinderFactory(),
// Default Loading UI for all ModuleScopes
defaultLoadingBuilder: (context) => const Center(
child: CircularProgressIndicator(),
),
// Default Error UI for all ModuleScopes
defaultErrorBuilder: (context, error, retry) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Error: $error'),
ElevatedButton(onPressed: retry, child: Text('Retry')),
],
),
),
child: MaterialApp(...),
)Override per-module with ModuleScope:
ModuleScope(
module: ProfileModule(),
loadingBuilder: (context) => ProfileShimmer(),
errorBuilder: (context, error, retry) => ProfileErrorView(error, retry),
child: ProfilePage(),
)class AppRouter {
static final observer = RouteObserver<ModalRoute<dynamic>>();
static final router = GoRouter(
observers: [observer],
routes: [
GoRoute(
path: '/product/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ModuleScope(
module: ProductModule(),
args: id, // Configurable<String>
child: ProductPage(),
);
},
),
],
);
}
// main.dart
runApp(ModularityRoot(
observer: AppRouter.observer,
child: MaterialApp.router(routerConfig: AppRouter.router),
));@AutoRouterConfig()
class AppRouter extends RootStackRouter {
@override
List<AutoRoute> get routes => [
AutoRoute(path: '/product/:id', page: ProductRoute.page),
];
}
// main.dart
final appRouter = AppRouter();
runApp(ModularityRoot(
child: Builder(
builder: (context) => MaterialApp.router(
routerConfig: appRouter.config(
navigatorObservers: () => [ModularityRoot.observerOf(context)],
),
),
),
));Generate dependency graphs with modularity_cli:
// tool/visualize.dart
import 'package:modularity_cli/modularity_cli.dart';
void main() async {
await GraphVisualizer.visualize(
AppModule(),
renderer: GraphRenderer.g6, // Interactive AntV G6
);
}Run with dart run tool/visualize.dart — opens an interactive diagram showing:
- Module hierarchy and imports
- Public exports vs private bindings
- Dependency types (singleton, factory, instance)
| Renderer | Description |
|---|---|
graphviz |
Static DOT diagram via quickchart.io |
g6 |
Interactive drag-and-zoom with tooltips |
Use your existing GetIt setup with Modularity:
import 'package:modularity_get_it/modularity_get_it.dart';
void main() {
runApp(ModularityRoot(
binderFactory: GetItBinderFactory(), // Uses global GetIt.instance
child: MaterialApp(...),
));
}For isolated instances:
GetItBinderFactory(useGlobalInstance: false)For code generation with injectable:
// See modularity_injectable package
@InjectableInit()
void configureDependencies() => getIt.init();
void main() {
configureDependencies();
runApp(ModularityRoot(
binderFactory: GetItBinderFactory(),
child: MaterialApp(...),
));
}Use modularity_test to verify your module's wiring and logic in isolation.
import 'package:modularity_test/modularity_test.dart';
void main() {
test('AppModule registers AuthService', () async {
await testModule(AppModule(), (module, binder) {
expect(binder.get<AuthService>(), isNotNull);
expect(binder.get<AuthService>().isLoggedIn, isFalse);
});
});
}Need to inspect which dependencies a real module contributes? After the controllers finish initialization you can dump the binder to see both private and exported tokens (plus imported scopes):
final registry = <ModuleRegistryKey, ModuleController>{};
final authController = ModuleController(AuthModule());
await authController.initialize(registry);
final dashboardController = ModuleController(DashboardModule());
await dashboardController.initialize(registry);
debugPrint(
(dashboardController.binder as SimpleBinder).debugGraph(includeImports: true),
);This prints something like:
SimpleBinder(4c1f)
Private:
- DashboardController
- DashboardViewModel
Public:
- DashboardService
Imports:
SimpleBinder(13ab)
Private:
- AuthRepositoryImpl
Public:
- AuthService
Handy when you need to confirm that AuthService is exported while AuthRepositoryImpl stays private.
Enable debug logging to trace module creation, caching, and disposal events:
void main() {
runApp(ModularityRoot(
// Enable default console logging (debug builds only recommended)
lifecycleLogger: ModularityRoot.defaultDebugLogger,
child: MyApp(),
));
}Output example:
[Modularity] CREATED ConfigModule key=config-module {policy: keepAlive, hasOverrideScope: false}
[Modularity] REGISTERED ConfigModule key=config-module {policy: keepAlive, refCount: 1, hasRoute: true}
[Modularity] REUSED ConfigModule key=config-module {refCount: 2}
[Modularity] ROUTETERMINATED ConfigModule {routeType: MaterialPageRoute<dynamic>}
[Modularity] EVICTED ConfigModule key=config-module {disposeController: true}
[Modularity] DISPOSED ConfigModule key=config-module {reason: evicted}
For custom integrations (analytics, crash reporting), use a custom logger on ModularityRoot:
ModularityRoot(
lifecycleLogger: (event, type, {retentionKey, details}) {
analytics.track('module_${event.name}', {
'type': type.toString(),
'key': retentionKey?.toString(),
...?details?.map((k, v) => MapEntry(k, v.toString())),
});
},
child: MyApp(),
)Available events: created, reused, registered, disposed, evicted, released, routeTerminated.
| Method | Description |
|---|---|
registerFactory<T>(() => ...) |
New instance on each get<T>() call |
registerSingleton<T>(instance) |
Eagerly registered, always returns the same instance |
registerLazySingleton<T>(() => ...) |
Instance created on first get<T>() call |
get<T>() |
Retrieve dependency (throws if not found) |
tryGet<T>() |
Retrieve dependency (returns null if not found) |
contains(Type) |
Check if type is registered |
parent<T>() |
Get from parent scope only |
tryParent<T>() |
Get from parent scope (returns null if not found) |
When using keepAlive retention policy, understand the distinction:
retentionKeydetermines cache identity. TwoModuleScopewidgets with the same key share the cached controller.overrideScopeaffects DI bindings but does NOT affect cache identity.
Implication: If you have two scopes with the same retentionKey but different overrideScope, they share a controller — first scope's overrides win.
// ❌ Problem: Both use same retentionKey, second overrides are ignored
ModuleScope(module: ConfigModule(), retentionKey: 'config', overrideScope: scopeA, ...)
ModuleScope(module: ConfigModule(), retentionKey: 'config', overrideScope: scopeB, ...) // shares controller with scopeA!
// ✅ Solution: Include scope identity in the key
ModuleScope(
module: ConfigModule(),
retentionKey: 'config-${identityHashCode(scopeA)}',
overrideScope: scopeA,
...
)