Flutter

Flutter Monorepo: Multi-Package Management with Melos

11 min readMarch 9, 2026
Flutter monorepoMelos FlutterFlutter multi-packageFlutter shared codeFlutter workspaceFlutter modular architectureMelos CI/CDFlutter code sharing

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:

  • Atomic changes: Update a shared model and fix all apps that use it in a single commit.
  • Consistent tooling: One linting configuration, one CI pipeline, one set of code quality rules.
  • Simplified onboarding: New developers clone one repo and have everything they need.
  • Refactoring confidence: Rename a class in a shared package and see every breakage immediately.

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

yaml
# Add to your root pubspec.yaml
dev_dependencies:
  melos: ^6.1.0
bash
dart pub get
dart run melos bootstrap

Melos Configuration

The melos.yaml file at the root of your repository is where you define your workspace. Here is a production-ready configuration:

yaml
name: my_flutter_workspace
repository: https://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.

dart
// 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() => '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,
  });
}
dart
// packages/core/lib/src/extensions/date_extensions.dart
extension DateTimeX on DateTime {
  String get formatted => '$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.

dart
// packages/models/lib/src/user/user.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part '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('customer')
  customer,
  @JsonValue('admin')
  admin,
  @JsonValue('driver')
  driver,
}

API Client Package

A shared API client ensures consistent authentication, error handling, and request/response processing across all apps.

dart
// packages/api_client/lib/src/client.dart
import 'package:dio/dio.dart';
import 'package:core/core.dart';
import '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: 3),
    ]);
  }

  Future<User> getCurrentUser() async {
    try {
      final response = await _dio.get('/users/me');
      return User.fromJson(response.data);
    } on DioException catch (e) {
      throw NetworkException(
        message: e.message ?? 'Failed to fetch user',
        statusCode: e.response?.statusCode,
      );
    }
  }

  Future<List<Order>> getOrders({int page = 1, int limit = 20}) async {
    try {
      final response = await _dio.get(
        '/orders',
        queryParameters: {'page': page, 'limit': limit},
      );
      return (response.data as List)
          .map((json) => Order.fromJson(json))
          .toList();
    } on DioException catch (e) {
      throw NetworkException(
        message: e.message ?? '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.

dart
// packages/ui_kit/lib/src/theme/app_theme.dart
import '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: 28),
    titleMedium: TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
    bodyMedium: TextStyle(fontWeight: FontWeight.w400, fontSize: 14),
  );
}
dart
// packages/ui_kit/lib/src/widgets/app_button.dart
import '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: 20,
              width: 20,
              child: CircularProgressIndicator(strokeWidth: 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:

yaml
# 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.0

And here is how shared packages depend on each other:

yaml
# 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.0

The 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:

bash
# 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/main

GitHub Actions Workflow

yaml
# .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 --release

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

dart
// packages/models/test/user_test.dart
import 'package:models/models.dart';
import 'package:test/test.dart';

void main() {
  group('User', () {
    test('fromJson creates User correctly', () {
      final json = {
        'id': 'usr_123',
        'email': 'test@example.com',
        'display_name': 'Test User',
        'role': 'customer',
        'created_at': '2025-01-15T10:30:00Z',
        'is_verified': true,
      };

      final user = User.fromJson(json);

      expect(user.id, 'usr_123');
      expect(user.email, 'test@example.com');
      expect(user.role, UserRole.customer);
      expect(user.isVerified, isTrue);
    });

    test('toJson produces correct map', () {
      final user = User(
        id: 'usr_123',
        email: 'test@example.com',
        displayName: 'Test User',
        role: UserRole.admin,
        createdAt: DateTime(2025, 1, 15),
      );

      final json = user.toJson();

      expect(json['role'], 'admin');
      expect(json['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.

dart
// apps/customer_app/test/integration/order_flow_test.dart
import 'package:customer_app/features/orders/order_bloc.dart';
import 'package:api_client/api_client.dart';
import 'package:models/models.dart';
import 'package:flutter_test/flutter_test.dart';
import '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('loads orders from API client', () async {
    when(mockApi.getOrders()).thenAnswer(
      (_) async => [
        Order(id: '1', status: OrderStatus.pending, total: 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:

  • You have 2-5 apps sharing significant business logic.
  • A small to medium team (2-15 developers) works across apps.
  • Shared code changes frequently and needs to be immediately available.
  • You want a single CI/CD pipeline and unified code review process.
  • Apps are versioned and released on a similar cadence.

Choose multi-repo when:

  • Apps are owned by completely independent teams with different release cycles.
  • Shared code is stable and changes infrequently (publish it as a versioned package).
  • Your organization has 50+ developers and the monorepo would become a bottleneck.
  • Apps use different tech stacks (one Flutter, one React Native).
  • Regulatory requirements demand separate repositories for audit trails.

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:

bash
# 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 bootstrap

From 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

Have a Flutter Project?

I build high-performance Flutter applications for iOS, Android, and web.

Get in Touch