Mobile Development
FlutterMonorepoMelosModularizationCI/CDTestingArchitecture

Scaling Flutter with Modularization: Monorepo Setup with Melos, Shared Packages, and Clean Boundaries

AO
Adrijan Omićević
·14 min read

# What You’ll Learn#

This guide explains how to scale Flutter using modularization in a monorepo: when it’s worth the complexity, how to structure shared packages, and how to keep package boundaries clean over time.

You’ll set up a Flutter monorepo with Melos, align dependencies across packages, and build a CI and testing strategy that stays fast even as your codebase grows.

For app architecture context (feature-first, Clean Architecture, and practical tradeoffs), read Flutter app architecture: Clean Architecture + feature-first.

# When Modularization Is Worth It (and When It Isn’t)#

Modularization is not a default best practice. It’s a scaling tool, and it introduces overhead in tooling, CI, dependency management, and boundary maintenance.

Modularize when you hit these signals#

SignalWhat you feel day-to-dayTypical threshold
Team growthMerge conflicts, unclear ownership, code review slows down4+ engineers touching the same areas weekly
Build and test time painFull test suite takes too long, local rebuilds feel sluggishUnit tests 10+ minutes, frequent full rebuilds
Multiple appsYou need shared design system, auth, analytics, domain logic2+ apps or a white-label setup
High couplingFeatures import each other, refactors break unrelated areasFrequent regressions outside changed feature
Release riskHard to reason about changes, missing isolationHotfixes take longer than planned

🎯 Key Takeaway: Modularize to reduce coupling and speed feedback loops, not to make the folder tree look clean.

Don’t modularize yet if this is you#

ScenarioBetter approach
Single app, 1 to 3 devsKeep one package, use feature-first modules inside lib/
Fast-changing product discoveryOptimize for iteration, revisit modularization later
No stable boundariesDefine features and ownership first, then split packages
You lack CI disciplineFix tests and automated checks before adding repo complexity

A useful rule: if you cannot clearly describe what goes into each package in one sentence, you’re not ready to split.

A Flutter monorepo should encode architecture decisions in its filesystem. The goal is predictable ownership, minimal cross-package coupling, and safe reuse.

A practical package taxonomy#

Use a small number of package types and keep them consistent:

