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

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

  • **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:"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.

    dart
    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,
      });
    }
    dart
    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.

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

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

    dart
    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),
      );
    }
    dart
    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:

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

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

  • 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