Modular Architecture

Medium PriorityAsked in ~55% of senior interviews

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

BenefitCost
Teams own packages independentlyMore 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 teamCross-package refactors are harder
Public/private API separationNeed to maintain barrel files
Easier to extract / open-source a packageInitial 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

PatternWhen
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 brokerLoosely coupled events ("user logged out")
Public domain models in packages/coreBoth features need to read a User
go_router routesNavigation between features — features expose route declarations
❌ Direct feature-to-feature importAnti-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

  1. 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.

  2. How do you handle cross-feature navigation? The app shell owns the router. Features expose route declarations (path + builder), not in-feature Navigator calls to other features. When checkout needs to send the user to auth, it context.push('/login') — the string is the only coupling, and the shell resolves it.

  3. How do you avoid feature-to-feature dependencies sneaking in? Custom analyzer rules (custom_lint) that forbid imports matching features/[^/]+/(?!^own_feature). Or rely on package boundaries: if features/checkout/pubspec.yaml doesn't list auth as a dep, the import doesn't resolve. Either way, make the boundary mechanical, not just convention.

  4. What's the role of a core package 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.