← Articles

Modular Flutter apps with Melos

By Ann Tech · 30 November 2024

A modular Flutter app splits code into independent packages so teams can work in parallel, features can be independently tested, and boundaries between layers are enforced by the Dart package system rather than conventions. Melos is the tool that manages multiple packages in one Git repo.

What modularization solves

In a single-package app, nothing stops a widget from importing a repository directly, or a repository from importing a widget. As the team grows, these accidental dependencies accumulate into a ball of mud.

With separate packages:

  • packages/core can't import packages/ui (creates a circular dependency)
  • packages/ui can't import business logic accidentally
  • Compile times drop because packages are compiled separately
  • Teams can own specific packages

Module architecture

my_app/
├── melos.yaml
├── apps/
│   └── mobile/              # The Flutter app itself
├── packages/
│   ├── core/                # Models, entities, repository interfaces
│   ├── data/                # Repository implementations, API clients
│   ├── ui/                  # Design system: colors, typography, shared widgets
│   ├── analytics/           # Analytics abstraction
│   └── features/
│       ├── auth/            # Authentication feature
│       ├── orders/          # Orders feature
│       └── settings/        # Settings feature

Dependency direction: appsfeaturesdatacore → nothing. ui is a leaf package depended on by features but not depending on data or core.

melos.yaml

name: my_workspace

packages:
  - apps/**
  - packages/**
  - packages/features/**

command:
  bootstrap:
    usePubspecOverrides: true

scripts:
  test:all:
    run: melos exec -- flutter test
    packageFilters:
      dirExists: test

  analyze:
    run: melos exec -- flutter analyze

  gen:
    run: melos exec -- dart run build_runner build --delete-conflicting-outputs
    packageFilters:
      dependsOn: build_runner

  test:changed:
    run: melos exec -- flutter test
    packageFilters:
      dirExists: test
      diff: origin/main

Feature package structure

Each feature package is a vertical slice:

packages/features/orders/
├── lib/
│   ├── orders.dart           # Public API: screens, providers, routes
│   └── src/
│       ├── screens/
│       │   ├── orders_list_screen.dart
│       │   └── order_detail_screen.dart
│       ├── providers/        # Riverpod providers or BLoCs
│       ├── widgets/          # Feature-specific widgets (not in ui package)
│       └── models/           # Feature-specific view models
├── test/
└── pubspec.yaml
# packages/features/orders/pubspec.yaml
name: feature_orders
dependencies:
  core: any             # Entities, repository interfaces
  data: any             # Repository implementations
  ui: any               # Design system widgets
  flutter_bloc: ^8.1.5
  go_router: ^14.0.0

Routing with go_router across modules

Each feature defines its own routes and the app assembles them:

// packages/features/orders/lib/src/routes.dart
class OrdersRoutes {
  static const listPath = '/orders';
  static const detailPath = '/orders/:id';

  static List<RouteBase> get routes => [
    GoRoute(
      path: listPath,
      builder: (_, __) => const OrdersListScreen(),
      routes: [
        GoRoute(
          path: ':id',
          builder: (_, state) => OrderDetailScreen(
            orderId: state.pathParameters['id']!,
          ),
        ),
      ],
    ),
  ];
}

// apps/mobile/lib/router.dart
final router = GoRouter(
  routes: [
    ...AuthRoutes.routes,
    ...OrdersRoutes.routes,
    ...SettingsRoutes.routes,
  ],
);

Dependency injection across modules

Use get_it with module-level registrations:

// packages/data/lib/src/di.dart
@module
abstract class DataModule {
  @singleton
  OrderRepository get orderRepository => FirestoreOrderRepository();
}

// packages/features/orders/lib/src/di.dart
@module
abstract class OrdersModule {
  @singleton
  OrdersBloc ordersBloc(OrderRepository repo) => OrdersBloc(repo);
}

// apps/mobile/lib/main.dart
await configureDependencies(); // Registers all modules

Running Melos

# Install
dart pub global activate melos

# Bootstrap (sets up local package references)
melos bootstrap

# Run all tests
melos run test:all

# Test only changed packages
melos run test:changed

# Test a specific package
melos run test:all --scope=feature_orders

# Run in all packages depending on core (after a core change)
melos run test:all --filter='dependsOn:core'

CI with affected package detection

- name: Install Melos
  run: dart pub global activate melos

- name: Bootstrap
  run: melos bootstrap

- name: Test changed packages
  run: melos run test:changed
  # Only tests packages changed since origin/main

Common pitfalls

Circular dependencies. If data imports features/orders, you have a cycle. The package system will reject this, which is the point — it enforces the architecture. Fix by extracting the shared type to core.

Too many packages too soon. Start with 3-4 packages (core, data, ui, app) and split feature packages only when the team grows or the feature becomes independently deployable. Premature splitting adds overhead without benefit.

Not gitignoring pubspec_overrides.yaml. Add **/pubspec_overrides.yaml to .gitignore. These are generated by melos bootstrap for local development — committing them breaks CI.

Sign in to like, dislike, or report.

Modular Flutter apps with Melos — ANN Tech