Package typePurposeCan depend onShould not depend on
apps/*Deployable Flutter appsfeatures/*, shared/*Other apps
features/*Feature modules, UI plus feature-specific logicshared/*Other features (prefer shared abstractions)
shared/*Cross-cutting librariesOther shared/* (carefully)features/* or apps/*
tools/*CI scripts, generators, dev toolingAnything (dev only)Runtime app code

Example folder layout#

PathExampleWhy it exists
apps/customer_appmain consumer appReleases to stores
apps/admin_appinternal admin appShares auth and design system
features/checkoutcheckout UI and flowsOwned by a feature team
shared/design_systemcomponents, tokensPrevents UI duplication
shared/corelogging, error types, envKeeps app glue minimal
shared/api_clientHTTP client and interceptorsAvoids copy-paste API setup
shared/domain_modelsstable models used across featuresAvoids circular deps

⚠️ Warning: A “shared” package without a strict purpose becomes a dumping ground. If a file doesn’t clearly belong, create a new focused package or keep it in the feature.

# Setting Up Melos for a Flutter Monorepo#

Melos is the most common workspace tool for Dart and Flutter monorepos. It automates bootstrapping local path dependencies, running scripts across packages, and aligning dependencies.

Step 1: Create melos.yaml#

Place it at repo root.

YAML
name: samioda_flutter_workspace
 
packages:
  - apps/**
  - features/**
  - shared/**
 
command:
  bootstrap:
    usePubspecOverrides: true
 
scripts:
  analyze:
    run: melos exec --fail-fast -- dart analyze .
    description: Run static analysis in all packages
 
  test:
    run: melos exec --fail-fast -- dart test
    description: Run unit tests in all packages that have them
 
  flutter_test:
    run: melos exec --fail-fast -- flutter test
    description: Run Flutter tests where applicable
 
  format:
    run: melos exec -- dart format --output=none --set-exit-if-changed .
    description: Enforce formatting across packages

Step 2: Bootstrap#

Bash
melos bootstrap

This wires local path dependencies, so packages can depend on each other without publishing to pub.dev.

💡 Tip: Commit the pubspec_overrides.yaml files if your team benefits from reproducible local setup, but ensure CI runs melos bootstrap so overrides stay consistent.

Step 3: Align SDK and Flutter constraints#

In every package pubspec.yaml, keep SDK constraints identical.

ConstraintRecommendationWhy
Dart SDKOne shared range, for example >=3.4.0 <4.0.0Avoid analyzer and build differences
Flutter SDKSame Flutter channel and version pinned in CIPrevent “works on my machine”

To enforce this, add a CI check that fails when constraints drift. Treat it like a dependency lock for monorepos.

# Designing Shared Packages That Stay Useful#

Shared packages are where monorepos either shine or collapse into spaghetti. The trick is making shared packages small, stable, and opinionated.

Shared package patterns that scale#

PatternExample packageWhat it containsWhat it avoids
Core utilitiesshared/coreerror types, logging, env configUI, feature logic
API layershared/api_clientDio setup, auth interceptors, retryFeature endpoints
Design systemshared/design_systembuttons, typography, spacing tokensFeature screens
Observabilityshared/analyticsevent interface, adaptersBusiness logic decisions
Domain primitivesshared/domain_modelsMoney, Address, IDsAPI DTOs tied to backend changes

A strong practice is to treat shared packages as products with versioned APIs, even if you never publish them.

Public API discipline with barrel files#

Expose a narrow public surface with explicit exports.

Dart
// shared/core/lib/core.dart
export 'src/logging/logger.dart';
export 'src/errors/app_exception.dart';
export 'src/env/app_env.dart';

Consumers import package:core/core.dart, not deep paths. This reduces refactor cost and makes boundary enforcement easier.

Preventing circular dependencies early#

Circular dependencies are common when teams create “helpers” that depend on feature code. Keep dependencies flowing in one direction.

LayerAllowed direction
Appsdown to features and shared
Featuresdown to shared
Sharedonly to shared utilities, never to features or apps

If a shared package needs feature-specific behavior, use an interface in shared and implement it in the feature or app.

# Feature Packages: What Goes Where#

Feature packages should be cohesive units that a team can own, test, and release as part of an app without touching unrelated code.

A feature package template#

FolderContentsPractical rule
lib/src/uiscreens, widgetsUI depends on feature state only
lib/src/stateBLoC, Riverpod, controllersNo direct HTTP, call use cases
lib/src/domainentities, use casesPure Dart, no Flutter imports
lib/src/datarepositories, mappersData depends on API client, storage

This aligns naturally with Clean Architecture and feature-first organization. For the bigger picture, see our Clean Architecture + feature-first guide.

Dependency injection across packages#

Avoid a global service locator in shared packages. Let the app compose dependencies and pass them down.

A simple composition root in the app package keeps boundaries clean.

Dart
// apps/customer_app/lib/main.dart
import 'package:flutter/material.dart';
import 'package:api_client/api_client.dart';
import 'package:checkout/checkout.dart';
 
void main() {
  final client = ApiClient(baseUrl: 'https://api.example.com');
  final checkoutDeps = CheckoutDependencies(apiClient: client);
 
  runApp(CustomerApp(checkout: checkoutDeps));
}

Keep dependency objects small and explicit. If a feature needs 12 dependencies, it’s a signal the feature package is doing too much.

# Keeping Boundaries Clean (Without Slowing the Team)#

Boundaries fail when they are “suggestions.” You need lightweight enforcement that catches violations during PRs.

Use lint rules consistently per package#

At minimum, enforce flutter_lints or dart_lints everywhere, then add custom rules gradually.

Rule categoryWhy it matters in monorepos
Import hygienePrevent deep imports and accidental cross-feature coupling
Unused dependenciesStops dependency creep and reduces build time
Analyzer strictnessMakes refactors safe across packages

Run analysis via Melos so it stays consistent.

Bash
melos run analyze

Enforce import boundaries with architecture tests#

A practical approach is a small “architecture test” package that scans imports and fails if rules are violated. Even a simple grep-based rule catches most issues.

Bash
# tools/check_boundaries.sh
set -e
 
# Disallow features importing other features directly
if grep -R "package:features_" -n features | grep -v "shared/" ; then
  echo "Boundary violation: feature importing another feature"
  exit 1
fi

Keep the rules minimal and aligned with real architecture. Overly strict rules create workarounds and reduce trust.

ℹ️ Note: If you use code generation heavily, exclude *.g.dart and build output folders from boundary checks to avoid noisy failures.

Dependency creep control#

In monorepos, the biggest silent cost is dependency duplication and transitive bloat.

ControlImplementationResult
Dependency allow-listDocument what belongs in shared/core vs featureFewer “quick adds”
Monthly dependency reviewRemove unused, consolidate HTTP, JSON, DI libsSmaller app size, faster builds
Unified versionsSame dio, freezed, riverpod versionsFewer conflicts

For performance implications, see Flutter performance optimization for 60 FPS. Bigger dependency graphs and excessive rebuild triggers translate into dropped frames and slower startup.

# Dependency Management in a Monorepo#

You need two things: alignment and autonomy. Alignment prevents conflicts, autonomy prevents lockstep changes for everything.

Dependency typeWhere to declareWhy
Core toolingRoot docs and CI scriptsConsistent across workspace
Shared runtime libsshared/* packagesCentralize stable primitives
Feature-specific libsfeatures/* packagesAvoid bloating the entire workspace
Dev dependenciesPer packageKeeps toolchains focused

When multiple packages need the same library, prefer adding it to the most specific shared package that owns that responsibility, not to every feature.

Version pinning#

Monorepos tend to “float” versions until something breaks. Use explicit version pins for critical libraries and upgrades via scheduled maintenance.

A pragmatic cadence is one dependency upgrade day every 2 to 4 weeks, plus security updates immediately.

# Testing Strategy Across Packages#

Modularization should reduce test scope and speed up feedback. If tests still require running the entire app, modularization is not delivering value.

What to test where#

Package typePrimary testsSecondary tests
Shared pure DartUnit testsContract tests for adapters
Shared Flutter UIGolden tests, widget testsAccessibility checks
Feature packagesUnit tests for domain, widget tests for UIIntegration tests in app
App packagesIntegration tests, smoke testsEnd-to-end flows per release

Make features testable without the app#

Design feature packages so they can be rendered in isolation with injected dependencies.

Dart
// features/checkout/test/checkout_widget_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:checkout/checkout.dart';
 
void main() {
  testWidgets('shows empty cart state', (tester) async {
    final deps = CheckoutDependencies.fake(emptyCart: true);
 
    await tester.pumpWidget(CheckoutTestApp(deps: deps));
    expect(find.text('Your cart is empty'), findsOneWidget);
  });
}

This is how you keep CI fast: features can be verified without building the full app.

Avoid flaky tests in a monorepo#

Flaky tests waste more time than slow tests. Treat flakiness as a defect.

Common flake sourceFix
Timers and animationsUse fake async or pump with deterministic durations
Network callsUse fakes, not real HTTP
Golden tests on different machinesPin Flutter version, use consistent fonts and rendering setup

# CI for Flutter Monorepos: Fast PRs, Reliable Releases#

CI needs to be selective, cached, and consistent. The biggest win is skipping work when a change only affects one feature.

For CI/CD platform specifics, see Flutter CI/CD with GitHub Actions, Codemagic, and Fastlane.

CI pipeline stages that work well#

StageRuns onGoal
Lint and formatevery PRcatch cheap issues early
Unit tests per packageevery PRfast correctness signal
Build appswhen app or shared packages changeverify compilation and assets
Integration testsnightly and before releaseverify critical flows
Releasetagged commitsdeterministic store builds

Path-based job selection#

Use path filters so a docs change does not trigger app builds. Also, if only one feature changes, run tests for that feature plus any shared packages it depends on.

Even without advanced dependency graph tooling, a simple mapping provides large gains.

Changed pathMinimum jobs
shared/core/**analyze and test all packages, build apps
features/checkout/**analyze and test checkout, build apps that use it
apps/customer_app/**analyze and test customer app, integration tests optionally

💡 Tip: Start with conservative selection, then optimize. A monorepo CI that misses failures is worse than a slower CI.

Caching essentials#

Cache what actually takes time:

CacheWhat it speeds upNotes
Pub cachedependency fetchWorks across jobs if keyed correctly
Build outputsincremental buildsOften tied to Flutter version and OS
CocoaPodsiOS buildsCache pods folder cautiously

Pin Flutter version in CI. Small differences in Flutter or Dart patch versions can break golden tests and create hard-to-debug inconsistencies.

# Common Pitfalls and How to Avoid Them#

  1. 1

    Creating too many packages too early
    Start with a few high-value splits: design system, API client, and one or two large features.

  2. 2

    Letting features depend on each other
    If two features need shared logic, extract it into a focused shared package with a stable API.

  3. 3

    Shared package becomes a “utils” bin
    If the package name is vague, it will become vague. Prefer specific names like api_client, observability, or design_system.

  4. 4

    Monorepo without boundary enforcement
    Add at least one automated boundary check in CI and run dart analyze everywhere on PRs.

  5. 5

    CI always runs everything
    Monorepos fail when feedback loops become slow. Introduce path filters and package-scoped tests early.

# Key Takeaways#

  • Modularize when team size, build times, or multi-app reuse create real pain; don’t split just for structure.
  • Use a clear taxonomy: apps/*, features/*, shared/*, and keep dependencies flowing one way.
  • Keep shared packages small and stable with explicit public APIs and no dependencies on features or apps.
  • Enforce boundaries via consistent linting and simple automated checks that block cross-feature imports.
  • Optimize CI with path-based job selection, aggressive caching, and package-level tests to keep PR feedback fast.

# Conclusion#

A Flutter monorepo with Melos pays off when it reduces coupling and speeds up delivery. The core work is not the tooling setup, but designing packages with clear responsibilities, enforcing boundaries automatically, and building a CI pipeline that runs only what changed.

If you want help implementing Flutter monorepo Melos modularization in a real product, Samioda can audit your current architecture, propose a package split plan, and set up CI and testing so your team can ship faster with fewer regressions.

FAQ

Share
A
Adrijan OmićevićSamioda Team
All articles →

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.