Flutter Monorepo: Multi-Package Management with Melos
# Multi-Package Flutter Monorepo with Shared Code
Managing multiple Flutter apps that share business logic, UI components, and API clients is one of those problems that seems simple until you are three months in and drowning in copy-pasted code. A monorepo with shared packages solves this by keeping everything in one repository while maintaining clear boundaries between packages. In a multi-app project I managed, switching to a monorepo cut our code duplication by over 60% and reduced the time to ship a new feature across all apps from two weeks to three days.
Why Monorepo for Flutter Projects
When you have a single app, a standard Flutter project structure works fine. The moment you add a second app -- maybe a customer-facing app and an admin panel, or a white-label product with multiple brand variants -- things get complicated.
The copy-paste trap: You start by copying shared code between repos. At first it feels fast. Then a bug fix in one repo does not reach the others. Models drift apart. The API client in app A handles pagination differently from app B. Three months later, you are maintaining three slightly different versions of the same code.
The package publishing overhead: You could extract shared code into published Dart packages. But now you need a private package registry, versioning discipline, and a release process for every small change. For most teams, this is unnecessary friction.
The monorepo solution: Everything lives in one repository. Shared code is organized into local packages. All apps reference these packages via path dependencies. A change to the shared API client is immediately available to every app. One PR, one review, one merge.
Benefits that compound over time:
Melos: The Monorepo Orchestrator
Melos is the de facto tool for managing Dart and Flutter monorepos. It handles dependency resolution, script orchestration, and selective command execution across packages. Think of it as the conductor that keeps all your packages playing in harmony.
Installation
# Add to your root pubspec.yaml
dev_dependencies:
melos: ^6.1.0dart pub get
dart run melos bootstrapMelos Configuration
The `melos.yaml` file at the root of your repository is where you define your workspace. Here is a production-ready configuration:
name: my_flutter_workspace
repository: https:"code-comment">//github.com/myorg/my-flutter-workspace
packages:
- apps/*
- packages/*
command:
bootstrap:
usePubspecOverrides: true
scripts:
analyze:
run: dart analyze --fatal-infos
exec:
concurrency: 5
description: Run static analysis across all packages.
test:
run: flutter test --coverage
exec:
concurrency: 3
failFast: true
packageFilters:
dirExists: test
description: Run tests in all packages that have a test directory.
format:
run: dart format --set-exit-if-changed .
description: Check formatting across the workspace.
build:runner:
run: dart run build_runner build --delete-conflicting-outputs
exec:
concurrency: 1
failFast: true
packageFilters:
dependsOn: build_runner
description: Run build_runner in packages that depend on it.
clean:
run: flutter clean
exec:
concurrency: 5
description: Clean all packages.
pub:upgrade:
run: flutter pub upgrade
exec:
concurrency: 3
description: Upgrade dependencies in all packages.The `packageFilters` option is powerful. Instead of running build_runner in every package (most of which do not use it), Melos only targets packages that actually depend on it. This saves significant CI time.
Folder Structure for a Real Monorepo
Here is the folder structure I use in production. It has evolved over multiple projects and balances clarity with practicality:
my_flutter_workspace/
melos.yaml
pubspec.yaml
apps/
customer_app/
lib/
test/
pubspec.yaml
android/
ios/
admin_app/
lib/
test/
pubspec.yaml
android/
ios/
driver_app/
lib/
test/
pubspec.yaml
android/
ios/
packages/
core/
lib/
src/
constants/
extensions/
errors/
utils/
core.dart
test/
pubspec.yaml
models/
lib/
src/
user/
order/
product/
models.dart
test/
pubspec.yaml
api_client/
lib/
src/
interceptors/
endpoints/
client.dart
api_client.dart
test/
pubspec.yaml
ui_kit/
lib/
src/
theme/
widgets/
tokens/
ui_kit.dart
test/
pubspec.yaml
analytics/
lib/
src/
providers/
events/
analytics.dart
test/
pubspec.yaml
tools/
ci/
scripts/Each directory under `packages/` is an independent Dart or Flutter package with its own `pubspec.yaml`, tests, and barrel file. The `apps/` directory contains the actual Flutter applications. The `tools/` directory holds CI scripts and utility tooling.
Shared Packages in Detail
Core Package
The core package contains foundational utilities that every other package depends on. It should be stable, well-tested, and change infrequently.
class=class="code-string">"code-comment">// packages/core/lib/src/errors/app_exception.dart
abstract class AppException implements Exception {
final String message;
final String? code;
final StackTrace? stackTrace;
const AppException({
required this.message,
this.code,
this.stackTrace,
});
@override
String toString() => class="code-string">'AppException($code): $message';
}
class NetworkException extends AppException {
final int? statusCode;
const NetworkException({
required super.message,
this.statusCode,
super.code,
super.stackTrace,
});
}
class CacheException extends AppException {
const CacheException({
required super.message,
super.code,
super.stackTrace,
});
}class=class="code-string">"code-comment">// packages/core/lib/src/extensions/date_extensions.dart
extension DateTimeX on DateTime {
String get formatted => class="code-string">'$day/$month/$year';
bool get isToday {
final now = DateTime.now();
return year == now.year && month == now.month && day == now.day;
}
bool get isPast => isBefore(DateTime.now());
}Models Package
Shared models are the most impactful package in a monorepo. When all apps use the same `User`, `Order`, or `Product` model, you eliminate an entire class of bugs caused by model drift.
class=class="code-string">"code-comment">// packages/models/lib/src/user/user.dart
import class="code-string">'package:freezed_annotation/freezed_annotation.dart';
part class="code-string">'user.freezed.dart';
part class="code-string">'user.g.dart';
@freezed
class User with _$User {
const factory User({
required String id,
required String email,
required String displayName,
required UserRole role,
required DateTime createdAt,
String? avatarUrl,
@Default(false) bool isVerified,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
enum UserRole {
@JsonValue(class="code-string">'customer')
customer,
@JsonValue(class="code-string">'admin')
admin,
@JsonValue(class="code-string">'driver')
driver,
}API Client Package
A shared API client ensures consistent authentication, error handling, and request/response processing across all apps.
class=class="code-string">"code-comment">// packages/api_client/lib/src/client.dart
import class="code-string">'package:dio/dio.dart';
import class="code-string">'package:core/core.dart';
import class="code-string">'package:models/models.dart';
class ApiClient {
final Dio _dio;
ApiClient({required String baseUrl, required TokenProvider tokenProvider})
: _dio = Dio(BaseOptions(baseUrl: baseUrl)) {
_dio.interceptors.addAll([
AuthInterceptor(tokenProvider: tokenProvider),
LoggingInterceptor(),
RetryInterceptor(maxRetries: class="code-number">3),
]);
}
Future<User> getCurrentUser() async {
try {
final response = await _dio.get(class="code-string">'/users/me');
return User.fromJson(response.data);
} on DioException catch (e) {
throw NetworkException(
message: e.message ?? class="code-string">'Failed to fetch user',
statusCode: e.response?.statusCode,
);
}
}
Future<List<Order>> getOrders({int page = class="code-number">1, int limit = class="code-number">20}) async {
try {
final response = await _dio.get(
class="code-string">'/orders',
queryParameters: {class="code-string">'page': page, class="code-string">'limit': limit},
);
return (response.data as List)
.map((json) => Order.fromJson(json))
.toList();
} on DioException catch (e) {
throw NetworkException(
message: e.message ?? class="code-string">'Failed to fetch orders',
statusCode: e.response?.statusCode,
);
}
}
}UI Kit Package
The UI kit provides a consistent visual language across all apps. It contains the shared theme, design tokens, and reusable widgets.
class=class="code-string">"code-comment">// packages/ui_kit/lib/src/theme/app_theme.dart
import class="code-string">'package:flutter/material.dart';
class AppTheme {
static ThemeData light({Color? primaryColor}) {
final primary = primaryColor ?? const Color(0xFF2563EB);
return ThemeData(
useMaterial3: true,
colorSchemeSeed: primary,
brightness: Brightness.light,
textTheme: _textTheme,
inputDecorationTheme: _inputTheme,
elevatedButtonTheme: _buttonTheme(primary),
);
}
static const _textTheme = TextTheme(
headlineLarge: TextStyle(fontWeight: FontWeight.w700, fontSize: class="code-number">28),
titleMedium: TextStyle(fontWeight: FontWeight.w600, fontSize: class="code-number">16),
bodyMedium: TextStyle(fontWeight: FontWeight.w400, fontSize: class="code-number">14),
);
}class=class="code-string">"code-comment">// packages/ui_kit/lib/src/widgets/app_button.dart
import class="code-string">'package:flutter/material.dart';
class AppButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed;
final bool isLoading;
const AppButton({
super.key,
required this.label,
this.onPressed,
this.isLoading = false,
});
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: isLoading ? null : onPressed,
child: isLoading
? const SizedBox(
height: class="code-number">20,
width: class="code-number">20,
child: CircularProgressIndicator(strokeWidth: class="code-number">2),
)
: Text(label),
);
}
}Dependency Management Across Packages
Getting dependencies right in a monorepo is critical. Here is the `pubspec.yaml` of an app that consumes all shared packages:
# apps/customer_app/pubspec.yaml
name: customer_app
description: Customer-facing mobile application.
dependencies:
flutter:
sdk: flutter
core:
path: ../../packages/core
models:
path: ../../packages/models
api_client:
path: ../../packages/api_client
ui_kit:
path: ../../packages/ui_kit
analytics:
path: ../../packages/analytics
# App-specific dependencies
flutter_bloc: ^8.1.0
go_router: ^14.0.0And here is how shared packages depend on each other:
# packages/api_client/pubspec.yaml
name: api_client
description: Shared HTTP client with auth and error handling.
dependencies:
dio: ^5.4.0
core:
path: ../core
models:
path: ../models
dev_dependencies:
test: ^1.25.0
mockito: ^5.4.0
build_runner: ^2.4.0The Dependency Rule
Packages should form a directed acyclic graph (DAG). If package A depends on package B, then B must never depend on A. In practice, this means `core` depends on nothing, `models` depends on `core`, `api_client` depends on `core` and `models`, and apps depend on everything.
Circular dependencies are a build-time error in Dart, so the compiler enforces this. But more subtly, you should also avoid deep dependency chains. If changing `core` forces a rebuild of every package, your CI times will suffer. Keep `core` small and stable.
Version Constraints
Within a monorepo, use path dependencies for internal packages. For external dependencies, pin versions consistently across packages. Melos `bootstrap` resolves version conflicts and alerts you if two packages require incompatible versions of the same dependency.
CI/CD for Monorepos
The biggest operational advantage of a monorepo is selective CI. You should not rebuild and retest every package when only one file changed.
Detecting Affected Packages
Melos can identify which packages were affected by a change:
# List packages that changed since the last release tag
melos list --since=origin/main --diff
# Run tests only in affected packages
melos run test --since=origin/mainGitHub Actions Workflow
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
jobs:
analyze-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
cache: true
- name: Install Melos
run: dart pub global activate melos
- name: Bootstrap
run: melos bootstrap
- name: Analyze affected packages
run: melos run analyze --since=origin/main
- name: Test affected packages
run: melos run test --since=origin/main
- name: Check formatting
run: melos run format
build-customer-app:
needs: analyze-and-test
if: |
contains(github.event.pull_request.labels.*.name, 'build:customer') ||
contains(github.event.pull_request.title, '[customer]')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
- run: melos bootstrap
- run: cd apps/customer_app && flutter build apk --release
build-admin-app:
needs: analyze-and-test
if: |
contains(github.event.pull_request.labels.*.name, 'build:admin') ||
contains(github.event.pull_request.title, '[admin]')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
- run: melos bootstrap
- run: cd apps/admin_app && flutter build apk --releaseThe `--since=origin/main` flag is the key. Melos compares the current branch to main and only runs commands in packages that have changed. On a monorepo with 15 packages, this can reduce CI time from 25 minutes to under 5 minutes.
Testing Strategy in Monorepos
Each package should have its own tests. This is not optional -- it is the foundation that makes monorepo development sustainable.
Package-Level Unit Tests
Shared packages need thorough unit tests because multiple apps depend on them. A bug in the `models` package affects every app.
class=class="code-string">"code-comment">// packages/models/test/user_test.dart
import class="code-string">'package:models/models.dart';
import class="code-string">'package:test/test.dart';
void main() {
group(class="code-string">'User', () {
test(class="code-string">'fromJson creates User correctly', () {
final json = {
class="code-string">'id': class="code-string">'usr_123',
class="code-string">'email': class="code-string">'test@example.com',
class="code-string">'display_name': class="code-string">'Test User',
class="code-string">'role': class="code-string">'customer',
class="code-string">'created_at': class="code-string">'class="code-number">2025-class="code-number">01-15T10:class="code-number">30:00Z',
class="code-string">'is_verified': true,
};
final user = User.fromJson(json);
expect(user.id, class="code-string">'usr_123');
expect(user.email, class="code-string">'test@example.com');
expect(user.role, UserRole.customer);
expect(user.isVerified, isTrue);
});
test(class="code-string">'toJson produces correct map', () {
final user = User(
id: class="code-string">'usr_123',
email: class="code-string">'test@example.com',
displayName: class="code-string">'Test User',
role: UserRole.admin,
createdAt: DateTime(class="code-number">2025, class="code-number">1, class="code-number">15),
);
final json = user.toJson();
expect(json[class="code-string">'role'], class="code-string">'admin');
expect(json[class="code-string">'is_verified'], false);
});
});
}Integration Tests at the App Level
Apps should have integration tests that exercise the full stack, including the shared packages as they are wired together.
class=class="code-string">"code-comment">// apps/customer_app/test/integration/order_flow_test.dart
import class="code-string">'package:customer_app/features/orders/order_bloc.dart';
import class="code-string">'package:api_client/api_client.dart';
import class="code-string">'package:models/models.dart';
import class="code-string">'package:flutter_test/flutter_test.dart';
import class="code-string">'package:mockito/mockito.dart';
class MockApiClient extends Mock implements ApiClient {}
void main() {
late OrderBloc bloc;
late MockApiClient mockApi;
setUp(() {
mockApi = MockApiClient();
bloc = OrderBloc(apiClient: mockApi);
});
test(class="code-string">'loads orders from API client', () async {
when(mockApi.getOrders()).thenAnswer(
(_) async => [
Order(id: class="code-string">'class="code-number">1', status: OrderStatus.pending, total: class="code-number">29.99),
],
);
bloc.add(LoadOrders());
await expectLater(
bloc.stream,
emitsInOrder([
isA<OrdersLoading>(),
isA<OrdersLoaded>(),
]),
);
});
}Testing Shared Packages in Isolation
A critical rule: shared package tests must never depend on app code. If your `api_client` test imports something from `customer_app`, you have a circular dependency waiting to happen. Use mocks and fakes within the package itself.
Monorepo vs Multi-Repo: When to Choose What
This is not a religious debate. Each approach fits different team structures and project realities.
Choose monorepo when:
Choose multi-repo when:
The hybrid approach: Some teams start with a monorepo and graduate stable packages into published packages over time. The `core` package that has not changed in six months can become a versioned dependency. The `models` package that changes with every API update stays in the monorepo.
Common Monorepo Mistakes
1. God Package
Putting everything into a single `shared` package defeats the purpose. When `shared` contains models, API clients, utilities, theme data, and analytics, every change triggers a rebuild of everything. Split by domain, not by convenience.
2. Cross-App Imports
App A should never import code from App B. If both apps need the same widget, move it to the `ui_kit` package. Cross-app imports create invisible coupling and make it impossible to build apps independently.
3. Inconsistent Dependency Versions
If `customer_app` uses `dio: ^5.3.0` and `admin_app` uses `dio: ^5.4.0`, you are asking for subtle runtime differences. Centralize version constraints or use Melos dependency overrides to enforce consistency.
4. Skipping the Bootstrap Step
New team members will clone the repo and run `flutter pub get` in an app directory. This fails because path dependencies are not resolved. Document that `melos bootstrap` is the first command to run, and enforce it with a setup script.
5. Testing Packages Through Apps
Testing your `api_client` package by running the customer app and clicking around is not a testing strategy. Each package must have its own unit tests that run independently. The CI pipeline should enforce this.
6. Monorepo Without Tooling
A monorepo without Melos (or similar tooling) is just a folder with multiple pubspec files. You lose all the benefits -- selective testing, script orchestration, dependency management. Invest in the tooling from day one.
Getting Started: Step by Step
If you are convinced and want to set up your first Flutter monorepo, here is the minimal path:
# Create workspace
mkdir my_workspace && cd my_workspace
dart pub init
# Add melos to dev_dependencies, then:
dart pub get
# Create structure
mkdir -p apps/my_app packages/core packages/models
# Initialize packages
cd packages/core && dart pub init && cd ../..
cd packages/models && dart pub init && cd ../..
# Create melos.yaml at root (see configuration above)
# Bootstrap everything
dart run melos bootstrapFrom here, move shared code into packages incrementally. Do not try to extract everything at once. Start with models -- they are the easiest to extract and provide the most immediate value.
In my experience, a well-structured monorepo becomes the backbone of a multi-app Flutter project. The initial setup takes a day or two. The time saved over the following months is measured in weeks. The key is disciplined package boundaries and investing in CI from the start.
If you are planning a multi-app Flutter architecture, let's discuss which structure fits your team best.
Related Articles
Clean Architecture in Flutter: Building Scalable Applications
Learn how to apply Clean Architecture in Flutter pragmatically. A practical guide to layers, dependency management, and testable code.
Dart Best Practices: Writing Clean and Maintainable Code
Learn practical Dart best practices for readability, testability, and long-term maintainability.
Flutter CI/CD: Automated Build, Test, and Release Pipeline
Design a robust Flutter CI/CD pipeline for reliable delivery with automated build, test, and release steps.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch