Modular Architecture
4 min read
Architecture
app/ ← thin shell (main.dart, top-level routing, DI wiring)
packages/
├── core/ ← models, utils, env, constants (no Flutter)
├── design_system/ ← AppButton, AppCard, theme, tokens
├── networking/ ← ApiClient, interceptors, retry
└── analytics/ ← tracking, crash reporting abstraction
features/
├── auth/ ← login / signup / reset
├── home/ ← feed / dashboard
├── profile/ ← user profile / settings
├── checkout/ ← cart / payment / order tracking
└── search/ ← search / filters / results
Each feature package has:
features/auth/
├── lib/
│ ├── auth.dart ← public barrel — only what the app shell imports
│ └── src/ ← private implementation
│ ├── domain/ ← entities + use cases
│ ├── data/ ← repositories + API mappers
│ └── presentation/ ← screens + state notifiers
├── test/
└── pubspec.yaml
Dependency direction is inwards-only: features/* may depend on packages/*, but never on each other directly. Cross-feature communication goes through app/ (the composition root) or through a shared abstraction in packages/.
Code in action — public API surface
// features/auth/lib/auth.dart (barrel — public API)
export 'src/presentation/login_screen.dart';
export 'src/presentation/signup_screen.dart';
export 'src/domain/entities/user.dart';
export 'src/domain/auth_repository.dart';
// Everything in lib/src/ that isn't exported here is INTERNAL.
// app/lib/main.dart (composition root)
import 'package:auth/auth.dart'; // public API only
import 'package:home/home.dart';
import 'package:design_system/design_system.dart';
final router = GoRouter(routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/', builder: (_, __) => const HomeScreen()),
]);
Melos for monorepo management
# melos.yaml
name: my_app_workspace
packages:
- app
- packages/**
- features/**
command:
bootstrap:
runPubGetInParallel: true
scripts:
analyze: { run: melos exec -- flutter analyze }
test: { run: melos exec -- flutter test }
format: { run: melos exec -- dart format --set-exit-if-changed . }
melos bootstrap # link local packages + flutter pub get all
melos run test # tests in every package
melos exec --scope='auth*' -- flutter test # filtered
melos version # bumps + tags only changed packages
Benefits and costs
| Benefit | Cost |
|---|---|
| Teams own packages independently | More boilerplate (pubspec per package) |
| Clear dependency boundaries (compiler-enforced) | Up-front design effort |
| Faster CI (rebuild only changed) | Tooling complexity (Melos, scripts) |
| Smaller mental scope per team | Cross-package refactors are harder |
| Public/private API separation | Need to maintain barrel files |
| Easier to extract / open-source a package | Initial migration cost from monolith |
Worth it when: more than ~5 engineers, multi-feature app, or you anticipate scaling. Overkill for a 1-3 person team on one screen.
Cross-feature communication patterns
| Pattern | When |
|---|---|
Composition in app/ (pass callbacks) | Simple "tap → go to another feature" |
Shared abstraction in packages/core (e.g., AnalyticsClient) | Cross-cutting concerns |
| Event bus / message broker | Loosely coupled events ("user logged out") |
Public domain models in packages/core | Both features need to read a User |
| go_router routes | Navigation between features — features expose route declarations |
| ❌ Direct feature-to-feature import | Anti-pattern; collapses module isolation |
Common mistakes to avoid
❌ Features importing each other directly
features/checkout imports features/auth → tight coupling, circular deps possible.
✅ Communicate via shared abstractions in packages/.
❌ Premature modularisation
3-person team, 10 screens → 15 packages = drowning in pubspec.yaml files.
✅ Start monolithic; extract packages when pain forces it.
❌ Putting business logic in app/
The shell should be tiny. Logic belongs in features or shared packages.
❌ Public barrel files that re-export internals
"We exposed everything for convenience" → module boundary is fiction.
✅ Curate barrel exports; review additions.
❌ Inconsistent dependency styles across packages
Some use Provider, some Riverpod, some Bloc — newcomers can't navigate.
✅ Pick one state-management style per repo.
❌ No CI signal per package
Failure on PR doesn't tell you which package broke.
✅ Melos runs CI per package; report includes which scope failed.
❌ Feature packages depending on Flutter when they don't need to
Lower-level domain logic should be pure Dart for portability + faster tests.
Interview follow-ups
-
When should you NOT modularise? Small teams (1–3 engineers), early-stage apps, prototypes — the boilerplate cost exceeds the boundary benefits. Modularise reactively when you feel the pain: long build times, merge conflicts, unclear ownership, teams stepping on each other.
-
How do you handle cross-feature navigation? The app shell owns the router. Features expose route declarations (path + builder), not in-feature
Navigatorcalls to other features. Whencheckoutneeds to send the user toauth, itcontext.push('/login')— the string is the only coupling, and the shell resolves it. -
How do you avoid feature-to-feature dependencies sneaking in? Custom analyzer rules (
custom_lint) that forbid imports matchingfeatures/[^/]+/(?!^own_feature). Or rely on package boundaries: iffeatures/checkout/pubspec.yamldoesn't listauthas a dep, the import doesn't resolve. Either way, make the boundary mechanical, not just convention. -
What's the role of a
corepackage in a modular app? Pure-Dart shared primitives: domain models (User,Product), value objects, utilities (Result<T>, formatters), platform-free constants. No Flutter, no Material, no IO — that makes it trivially testable and reusable across packages (and potentially across apps).
How helpful was this content?
Please sign in to rate this article.