Flutter Monorepo: Multi-Paket-Verwaltung mit Melos
# Multi-Package Flutter Monorepo mit geteiltem Code
Mehrere Flutter-Apps zu verwalten, die sich Geschaeftslogik, UI-Komponenten und API-Clients teilen, wirkt auf den ersten Blick einfach. Drei Monate spaeter ertrinkt man jedoch in kopiertem Code. Ein Monorepo mit geteilten Packages loest dieses Problem, indem alles in einem einzigen Repository lebt und gleichzeitig klare Grenzen zwischen den Packages bestehen bleiben. In einem Multi-App-Projekt, das ich verantwortet habe, hat der Wechsel zum Monorepo die Code-Duplizierung um ueber 60% reduziert und die Zeit, ein neues Feature in allen Apps auszurollen, von zwei Wochen auf drei Tage verkuerzt.
Warum Monorepo fuer Flutter-Projekte?
Solange man nur eine App hat, funktioniert die Standard-Flutter-Projektstruktur bestens. Sobald eine zweite App hinzukommt -- etwa eine Kunden-App und ein Admin-Panel oder ein White-Label-Produkt mit mehreren Markenvarianten -- wird es kompliziert.
Die Copy-Paste-Falle: Man beginnt damit, geteilten Code zwischen Repositories zu kopieren. Anfangs fuehlt es sich schnell an. Dann erreicht ein Bugfix in einem Repo die anderen nicht. Modelle driften auseinander. Der API-Client in App A behandelt Paginierung anders als in App B. Drei Monate spaeter pflegt man drei leicht unterschiedliche Versionen desselben Codes.
Der Package-Publishing-Aufwand: Man koennte geteilten Code in veroeffentlichte Dart-Packages auslagern. Aber dann braucht man eine private Package-Registry, Versionierungsdisziplin und einen Release-Prozess fuer jede kleine Aenderung. Fuer die meisten Teams ist das unnoetige Reibung.
Die Monorepo-Loesung: Alles lebt in einem Repository. Geteilter Code ist in lokalen Packages organisiert. Alle Apps referenzieren diese Packages ueber Path-Dependencies. Eine Aenderung am geteilten API-Client steht sofort in jeder App zur Verfuegung. Ein PR, ein Review, ein Merge.
Vorteile, die sich ueber die Zeit summieren:
Melos: Der Monorepo-Orchestrator
Melos ist das De-facto-Tool zur Verwaltung von Dart- und Flutter-Monorepos. Es uebernimmt Dependency-Resolution, Skript-Orchestrierung und selektive Befehlsausfuehrung ueber Packages hinweg. Man kann es sich als den Dirigenten vorstellen, der alle Packages im Einklang haelt.
Installation
# In die Root-pubspec.yaml einfuegen
dev_dependencies:
melos: ^6.1.0dart pub get
dart run melos bootstrapMelos-Konfiguration
Die Datei `melos.yaml` im Wurzelverzeichnis des Repositories definiert den Workspace. Hier eine produktionsreife Konfiguration:
name: flutter_workspace
repository: https:"code-comment">//github.com/meinorg/flutter-workspace
packages:
- apps/*
- packages/*
command:
bootstrap:
usePubspecOverrides: true
scripts:
analyze:
run: dart analyze --fatal-infos
exec:
concurrency: 5
description: Statische Analyse in allen Packages ausfuehren.
test:
run: flutter test --coverage
exec:
concurrency: 3
failFast: true
packageFilters:
dirExists: test
description: Tests in allen Packages mit Test-Verzeichnis ausfuehren.
format:
run: dart format --set-exit-if-changed .
description: Formatierung im gesamten Workspace pruefen.
build:runner:
run: dart run build_runner build --delete-conflicting-outputs
exec:
concurrency: 1
failFast: true
packageFilters:
dependsOn: build_runner
description: build_runner in Packages mit build_runner-Abhaengigkeit ausfuehren.
clean:
run: flutter clean
exec:
concurrency: 5
description: Alle Packages bereinigen.
pub:upgrade:
run: flutter pub upgrade
exec:
concurrency: 3
description: Abhaengigkeiten in allen Packages aktualisieren.Die Option `packageFilters` ist maechtig. Statt build_runner in jedem Package auszufuehren (die meisten verwenden es gar nicht), zielt Melos nur auf Packages ab, die tatsaechlich davon abhaengen. Das spart erheblich CI-Zeit.
Ordnerstruktur fuer ein echtes Monorepo
Hier die Ordnerstruktur, die ich in der Produktion verwende. Sie hat sich ueber mehrere Projekte hinweg entwickelt und vereint Klarheit mit Praktikabilitaet:
flutter_workspace/
melos.yaml
pubspec.yaml
apps/
kunden_app/
lib/
test/
pubspec.yaml
android/
ios/
admin_app/
lib/
test/
pubspec.yaml
android/
ios/
fahrer_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/Jedes Verzeichnis unter `packages/` ist ein eigenstaendiges Dart- oder Flutter-Package mit eigener `pubspec.yaml`, Tests und Barrel-Datei. Das `apps/`-Verzeichnis enthaelt die eigentlichen Flutter-Anwendungen. Das `tools/`-Verzeichnis beherbergt CI-Skripte und Hilfstools.
Geteilte Packages im Detail
Core-Package
Das Core-Package enthaelt grundlegende Hilfsmittel, von denen jedes andere Package abhaengt. Es sollte stabil, gut getestet und selten veraendert werden.
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,
});
}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
Geteilte Modelle sind das wirkungsvollste Package in einem Monorepo. Wenn alle Apps dasselbe `User`-, `Order`- oder `Product`-Modell verwenden, eliminiert man eine ganze Klasse von Bugs, die durch Model-Drift entstehen.
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
Ein geteilter API-Client stellt konsistente Authentifizierung, Fehlerbehandlung und Request-/Response-Verarbeitung ueber alle Apps hinweg sicher.
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">'Benutzer konnte nicht geladen werden',
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">'Bestellungen konnten nicht geladen werden',
statusCode: e.response?.statusCode,
);
}
}
}UI-Kit-Package
Das UI-Kit sorgt fuer eine konsistente visuelle Sprache ueber alle Apps hinweg. Es enthaelt das geteilte Theme, Design-Tokens und wiederverwendbare Widgets.
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),
);
}Abhaengigkeitsverwaltung zwischen Packages
Die Abhaengigkeiten in einem Monorepo richtig aufzusetzen ist entscheidend. So sieht die `pubspec.yaml` einer App aus, die alle geteilten Packages nutzt:
# apps/kunden_app/pubspec.yaml
name: kunden_app
description: Mobile Anwendung fuer Endkunden.
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-spezifische Abhaengigkeiten
flutter_bloc: ^8.1.0
go_router: ^14.0.0Und so haengen die geteilten Packages voneinander ab:
# packages/api_client/pubspec.yaml
name: api_client
description: Geteilter HTTP-Client mit Auth und Fehlerbehandlung.
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.0Die Abhaengigkeitsregel
Packages muessen einen gerichteten azyklischen Graphen (DAG) bilden. Wenn Package A von Package B abhaengt, darf B niemals von A abhaengen. Praktisch bedeutet das: `core` haengt von nichts ab, `models` haengt von `core` ab, `api_client` haengt von `core` und `models` ab, und Apps haengen von allem ab.
Zirkulaere Abhaengigkeiten sind ein Compile-Time-Fehler in Dart -- der Compiler erzwingt das. Aber subtiler sollte man auch tiefe Abhaengigkeitsketten vermeiden. Wenn eine Aenderung an `core` jedes Package neu bauen laesst, leiden die CI-Zeiten. Halten Sie `core` klein und stabil.
CI/CD fuer Monorepos
Der groesste operationelle Vorteil eines Monorepos ist selektive CI. Man sollte nicht jedes Package neu bauen und testen, wenn sich nur eine Datei geaendert hat.
Betroffene Packages erkennen
Melos kann identifizieren, welche Packages von einer Aenderung betroffen sind:
# Packages auflisten, die sich seit dem letzten Release-Tag geaendert haben
melos list --since=origin/main --diff
# Tests nur in betroffenen Packages ausfuehren
melos run test --since=origin/mainGitHub-Actions-Workflow
# .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: Melos installieren
run: dart pub global activate melos
- name: Bootstrap
run: melos bootstrap
- name: Betroffene Packages analysieren
run: melos run analyze --since=origin/main
- name: Betroffene Packages testen
run: melos run test --since=origin/main
- name: Formatierung pruefen
run: melos run format
build-kunden-app:
needs: analyze-and-test
if: |
contains(github.event.pull_request.labels.*.name, 'build:kunden') ||
contains(github.event.pull_request.title, '[kunden]')
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/kunden_app && flutter build apk --releaseDas `--since=origin/main`-Flag ist der Schluessel. Melos vergleicht den aktuellen Branch mit main und fuehrt Befehle nur in geaenderten Packages aus. Bei einem Monorepo mit 15 Packages kann das die CI-Zeit von 25 Minuten auf unter 5 Minuten reduzieren.
Teststrategie in Monorepos
Jedes Package sollte eigene Tests haben. Das ist keine Option -- es ist das Fundament, das Monorepo-Entwicklung tragfaehig macht.
Package-Level Unit Tests
Geteilte Packages brauchen gruendliche Unit-Tests, weil mehrere Apps von ihnen abhaengen. Ein Bug im `models`-Package betrifft jede App.
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 erzeugt User korrekt', () {
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">'Testbenutzer',
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 erzeugt korrekte Map', () {
final user = User(
id: class="code-string">'usr_123',
email: class="code-string">'test@example.com',
displayName: class="code-string">'Testbenutzer',
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);
});
});
}Integrationstests auf App-Ebene
Apps sollten Integrationstests haben, die den vollstaendigen Stack durchspielen, einschliesslich der geteilten Packages in ihrer Zusammenschaltung.
class=class="code-string">"code-comment">// apps/kunden_app/test/integration/order_flow_test.dart
import class="code-string">'package:kunden_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">'laedt Bestellungen vom 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>(),
]),
);
});
}Geteilte Packages isoliert testen
Eine entscheidende Regel: Tests geteilter Packages duerfen niemals von App-Code abhaengen. Wenn der `api_client`-Test etwas aus `kunden_app` importiert, hat man eine zirkulaere Abhaengigkeit in der Entstehung. Verwenden Sie Mocks und Fakes innerhalb des Packages selbst.
Monorepo vs. Multi-Repo: Wann was waehlen
Das ist keine Glaubensfrage. Jeder Ansatz passt zu unterschiedlichen Teamstrukturen und Projektrealitaeten.
Monorepo waehlen, wenn:
Multi-Repo waehlen, wenn:
Der hybride Ansatz: Manche Teams starten mit einem Monorepo und befoerdern stabile Packages mit der Zeit zu veroeffentlichten Packages. Das `core`-Package, das sich seit sechs Monaten nicht geaendert hat, kann eine versionierte Abhaengigkeit werden. Das `models`-Package, das sich mit jedem API-Update aendert, bleibt im Monorepo.
Haeufige Monorepo-Fehler
1. Gott-Package
Alles in ein einziges `shared`-Package zu packen verfehlt den Zweck. Wenn `shared` Modelle, API-Clients, Hilfsmittel, Theme-Daten und Analytics enthaelt, loest jede Aenderung einen Rebuild von allem aus. Gliedern Sie nach Domaene, nicht nach Bequemlichkeit.
2. Cross-App-Imports
App A sollte niemals Code aus App B importieren. Wenn beide Apps dasselbe Widget brauchen, verschieben Sie es ins `ui_kit`-Package. Cross-App-Imports erzeugen unsichtbare Kopplung und machen es unmoeglich, Apps unabhaengig zu bauen.
3. Inkonsistente Abhaengigkeitsversionen
Wenn `kunden_app` `dio: ^5.3.0` verwendet und `admin_app` `dio: ^5.4.0`, laden Sie subtile Laufzeitunterschiede ein. Zentralisieren Sie Versionseinschraenkungen oder erzwingen Sie Konsistenz ueber Melos-Dependency-Overrides.
4. Den Bootstrap-Schritt ueberspringen
Neue Teammitglieder werden das Repo klonen und `flutter pub get` im App-Verzeichnis ausfuehren. Das schlaegt fehl, weil Path-Dependencies nicht aufgeloest werden. Dokumentieren Sie, dass `melos bootstrap` der erste auszufuehrende Befehl ist, und erzwingen Sie es mit einem Setup-Skript.
5. Packages ueber Apps testen
Das `api_client`-Package zu testen, indem man die Kunden-App startet und herumklickt, ist keine Teststrategie. Jedes Package muss eigene Unit-Tests haben, die unabhaengig laufen. Die CI-Pipeline sollte das erzwingen.
6. Monorepo ohne Tooling
Ein Monorepo ohne Melos (oder vergleichbares Tooling) ist nur ein Ordner mit mehreren Pubspec-Dateien. Man verliert alle Vorteile -- selektives Testen, Skript-Orchestrierung, Abhaengigkeitsverwaltung. Investieren Sie vom ersten Tag an in das Tooling.
Schritt fuer Schritt loslegen
Wenn Sie ueberzeugt sind und Ihr erstes Flutter-Monorepo aufsetzen moechten, hier der minimale Weg:
# Workspace erstellen
mkdir mein_workspace && cd mein_workspace
dart pub init
# melos zu dev_dependencies hinzufuegen, dann:
dart pub get
# Struktur anlegen
mkdir -p apps/meine_app packages/core packages/models
# Packages initialisieren
cd packages/core && dart pub init && cd ../..
cd packages/models && dart pub init && cd ../..
# melos.yaml im Root erstellen (siehe Konfiguration oben)
# Alles bootstrappen
dart run melos bootstrapVon hier aus verschieben Sie geteilten Code schrittweise in Packages. Versuchen Sie nicht, alles auf einmal zu extrahieren. Beginnen Sie mit Modellen -- sie sind am einfachsten zu extrahieren und bieten den unmittelbarsten Nutzen.
Meiner Erfahrung nach wird ein gut strukturiertes Monorepo zum Rueckgrat eines Multi-App-Flutter-Projekts. Die initiale Einrichtung dauert ein bis zwei Tage. Die in den folgenden Monaten eingesparte Zeit misst sich in Wochen. Der Schluessel sind disziplinierte Package-Grenzen und die Investition in CI von Anfang an.
Wenn Sie eine Multi-App-Flutter-Architektur planen, lassen Sie uns gemeinsam besprechen, welche Struktur am besten zu Ihrem Team passt.
Verwandte Artikel
Clean Architecture in Flutter: Skalierbare Anwendungen entwickeln
Lernen Sie, Clean Architecture in Flutter praxisnah umzusetzen. Ein Leitfaden für Schichten, Dependency Management und testbaren Code.
Dart Best Practices: Sauberer und wartbarer Code
Lernen Sie praxiserprobte Dart-Methoden für lesbaren, testbaren und wartbaren Code.
Flutter CI/CD: Automatisierte Build-, Test- und Release-Pipeline
Entwerfen Sie eine robuste Flutter-CI/CD-Pipeline mit automatisierten Build-, Test- und Release-Schritten.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen