← Articles

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

  1. Start with the design system — it has no dependencies on the rest of the app
  2. Extract core infrastructure (network, storage)
  3. Extract domain models and repository interfaces
  4. Extract data layer implementations
  5. 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.

Package architecture in Flutter: when to split into packages — ANN Tech