Package architecture in Flutter: when to split into packages
By Ann Tech · 8 June 2026
At some point, a growing Flutter app becomes hard to navigate and easy to accidentally couple. Splitting into packages is the solution — it enforces import boundaries at compile time and makes the codebase navigable again.
When packages are worth the overhead
Worth splitting:
- A design system used by multiple apps
- A data layer (repositories, models) that could be reused or tested in isolation
- A feature that's worked on by a dedicated sub-team
- A platform plugin with example app
Not worth splitting:
- A small app with 2-3 features
- Early-stage projects where the architecture is still evolving
- Solo projects where the overhead isn't offset by collaboration benefits
Package types in a Flutter monorepo
packages/
# Infrastructure
core_network/ # Dio setup, interceptors
core_storage/ # SharedPreferences, secure storage wrappers
core_analytics/ # Analytics service abstraction + implementations
# Domain
auth_domain/ # User model, AuthRepository interface
product_domain/ # Product model, ProductRepository interface
# Data
auth_data/ # AuthRepository implementation (hits the API)
product_data/ # ProductRepository implementation
# UI
design_system/ # Tokens, components, themes
shared_widgets/ # App-specific shared widgets (not design system)
# Features
feature_auth/ # Login, register screens
feature_products/ # Product listing, detail screens
feature_cart/ # Cart screen, checkout
apps/
consumer/ # Assembles all feature packages into an app
admin/ # Different assembly of some feature packages
Package pubspec.yaml
Each package has its own pubspec.yaml:
# packages/auth_data/pubspec.yaml
name: auth_data
description: AuthRepository implementation
version: 0.1.0
environment:
sdk: '>=3.0.0 <4.0.0'
flutter: '>=3.24.0'
dependencies:
flutter:
sdk: flutter
auth_domain:
path: ../auth_domain
core_network:
path: ../core_network
dio: ^5.4.0
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.0
Enforcing package boundaries
The compiler enforces dependencies — if feature_products doesn't list feature_cart as a dependency, it can't import from it. This makes accidental coupling a compile error rather than a code review catch.
// COMPILE ERROR if feature_products doesn't depend on feature_cart:
import 'package:feature_cart/cart_notifier.dart';
Public API surface
Each package should have a single barrel export file:
// packages/auth_domain/lib/auth_domain.dart
export 'src/models/user.dart';
export 'src/repositories/auth_repository.dart';
// Internal implementation details are NOT exported
Consumers import only from the barrel:
import 'package:auth_domain/auth_domain.dart';
// Not: import 'package:auth_domain/src/models/user.dart';
Testing advantages
Each package has its own test/ directory. Tests only cover that package's code:
// packages/auth_data/test/auth_repository_test.dart
void main() {
late MockDio dio;
late AuthRepositoryImpl repo;
setUp(() {
dio = MockDio();
repo = AuthRepositoryImpl(dio);
});
test('login returns user on success', () async {
when(() => dio.post('/auth/login', data: any(named: 'data')))
.thenAnswer((_) async => Response(
data: {'token': 'test-token', 'userId': '123'},
statusCode: 200,
requestOptions: RequestOptions(),
));
final user = await repo.login('[email protected]', 'password');
expect(user.id, '123');
});
}
Migrating an existing app to packages
- Start with the design system — it has no dependencies on the rest of the app
- Extract core infrastructure (network, storage)
- Extract domain models and repository interfaces
- Extract data layer implementations
- Extract features one at a time, starting with the most isolated
Don't try to extract everything at once. Extract one package per sprint, verify it's working, then continue.
Common pitfalls
Too many tiny packages. A package for every 5 files creates more overhead than value. Package when you have a meaningful boundary — a clear API, a team ownership line, or a reuse need.
Putting platform-specific code in a shared package. A package used by both iOS and Android apps can't import platform-channel packages that don't support both. Keep platform-specific code in feature packages or platform-specific packages.
Not testing packages in isolation. The value of a package boundary is that you can test it independently. If your package tests require the full app to run, the boundary isn't real — refactor until tests work standalone.
Sign in to like, dislike, or report.