Why modularize?
- Scale: Teams can ship features independently with fewer merge conflicts.
- Build times: Smaller targets speed up builds and tests.
- Boundaries: Clear APIs reduce accidental coupling and regressions.
- Reuse: Common modules can be shared across apps and platforms.
Core principles
-
Directional dependencies
- High-level modules (features) depend on lower-level modules (domain, data, ui-kit).
- Never allow back-edges (e.g., data → feature).
-
Stable contracts
- Define public APIs via Dart
exports; keep implementation details internal. - Use semantic versioning within the monorepo to reason about changes.
- Define public APIs via Dart
-
Single responsibility per package
- Feature modules encapsulate screen flows + UI state.
- Domain modules encapsulate use cases and business rules.
- Data modules encapsulate repositories and gateways.
Reference layout (monorepo)
/app
lib/
main.dart
di/
container.dart
pubspec.yaml
/packages
/feature_todos
lib/
feature_todos.dart # public API (exports)
src/
presentation/...
state/...
routes.dart
pubspec.yaml
/feature_profile
/domain_auth
lib/
domain_auth.dart
src/
usecases/
entities/
errors.dart
pubspec.yaml
/data_auth
lib/
data_auth.dart
src/
repositories/
datasources/
mappers/
pubspec.yaml
/ui_kit
lib/
ui_kit.dart
src/
atoms/
molecules/
theming/
pubspec.yaml
Dependencies (directional)
- app → feature_todos, feature_profile, ui_kit, domain_* (optional), data_* (via DI)
- feature_* → domain_*, ui_kit
- data_* → domain_* (for models) and external packages (http, drift, etc.)
- domain_* → pure Dart only (no Flutter imports)
Enforcing boundaries
- Use Dart
libraryandpartto scope internals if desired. - In each package, expose a single entrypoint
lib/<package>.dartexporting only the public API:// lib/feature_todos.dart library feature_todos; export 'src/routes.dart' show TodosRoute; export 'src/state/todos_controller.dart' show TodosController; - Keep
src/private to the package’s consumers; do not export deep internals.
DI and composition root
- The app crate (
/app) is the only place where you wire concrete implementations:// app/lib/di/container.dart import 'package:data_auth/data_auth.dart'; import 'package:domain_auth/domain_auth.dart'; class AppContainer { late final AuthRepository authRepo; late final SignInUseCase signIn; AppContainer() { authRepo = HttpAuthRepository(/* client, baseUrl */); signIn = SignInUseCase(authRepo); } } - Feature modules accept dependencies in constructors (no global singletons).
State management per feature
- Choose 1–2 patterns repo-wide (e.g., Riverpod + BLoC)
- Example: Riverpod for feature states, BLoC for flows with explicit state machines.
- Avoid mixing many patterns; consistency reduces onboarding time.
Navigation
- Each feature exports a typed route or a route builder function:
// feature_todos/lib/src/routes.dart import 'package:flutter/widgets.dart'; import 'presentation/todos_page.dart'; class TodosRoute extends PageRouteBuilder { TodosRoute() : super(pageBuilder: (_, __, ___) => const TodosPage()); } - The app composes routes in a central router.
Testing strategy
- Unit tests in domain_* (pure Dart) are the backbone of correctness.
- Widget tests in feature_* validate UI behavior and state transitions.
- Contract tests at package boundaries verify models and repository behavior.
- Integration tests verify critical money paths end-to-end (small, stable suite).
Build and CI optimizations
- Use Melos or custom scripts to run
flutter testonly in changed packages. - Cache pub and build artifacts in CI; shard tests per package group.
- Lint for forbidden imports (e.g., deny
feature_*importingapp).
Migration plan (incremental)
- Identify high-churn areas → extract first feature package.
- Extract domain and data layers for that feature.
- Move shared UI components to
ui_kit. - Establish DI in app; delete old cross-cutting singletons.
- Repeat feature-by-feature; keep app compiling at each step.
Common pitfalls and remedies
- Pitfall: “God package” exporting everything. Remedy: minimal public API; enforce via exports.
- Pitfall: Circular deps via utility modules. Remedy: move utilities down the stack or duplicate tiny helpers.
- Pitfall: Over-abstracting too early. Remedy: let duplication exist briefly; refactor only when usage patterns stabilize.
Checklist
- Directional deps validated
- Public API minimal and documented
- Feature owns its routes, state, and presentation
- Domain is Flutter-free and test-heavy
- Data is replaceable via DI
- CI runs targeted tests per package
- Lints prevent forbidden imports
Conclusion Modularization is not about dogmatic layering; it’s about creating stable seams for teams to move fast without stepping on each other. Start where pain is highest, codify boundaries, and evolve toward a package graph that fits your organization’s scale.