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/corecan't importpackages/ui(creates a circular dependency)packages/uican'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: apps → features → data → core → 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.