Most of our engagements start on a codebase that outgrew its folder structure. One lib/ directory, forty features, six engineers, a flutter analyze run that takes a minute, and a CI job that rebuilds everything because someone changed a colour constant. Then sales sells a white-label version of the app to three customers, and the team discovers there is no seam anywhere to hang a brand on.
This tutorial is the refactor we run in that situation: a Melos-managed monorepo, a package-per-feature layout with enforced dependency direction, and flavors so one codebase ships several branded builds. It targets Flutter 3.4x / Dart 3.x and Melos 6.x.
When a monorepo is the right answer
Be honest about the threshold. A single-package app with three developers does not need this. Split when at least two of these are true:
- More than one squad edits the app and merge conflicts are routine.
- You ship more than one binary from the same domain code (consumer app + courier app, or N white-label brands).
- Analyzer and test runs are slow enough that people skip them locally.
- You want to stop layering violations (UI importing the HTTP client directly) with tooling rather than code review.
The cost is real: more pubspec.yaml files, a bootstrap step, and a CI graph to maintain. Budget two to four weeks of incremental work on a mature app, not a big-bang rewrite.
Target layout
my_app/
melos.yaml
pubspec.yaml # workspace root (Dart 3.6+ pub workspaces)
apps/
consumer_app/ # the Flutter app shells
courier_app/
packages/
core_ui/ # design system: tokens, widgets, theming
core_network/ # Dio/http client, interceptors, retry
core_storage/ # Drift, secure storage
domain_models/ # pure Dart entities + value objects
feature_auth/
feature_orders/
feature_profile/
tooling/
analysis/ # shared analysis_options.yaml
Two rules make this hold up:
- Dependency direction is one-way.
apps/*may depend onfeature_*;feature_*may depend oncore_*anddomain_models;core_*anddomain_modelsdepend on nothing internal. A feature never imports another feature. - Features talk through the app shell. Cross-feature navigation goes through route names owned by the shell, not through direct imports.
Bootstrapping Melos
# melos.yaml
name: my_app
packages:
- apps/**
- packages/**
command:
bootstrap:
runPubGetInParallel: true
scripts:
analyze:
run: melos exec -c 1 -- dart analyze --fatal-infos
description: Analyze every package.
test:
run: melos exec -c 4 --dir-exists=test -- flutter test --reporter compact
format:
run: dart format --set-exit-if-changed .
gen:
run: melos exec --depends-on=build_runner -- dart run build_runner build --delete-conflicting-outputs
ci:
run: melos run format && melos run analyze && melos run test
dart pub global activate melos
melos bootstrap
On Dart 3.6+ you can additionally declare a pub workspace in the root pubspec.yaml so every package shares one .dart_tool and one resolution:
# pubspec.yaml (root)
name: _my_app_workspace
publish_to: none
environment:
sdk: ^3.6.0
workspace:
- apps/consumer_app
- apps/courier_app
- packages/core_ui
- packages/core_network
- packages/core_storage
- packages/domain_models
- packages/feature_auth
- packages/feature_orders
- packages/feature_profile
Each member package adds resolution: workspace and drops its own lockfile. One resolution means one version of intl, one version of analyzer, and no more "works in feature_orders, breaks in the app".
Carving the first package out
Do not start with your biggest feature. Start with core_ui, because everything depends on it and nothing depends back.
mkdir -p packages/core_ui
cd packages/core_ui && flutter create --template=package .
Move design tokens, the ThemeData builders, and shared widgets. Export one barrel file:
// packages/core_ui/lib/core_ui.dart
library core_ui;
export 'src/theme/app_theme.dart';
export 'src/theme/brand.dart';
export 'src/widgets/primary_button.dart';
export 'src/widgets/app_scaffold.dart';
In the app:
dependencies:
core_ui:
path: ../../packages/core_ui # or just `core_ui: any` under a pub workspace
Then run a codemod over the old imports and delete the originals in the same commit. Keep each extraction to one package per pull request; a half-moved package is worse than none.
Enforcing the layering with the analyzer
Code review will not hold the line for a year. Tooling will. Give every package a shared base and forbid the imports you care about:
# tooling/analysis/analysis_options.yaml
include: package:flutter_lints/flutter.yaml
linter:
rules:
- implementation_imports
- depend_on_referenced_packages
- always_declare_return_types
# packages/feature_orders/analysis_options.yaml
include: ../../tooling/analysis/analysis_options.yaml
The strongest guard is simply not declaring the dependency: if feature_orders/pubspec.yaml has no feature_profile entry, the import fails to resolve and CI goes red. depend_on_referenced_packages closes the loophole where a transitive dependency silently makes an import compile.
For stricter architecture assertions, add a custom lint package (custom_lint + dart_code_metrics style rules) that bans package:core_network imports from anything under lib/presentation/.
Flavors: one codebase, many brands
A white-label build differs in four places: application ID / bundle ID, display name, assets and theme, and backend configuration. Keep all four declarative.
Android — android/app/build.gradle.kts:
android {
flavorDimensions += "brand"
productFlavors {
create("acme") {
dimension = "brand"
applicationId = "com.acme.orders"
resValue("string", "app_name", "Acme Orders")
}
create("globex") {
dimension = "brand"
applicationId = "com.globex.orders"
resValue("string", "app_name", "Globex Delivery")
}
}
}
iOS — create a scheme and an .xcconfig per brand, each setting PRODUCT_BUNDLE_IDENTIFIER and DISPLAY_NAME, and make sure each brand has its own GoogleService-Info.plist copied by a build phase.
Dart — one entrypoint per brand, no if (brand == ...) scattered through widgets:
// apps/consumer_app/lib/main_acme.dart
void main() => bootstrap(
BrandConfig(
id: 'acme',
apiBaseUrl: 'https://api.acme.example.com',
theme: AcmeTheme.light,
featureFlags: const {'loyalty': true, 'chat': false},
),
);
// apps/consumer_app/lib/bootstrap.dart
Future<void> bootstrap(BrandConfig config) async {
WidgetsFlutterBinding.ensureInitialized();
runApp(BrandScope(config: config, child: const App()));
}
Build and run:
flutter run --flavor acme -t lib/main_acme.dart
flutter build appbundle --flavor globex -t lib/main_globex.dart --dart-define-from-file=config/globex.json
--dart-define-from-file is the right home for per-brand endpoints and keys that must not live in git — CI writes the JSON from secrets before the build. Never put a brand secret in a checked-in Dart constant.
Brand assets without asset bloat
Give each brand its own asset directory and declare only that directory in the flavor's asset bundle, or use the flutter_flavorizr-style layout where assets resolve per flavor. Shipping all brands' logos in every binary is the default failure mode and it shows up directly in download size.
CI that only builds what changed
This is where the monorepo pays for itself. Melos knows the package graph:
# .github/workflows/ci.yaml
jobs:
changed:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: subosito/flutter-action@v2
with: { flutter-version: '3.44.x', cache: true }
- run: dart pub global activate melos
- run: melos bootstrap
- name: Analyze + test changed packages and their dependents
run: |
melos exec --diff=origin/main --include-dependents -- \
"dart analyze --fatal-infos && flutter test --reporter compact"
--diff plus --include-dependents is the key pair: touching core_ui still runs every feature's tests, but touching feature_profile runs only that package. On a real client codebase this took a 19-minute pull request job to roughly four minutes for typical changes.
Build one flavor per matrix entry on merge, not per pull request, unless release cadence demands it.
Versioning and changelogs
If packages stay private, skip semantic versioning ceremony. If you publish shared packages to a private pub server (common when several client apps reuse a design system), let Melos do it from Conventional Commits:
melos version --no-private # bumps versions, writes CHANGELOGs from commit messages
melos publish --dry-run
Migration order that avoids a frozen trunk
- Add
melos.yamland the workspace root; change nothing else. Merge. - Extract
domain_models(pure Dart, no Flutter imports). Merge. - Extract
core_ui, thencore_network, thencore_storage. One pull request each. - Extract the newest feature next — it has the cleanest boundaries — and use it as the reference layout.
- Introduce flavors on the current app shell before splitting the remaining features.
- Switch CI to the changed-package graph once more than half the code lives in packages.
Trunk stays releasable the whole way. Every step is revertable on its own.
What to check before you call it done
melos bootstrapfrom a clean clone succeeds on a machine with no prior.dart_tool.- Every
feature_*package has tests that run without the app shell. - Removing a feature package from the app's
pubspec.yamlproduces compile errors only in the app shell — never in another feature. - Each flavor installs side-by-side on one device with the correct name, icon, and backend.
- CI on a one-line change in a leaf feature runs in single-digit minutes.
Where teams get stuck
Circular dependencies between features. The fix is almost always a shared domain_models type plus an event or route owned by the shell, not a new common package that quietly becomes a second lib/.
A common or utils package. It attracts everything and inverts the dependency graph within a quarter. Name packages after capabilities.
Flavor logic inside widgets. if (brand == 'acme') in build methods is unbounded complexity. Push differences into BrandConfig, theme data, and feature flags.
Per-package lockfiles drifting. Pub workspaces or melos bootstrap with a shared resolution; never both by hand.
Modularization is not an aesthetic project. It buys parallel work, fast CI, enforceable boundaries, and the ability to ship a second branded app in days instead of a quarter. If you want the refactor run against a live codebase without freezing feature delivery, get in touch — this is a standard engagement for our Flutter consultants, and it usually lands incrementally alongside your existing roadmap.