Single-Tenant Multi-Instance Architektur - easySale¶
Konzept: Single-Tenant mit Shared Codebase
Datum: 24. Februar 2026
Version: 2.0 — Multi-Repo-Architektur
Status: Implementiert
📦 Update März 2026 — Multi-Repo-Architektur
Das Projekt verwendet jetzt separate Git-Repos für Core und Clients: - Core-Repo (
easysale-core): Enthält ERP Base, Shop Base, Shared, Functions - Client-Repos (easysale-client-<slug>): Je ein eigenes Repo pro Kunde - Clients referenzieren Core per Git-Dependency inpubspec.yaml- VS Code Multi-Root Workspace mit Core read-only für sichere Entwicklung - Auto-Deployment per GitHub Actions (Core-Push → Client-Rebuild)Die Grundprinzipien (Single-Tenant, Extensions, ClientConfig) bleiben gleich. Nur die Repository-Struktur und Dependency-Auflösung haben sich geändert.
⚠️ Wichtig: Jeder Kunde erhält eine komplett eigenständige Instanz (Single-Tenant).
Der gemeinsame Code wird nur zur Entwicklung und Wartung geteilt, nicht zur Laufzeit.
📋 Inhaltsverzeichnis¶
- Executive Summary
- Problemstellung & Ziele
- Lösungsansatz: Single-Tenant mit Shared Codebase
- Architektur-Übersicht
- Build & Deployment Process
- Erweiterungsmöglichkeiten
- Workflows & Szenarien
- Implementierungsplan
- Vor- & Nachteile
- Migration & Rollout
Executive Summary¶
🎯 Das Konzept in 30 Sekunden¶
Problem:
Jeder Kunde braucht eigene, isolierte App-Instanz. Aber: Bugfixes sollen nicht 10× manuell gemerged werden müssen.
Lösung:
Single-Tenant mit Shared Codebase
┌─────────────────────────────────┐
│ Ein Git Repository │
│ 80% gemeinsamer Core-Code │
│ 20% kundenspezifisch │
└───────────┬─────────────────────┘
│ Build & Deploy
▼
┌───────────────────────────────────────────────┐
│ Kunde A Kunde B Kunde C │
│ (Single-Tenant) (Single-Tenant) (...) │
│ ├─ Firebase A ├─ Firebase B ├─ Fireba │
│ ├─ App A ├─ App B ├─ App C │
│ └─ Daten A └─ Daten B └─ Daten C│
│ (isoliert) (isoliert) (...) │
└───────────────────────────────────────────────┘
Key Facts: - ✅ Single-Tenant: Jeder Kunde = Eigene Firebase + Eigene App + Eigene Daten - ✅ Multi-Repo: Core + separate Client-Repos (sichere Isolation) - ✅ Shared Code: 80% gemeinsam entwickelt & gewartet (per Git-Dependency) - ✅ Bugfix: 1× im Core fixen → automatisch für alle deploybar - ✅ Anpassbar: 20% kundenspezifisch über Extensions - ✅ Sicher: Totale Datenisolation (DSGVO-konform) + Core read-only
📋 Inhaltsverzeichnis¶
Problemstellung & Ziele¶
Aktueller Bedarf¶
- Pro Kunde eine eigenständige Instanz (ERP + Shop) - SINGLE-TENANT ✅
- Jeder Kunde hat komplett eigene Firebase-Instanz (eigene Datenbank, Auth, Functions)
- Jeder Kunde hat eigene Apple/Google-Umgebung (Bundle IDs, Zertifikate)
- Jeder Kunde wird separat deployed (unabhängige Releases)
- Datenisolation ist garantiert (keine gemeinsame Datenbank)
- Individuelle Kundenwünsche sollen möglich sein
- Bugfixes zentral für alle Kunden (trotz separater Deployments)
Herausforderungen¶
❌ Branch-per-Client Ansatz:
Probleme: - Bugfixes müssen in JEDEN Branch cherry-picked werden - Nach 6 Monaten divergieren die Branches - Fehleranfällig (Bugfix vergessen?) - Kein Code Reuse - Testing-Aufwand multipliziert sich
Ziele der Lösung¶
✅ Bugfixes zentral - 1x fixen, alle profitieren
✅ Individualisierbar - Kunden können Anpassungen bekommen
✅ Wartbar - Klare Trennung Core vs. Client-Spezifisch
✅ Skalierbar - 100 Kunden möglich
✅ Schnell aufsetzen - Neuer Kunde in < 1 Stunde
Lösungsansatz: Single-Tenant mit Shared Codebase¶
Grundprinzip: Isolation + Code-Sharing¶
Single-Tenant Deployment: - Jeder Kunde = Eigene Firebase-Projekt - Jeder Kunde = Eigene App-Deployment - Jeder Kunde = Eigene Datenbank (komplett isoliert) - Jeder Kunde = Eigene URLs, Domains, Zertifikate
Multi-Repo Shared Codebase Development: - 80% gemeinsamer Code → Im Core-Repo (entwickelt & gewartet) - 20% kundenspezifisch → In separaten Client-Repos (Extensions) - Build-Zeit: Code wird per Git-Dependency kombiniert - Deploy-Zeit: Jeder Kunde bekommt eigene Instanz
┌─────────────────────────────────────┐
│ Core Packages │
│ (Shared Business Logic & Models) │
│ │
│ - erp_core │
│ - shop_core │
│ - shared │
└─────────────────┬───────────────────┘
│
│ extends/customizes
▼
┌─────────────────────────────────────┐
│ Client Overlays │
│ (Customer-Specific Extensions) │
│ │
│ clients/ │
│ ├── client_a/ (80% re-use) │
│ ├── client_b/ (80% re-use) │
│ └── client_x_custom/ (Fork) │
└─────────────────────────────────────┘
Hybrid-Ansatz¶
Standard-Kunden (80%): - Nutzen Core + Config + Extensions - Feature Flags, Theme-Anpassungen - Kleine UI/Logic-Erweiterungen
Special-Kunden (20%): - Bekommen eigenen Fork wenn nötig - Für komplett andere Anforderungen - Bugfixes manuell mergen (nur für diese)
Architektur-Übersicht¶
Single-Tenant Deployment-Modell¶
Jeder Kunde erhält:
Kunde: Pharma AG
├── Firebase Projekt: "pharma-ag-prod"
│ ├── Firestore Database (eigene Daten)
│ ├── Authentication (eigene Users)
│ ├── Cloud Functions (eigener Code)
│ ├── Hosting: https://pharma-ag.web.app
│ └── Storage (eigene Dateien)
│
├── iOS App
│ ├── Bundle ID: com.pharmaag.erp
│ ├── App Store: Eigener Account
│ └── Push Certificates: Eigene APNs
│
└── Android App
├── Package: com.pharmaag.erp
├── Google Play: Eigener Account
└── FCM: Eigene Konfiguration
Kunde: Baumarkt GmbH
├── Firebase Projekt: "baumarkt-gmbh-prod"
│ ├── Firestore Database (EIGENE Daten - isoliert)
│ ├── Authentication (EIGENE Users - isoliert)
│ └── ...
└── ...
🔒 Totale Datenisolation: - Pharma AG kann NIEMALS Daten von Baumarkt sehen - Separate Firebase-Projekte = separate Datenbanken - Keine tenant_id Filter notwendig - DSGVO-konform durch physische Trennung
Verzeichnisstruktur¶
# Core-Repo (easysale-core):
easysale-core/
├── core/
│ ├── apps/
│ │ ├── erp_system/ # ERP Base App
│ │ └── shop_system/ # Shop Base App
│ ├── shared/ # Basis Models, Extensions, Utils
│ ├── functions/ # Cloud Functions
│ └── docs/ # Dokumentation
├── onboarding/
│ ├── create_client.sh # Neuen Client anlegen
│ ├── scripts/ # Deploy-Skripte
│ └── templates/ # Workflow-Templates
├── .github/
│ ├── workflows/notify-clients.yml # Auto-Notify bei Core-Push
│ └── client-registry.json # Welche Clients nutzen welche Version
└── melos.yaml # Monorepo Config
# Client-Repos (je eigenes Git-Repo):
easysale-client-pharma/
├── erp/ # Dünner Wrapper
│ ├── lib/
│ │ ├── main.dart # Entry Point (registriert ClientConfig)
│ │ ├── config/ # Client Config
│ │ ├── extensions/ # Model Extensions
│ │ ├── blocs/ # Custom BLoCs
│ │ └── pages/ # Custom UI
│ ├── assets/ # Client Assets
│ └── pubspec.yaml # Git-Dependency auf Core
├── firebase/ # Firebase Config
│ ├── .firebaserc
│ └── firebase.json
├── .github/workflows/auto-deploy.yml
├── .code-workspace # Multi-Root Workspace (Core read-only)
└── README.md
easysale-client-baumarkt/
└── ... # Gleiche Struktur
Build & Deployment Process¶
Von Shared Code zu separaten Instanzen¶
Development Time (Multi-Repo Shared Codebase):
# Core-Repo (read-only für Client-Entwicklung):
easysale-core/
├── core/apps/erp_system/ # Gemeinsamer Code
└── core/shared/ # Gemeinsame Models
# Client-Repos (eigene Git-Repos):
easysale-client-pharma/erp/ # + Pharma Extensions (git: dep auf Core)
easysale-client-baumarkt/erp/ # + Baumarkt Extensions (git: dep auf Core)
Build Time (Code-Kombinierung per Git-Dependency):
# Für Pharma AG
cd easysale-client-pharma/erp
flutter pub get # Holt Core per Git-Dependency
flutter build web --release
# Ergebnis: build/web/
# = erp_system + shared + pharma_extensions
# → Alles in EINEM Bundle
Deploy Time (Separate Instanzen):
# Pharma AG Deployment (im Client-Repo)
cd easysale-client-pharma/firebase
firebase deploy --project pharma-ag-prod --only hosting
# → https://pharma-ag.web.app
# Baumarkt Deployment (anderes Client-Repo)
cd easysale-client-baumarkt/firebase
firebase deploy --project baumarkt-gmbh-prod --only hosting
# → https://baumarkt-gmbh.web.app
# Oder: Automatisch per GitHub Actions (empfohlen)
# Push auf Core → notify-clients.yml → auto-deploy.yml pro Client
Runtime (Komplette Isolation):
┌─────────────────────────────────┐
│ pharma-ag.web.app │
│ ↓ │
│ Firebase: "pharma-ag-prod" │
│ Firestore: Pharma Daten │
│ Auth: Pharma Users │
└─────────────────────────────────┘
↕ KEINE Verbindung
┌─────────────────────────────────┐
│ baumarkt-gmbh.web.app │
│ ↓ │
│ Firebase: "baumarkt-gmbh-prod" │
│ Firestore: Baumarkt Daten │
│ Auth: Baumarkt Users │
└─────────────────────────────────┘
🎯 Wichtig zu verstehen: - Code teilen (Development) ≠ Daten teilen (Runtime) - Jeder Build ist eigenständig und vollständig - Keine Laufzeit-Abhängigkeiten zwischen Kunden - Wenn Pharma-AG down ist → Baumarkt läuft weiter
Erweiterungsmöglichkeiten¶
1. 📦 Model Extensions (customData)¶
Im Core: Model vorbereiten
// packages/shared/lib/models/article/article.dart
class Article extends EntityBase {
final String number;
final String name;
// ... Standard-Felder
/// Client-spezifische Erweiterungen
final Map<String, dynamic>? customData;
Article({
required this.number,
required this.name,
this.customData,
// ...
});
}
Im Client: Extension nutzen
// easysale-client-pharma/erp/lib/extensions/article_pharma_extension.dart
class ArticlePharmaData {
final String chargennummer;
final DateTime? verfallsdatum;
final String? zulassungsnummer;
factory ArticlePharmaData.fromMap(Map<String, dynamic> json) { ... }
Map<String, dynamic> toMap() { ... }
}
extension ArticlePharmaExtension on Article {
ArticlePharmaData? get pharmaData {
final data = customData?['pharma'];
return data != null ? ArticlePharmaData.fromMap(data) : null;
}
}
Verwendung:
// Client Code
final article = context.read<ArticlesBloc>().selectedArticle;
final chargennummer = article.pharmaData?.chargennummer ?? 'N/A';
2. 🎨 UI Overrides (Komponenten ersetzen)¶
Im Core: UI Override Service
// packages/erp_core/lib/services/ui_override_service.dart
class UiOverrideService {
final Map<Type, WidgetBuilder> _screenOverrides = {};
final Map<String, WidgetBuilder> _componentOverrides = {};
/// Screen komplett ersetzen
void registerScreenOverride<T>(Widget Function(BuildContext) builder) {
_screenOverrides[T] = builder;
}
/// Komponente ersetzen (z.B. Header, List Item)
void registerComponentOverride(String key, WidgetBuilder builder) {
_componentOverrides[key] = builder;
}
Widget buildScreen<T>(BuildContext context, Widget defaultScreen) {
return _screenOverrides[T]?.call(context) ?? defaultScreen;
}
Widget? buildComponent(String key, BuildContext context) {
return _componentOverrides[key]?.call(context);
}
}
Im Core: Template Page anpassbar machen
// apps/erp_template/lib/pages/articles/detail_page.dart
class ArticleDetailPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final uiOverride = context.read<UiOverrideService?>();
return Scaffold(
body: Column(
children: [
// Header überschreibbar
uiOverride?.buildComponent('article_detail_header', context)
?? EsDetailPageHeader(article: article),
// Content
_buildContent(),
],
),
);
}
}
Im Client: UI anpassen
// easysale-client-pharma/erp/lib/config/pharma_ui_config.dart
class PharmaUiConfig {
static void configure(UiOverrideService service) {
// Komplette Page ersetzen
service.registerScreenOverride<ArticleDetailPage>(
(context) => PharmaArticleDetailPage(),
);
// Oder nur Komponente ersetzen
service.registerComponentOverride(
'article_detail_header',
(context) => PharmaCustomHeader(),
);
service.registerComponentOverride(
'article_list_item',
(context) => PharmaArticleListItem(),
);
}
}
3. 🎯 BLoC Extensions (Business Logic)¶
3 Varianten:
Variant A: BLoC erweitern (Vererbung)¶
// easysale-client-pharma/erp/lib/blocs/pharma_articles_bloc.dart
// NEUE Events
class ValidateChargennummer extends ArticleEvent {
final String chargennummer;
ValidateChargennummer(this.chargennummer);
}
// NEUE States
class ChargennummerValidated extends BlocLoaded {
final bool isValid;
final String? errorMessage;
ChargennummerValidated({required this.isValid, this.errorMessage});
}
// ERWEITETER BLoC
class PharmaArticlesBloc extends ArticlesBloc {
PharmaArticlesBloc({
required super.articleFirebaseService,
required super.customerFirebaseService,
}) {
// Zusätzliche Event Handler
on<ValidateChargennummer>(_onValidateChargennummer);
}
// Originale Methode überschreiben
@override
Future<void> _onCreateArticle(
CreateArticle event,
Emitter<BaseBlocState> emit,
) async {
// ZUSÄTZLICHE Validierung
final pharmaData = event.article.customData?['pharma'];
if (pharmaData == null) {
emit(BlocOperationFailed('Pharma-Daten erforderlich!'));
return;
}
// Check: Chargennummer bereits vorhanden?
final exists = await _findByChargennummer(pharmaData['chargennummer']);
if (exists != null) {
emit(BlocOperationFailed('Chargennummer bereits vergeben!'));
return;
}
// Original Methode aufrufen
await super._onCreateArticle(event, emit);
// Zusätzliche Aktionen
await _notifyRegulatoryDepartment(event.article);
}
// Neue Event Handler
Future<void> _onValidateChargennummer(
ValidateChargennummer event,
Emitter<BaseBlocState> emit,
) async {
final exists = await _findByChargennummer(event.chargennummer);
emit(ChargennummerValidated(
isValid: exists == null,
errorMessage: exists != null ? 'Bereits vergeben' : null,
));
}
}
Variant B: Komplett neuer BLoC (zusätzlich)¶
// easysale-client-baumarkt/erp/lib/blocs/warehouse_bloc.dart
// Neues Feature: Lagerverwaltung
class WarehouseBloc extends Bloc<WarehouseEvent, WarehouseState> {
final WarehouseRepository warehouseRepo;
WarehouseBloc({required this.warehouseRepo}) : super(WarehouseInitial()) {
on<LoadWarehouseStock>(_onLoadWarehouseStock);
on<UpdateShelfLocation>(_onUpdateShelfLocation);
}
// Komplett eigene Logik
Future<void> _onLoadWarehouseStock(...) async { ... }
}
Variant C: BLoC Factory Pattern¶
// packages/erp_core/lib/blocs/bloc_factory.dart
abstract class BlocFactory {
ArticlesBloc createArticlesBloc({...});
OrderBloc createOrderBloc({...});
}
class DefaultBlocFactory implements BlocFactory {
@override
ArticlesBloc createArticlesBloc({...}) {
return ArticlesBloc(...);
}
}
// Client Factory
class PharmaBlocFactory implements BlocFactory {
@override
ArticlesBloc createArticlesBloc({...}) {
return PharmaArticlesBloc(...); // Eigener BLoC
}
}
4. ⚙️ Service Extensions¶
Im Core: Service Interfaces
// packages/erp_core/lib/services/article_service.dart
abstract class ArticleService {
Future<void> saveArticle(Article article);
ValidationResult validateArticle(Article article);
double calculatePrice(Article article, Customer customer);
}
class DefaultArticleService implements ArticleService {
@override
Future<void> saveArticle(Article article) async {
await FirebaseFirestore.instance
.collection('articles')
.doc(article.id)
.set(article.toMap());
}
@override
ValidationResult validateArticle(Article article) {
if (article.number.isEmpty) {
return ValidationResult.error('Artikelnummer fehlt');
}
return ValidationResult.ok();
}
@override
double calculatePrice(Article article, Customer customer) {
return article.basePrice;
}
}
Im Client: Service überschreiben
// easysale-client-pharma/erp/lib/services/pharma_article_service.dart
class PharmaArticleService extends DefaultArticleService {
@override
Future<void> saveArticle(Article article) async {
// Zusätzliche Pharma-Validierung
final pharmaData = article.customData?['pharma'];
if (pharmaData == null) {
throw Exception('Pharma-Daten fehlen!');
}
// Zusätzliche Collections
final batch = FirebaseFirestore.instance.batch();
batch.set(
FirebaseFirestore.instance.collection('articles').doc(article.id),
article.toMap(),
);
// Extra: Pharma-spezifisch
batch.set(
FirebaseFirestore.instance.collection('pharma_articles').doc(article.id),
{
'chargennummer': pharmaData['chargennummer'],
'verfallsdatum': pharmaData['verfallsdatum'],
'regulatoryStatus': 'APPROVED',
},
);
await batch.commit();
}
@override
ValidationResult validateArticle(Article article) {
// Original Validierung
final baseResult = super.validateArticle(article);
if (!baseResult.isValid) return baseResult;
// Pharma-spezifische Validierung
final pharmaData = article.customData?['pharma'];
if (pharmaData?['chargennummer']?.isEmpty ?? true) {
return ValidationResult.error('Chargennummer erforderlich');
}
return ValidationResult.ok();
}
@override
double calculatePrice(Article article, Customer customer) {
final basePrice = super.calculatePrice(article, customer);
// 15% Aufschlag für apothekenpflichtige Artikel
final pharmaData = article.customData?['pharma'];
final hasApothekenzulassung = pharmaData?['apothekenzulassung'] == true;
return hasApothekenzulassung ? basePrice * 1.15 : basePrice;
}
}
5. 🔌 Plugin System (komplette Feature-Module)¶
// easysale-client-automotive/erp/lib/plugins/automotive_plugin.dart
class AutomotivePlugin {
// Eigene Models registrieren
static void registerModels() { ... }
// Eigene Services
static void registerServices(GetIt di) {
di.registerSingleton<VehicleCompatibilityService>(...);
di.registerSingleton<TireSeasonService>(...);
}
// Eigene UI
static void registerUI(UiOverrideService ui) {
ui.registerComponentOverride('article_list_item',
(ctx) => AutomotiveArticleListItem());
ui.registerComponentOverride('article_additional_tabs',
(ctx) => VehicleCompatibilityTab());
}
// Eigene Routes
static void registerRoutes(Router router) {
router.addRoute('/vehicles', (ctx) => VehicleSearchPage());
router.addRoute('/tire-configurator', (ctx) => TireConfiguratorPage());
}
// Alles initialisieren
static void initialize(GetIt di, UiOverrideService ui, Router router) {
registerModels();
registerServices(di);
registerUI(ui);
registerRoutes(router);
}
}
6. 🎨 Theme & Styling¶
Im Client: Eigenes Theme
// easysale-client-pharma/erp/lib/config/pharma_theme.dart
class PharmaTheme {
static ThemeData get lightTheme {
return ThemeData(
primaryColor: Color(0xFF006D77), // Pharma Teal
colorScheme: ColorScheme.fromSeed(
seedColor: Color(0xFF006D77),
),
appBarTheme: AppBarTheme(
backgroundColor: Color(0xFF006D77),
),
// ... weitere Anpassungen
);
}
}
Warum Single-Tenant für easySale?¶
✅ Zwingende Gründe¶
1. Regulatorische Anforderungen - DSGVO: Daten müssen trennbar sein (Recht auf Datenexport/Löschung pro Kunde) - Branchen-spezifisch: Pharma hat andere Compliance als Baumarkt - Audit-Trail: Pro Kunde eigene Audit-Logs - Data Residency: Kunde kann fordern "Daten nur in Deutschland"
2. Sicherheit
// ❌ Multi-Tenant Risiko:
query.where('tenantId', isEqualTo: currentUser.tenantId);
// ☠️ Ein vergessener Filter = Komplettes Datenleck!
// ✅ Single-Tenant:
// Firestore Rules pro Projekt
// Kunde A kann PHYSISCH nicht auf Firebase B zugreifen
// Kein tenantId Filter nötig → kein Fehlerrisiko
3. Performance & Skalierung - Kunde A macht Black Friday Sale → beeinflusst Kunde B NICHT - Jeder Kunde kann unabhängig skalieren - Keine "Noisy Neighbor" Probleme
4. Customization-Freiheit - Kunde will eigene Firebase Functions? ✅ Kein Problem - Kunde will eigene Firestore Security Rules? ✅ Möglich - Kunde will eigene Domain/Subdomain? ✅ Einfach - Kunde will eigenes Backup-Schedule? ✅ Machbar
5. Verkauf & Vertragsgestaltung - Kunde kann eigenes Firebase-Konto verwenden (White-Label) - Klare Kostenzuordnung pro Kunde - Einfacher zu verkaufen: "Ihre eigene, dedizierte Instanz" - Premium-Pricing möglich durch Isolation
6. Disaster Recovery - Kunde A löscht versehentlich Daten? → Kunde B nicht betroffen - Rollback pro Kunde möglich - Backup-Strategie individuell anpassbar - Versionierung pro Kunde (Beta-Tester möglich)
⚠️ Nachteile ehrlich betrachtet¶
1. Höhere Infrastruktur-Kosten
Multi-Tenant: 1 × Firebase Blaze = €50/Monat für ALLE Kunden
Single-Tenant: 10 × Firebase Blaze = €50 × 10 = €500/Monat
Mitigation: An Kunden weitergeben (€50-100/Monat im SaaS-Preis)
2. Deployment-Aufwand
Multi-Tenant: 1 Deployment → alle Kunden aktualisiert
Single-Tenant: N Deployments → alle Kunden aktualisiert
Mitigation: CI/CD automatisiert (GitHub Actions Matrix)
Vorteil: Staggered Rollouts möglich (erst Testkunden, dann alle)
3. Monitoring-Komplexität
Multi-Tenant: 1 Dashboard für alles
Single-Tenant: 10 Dashboards zu überwachen
Mitigation: Firebase Admin SDK für aggregierte Dashboards
Vorteil: Klarere Fehler-Attribution ("Bug bei Kunde X")
💰 Kostenrechnung (reales Beispiel)¶
Annahme: 10 Kunden, mittlere Nutzung
Option A: Multi-Tenant
Firebase Blaze (shared): €100/Monat
Zusätzliche Entwicklung: €2000 (einmalig für tenant_id Logik)
Ongoing Security Audits: €500/Monat (Risiko-Mitigation)
────────────────────────────────────────────
Monatlich: €600
Einmalig: €2000
Option B: Single-Tenant (unser Konzept)
Firebase Blaze × 10: €500/Monat
CI/CD Automatisierung: €1000 (einmalig)
Monitoring Dashboard: €50/Monat
────────────────────────────────────────────
Monatlich: €550
Einmalig: €1000
Pro Kunde weiterberechnen:
SaaS Preis: €149/Monat
- Infrastruktur (€55): €94 Marge
- Support & Wartung: Inklusive
- Individuelle Anpassungen: Extra abrechenbar
Break-Even: Bei €149/Monat SaaS-Preis sind €55 Infrastruktur (37%) absolut vertretbar.
🎯 Fazit: Single-Tenant ist richtig für easySale¶
Gründe: 1. ✅ B2B-Kunden erwarten Isolation - "Shared Database" ist Verkaufshindernis 2. ✅ Compliance unumgänglich - DSGVO, Branchen-Spezifika 3. ✅ Individualisierung Verkaufsargument - "Maßgeschneidert für Sie" 4. ✅ Kosten weitergebbar - Im SaaS-Preis enthalten 5. ✅ Sicherheit > Kosten - Ein Datenleck zerstört Reputation 6. ✅ Wettbewerbsvorteil - Viele Konkurrenten haben Multi-Tenant (Risiko)
Was wir NICHT wollen:
❌ "Ups, wegen eines Bugs konnte Kunde A die Daten von Kunde B sehen"
❌ "Kunde X überlastet das System, alle sind langsam"
❌ "DSGVO-Auskunft dauert Wochen weil alles gemischt ist"
Was wir wollen:
✅ "Jeder Kunde hat garantiert isolierte Daten"
✅ "Skalierung pro Kunde individuell"
✅ "DSGVO-Auskunft per Knopfdruck (Firebase Export)"
✅ "Premium-Positionierung durch dedizierte Instanz"
Workflows & Szenarien¶
Szenario 1: Bugfix im Core¶
# 1. Bugfix im Core-Repo
cd easysale-core/core/apps/erp_system
# ... Code-Änderung ...
# 2. Testen
melos test:all # Alle Packages testen
# 3. Commit & Push
git add .
git commit -m "fix: Artikel-Validierung korrigiert"
git push
# 4. GitHub Actions (notify-clients.yml) triggert automatisch
# alle Client-Repos per repository_dispatch → auto-deploy.yml ✓
# Kein manuelles Cherry-Picking nötig!
Resultat: Alle Standard-Clients werden automatisch re-deployed.
Szenario 2: Client-spezifisches Feature¶
# Nur für Pharma AG: Chargennummer-Validierung
# 1. Im Client-Repo implementieren
cd easysale-client-pharma/erp/lib/blocs
# Erstelle pharma_articles_bloc.dart
cd easysale-client-pharma/erp/lib/pages
# Erstelle pharma_article_editor.dart
# 2. Nur diesen Client testen
cd easysale-client-pharma/erp
flutter test
# 3. Push auf Client-Repo → auto-deploy.yml baut und deployt
git push
Resultat: Nur Pharma AG hat das Feature, andere Clients unberührt.
Szenario 3: Neuer Kunde aufsetzen¶
# 1. Automatisches Setup (im Core-Repo)
./onboarding/create_client.sh
# Output:
# ✅ GitHub Repo easysale-client-neue_firma erstellt
# ✅ Flutter-App mit Git-Dependency auf Core angelegt
# ✅ Firebase-Projekte erstellt (dev + prod)
# ✅ .code-workspace mit Multi-Root + Core read-only
# ✅ GitHub Actions auto-deploy.yml konfiguriert
# ✅ client-registry.json aktualisiert
# 2. Client-Repo auschecken
cd ~/Development
git clone git@github.com:Tech-Schuppen/easysale-client-neue_firma.git
# 3. .code-workspace öffnen (Multi-Root mit Core read-only)
code easysale-client-neue_firma/.code-workspace
# 4. Client anpassen (optional)
cd easysale-client-neue_firma/erp/lib/config
# client_config.dart bearbeiten (Logo, Farben, etc.)
# 5. Push → auto-deploy.yml baut und deployt
git push
Zeitaufwand: < 1 Stunde für Standard-Client
Szenario 4: Kunde will krasse Änderungen¶
Beispiel: Automotive AG will komplett andere Artikel-Struktur
Entscheidung:
IF Änderung passt in Plugin-System:
→ Extension verwenden ✅
ELSE IF Änderung zu groß:
→ Eigener Fork für diesen Kunden
→ clients/automotive_custom/
→ Bugfixes manuell cherry-picken (nur für diesen)
Vorgehen:
# Option A: Plugin (bevorzugt)
cd easysale-client-automotive/erp
# Erstelle lib/plugins/automotive_plugin.dart
# Registriere in main.dart
# Option B: Fork (wenn nötig)
# Client pinnt auf eigenen Core-Branch:
# In pubspec.yaml des Clients:
dependencies:
erp_system:
git:
url: git@github.com:Tech-Schuppen/easysale-core.git
path: core/apps/erp_system
ref: client-automotive-custom # Eigener Branch
Implementierungsplan¶
Phase 1: Core-Vorbereitung (2-3 Tage)¶
1.1 Models erweitern
- [ ] customData zu Article hinzufügen
- [ ] customData zu Order hinzufügen
- [ ] customData zu Customer hinzufügen
- [ ] Migration für bestehende Daten
1.2 UI Override System
- [ ] UiOverrideService erstellen
- [ ] Article Detail Page anpassbar machen
- [ ] Article List Item anpassbar machen
- [ ] Order Detail Page anpassbar machen
1.3 BLoC Factory
- [ ] BlocFactory Interface erstellen
- [ ] DefaultBlocFactory implementieren
- [ ] BLoC Provider umstellen
1.4 Service Interfaces
- [ ] ArticleService Interface
- [ ] OrderService Interface
- [ ] CustomerService Interface
- [ ] Default Implementations
1.5 Client Config System
- [ ] ClientConfig Abstract Class
- [ ] DefaultClientConfig implementieren
- [ ] Config laden in App
Phase 2: Automatisierung (1-2 Tage)¶
2.1 Scripts (im Core-Repo)
onboarding/
├── create_client.sh # Client-Repo + Firebase Setup
├── scripts/ # Einzelne Deploy-Skripte
└── templates/ # Workflow-Templates für Client-Repos
2.2 Melos konfigurieren (Core-Repo)
# melos.yaml (im Core-Repo für koordinierte Builds/Tests)
packages:
- core/apps/**
- core/shared
scripts:
test:all:
run: flutter test
analyze:
run: flutter analyze
2.3 CI/CD Setup
- [x] notify-clients.yml im Core-Repo (triggert Client-Repos)
- [x] auto-deploy.yml Template für Client-Repos
- [x] client-registry.json für Versionszuordnung
- [ ] CLIENT_REPOS_PAT Secret im Core-Repo anlegen
Phase 3: Beispiel-Client (1 Tag)¶
3.1 Demo-Client "Pharma AG" erstellen - [ ] Client Struktur anlegen - [ ] Model Extension (Chargennummer, etc.) - [ ] BLoC Extension (Validierung) - [ ] UI Anpassung (Extra Tab) - [ ] Service Extension (Spezielle Speicherlogik)
3.2 Dokumentation - [ ] README für Clients - [ ] Beispiel-Code dokumentieren - [ ] Video-Tutorial aufnehmen
Phase 4: Migration (Nach Bedarf)¶
Bestehende Kunden migrieren:
# Für jeden bestehenden Kunden:
1. Client-Ordner anlegen
2. Spezifische Anpassungen identifizieren
3. In Extensions umwandeln
4. Testen
5. Deployen
Vor- & Nachteile¶
✅ Vorteile¶
| Aspekt | Vorteil | Impact |
|---|---|---|
| Wartbarkeit | Bugfixes zentral → alle profitieren | 🔥 Hoch |
| Code Reuse | 80% gemeinsamer Code | 🔥 Hoch |
| Skalierbarkeit | 100+ Clients möglich | 🔥 Hoch |
| Schnelligkeit | Neuer Client in < 1h | ⭐ Mittel |
| Testbarkeit | Core Tests → alle Clients getestet | 🔥 Hoch |
| Individualisierung | Trotzdem kundenspezifisch anpassbar | ⭐ Mittel |
| Klare Struktur | Core vs. Client klar getrennt | ⭐ Mittel |
⚠️ Nachteile¶
| Aspekt | Nachteil | Mitigation |
|---|---|---|
| Initiale Komplexität | Core muss Extension-Points haben | Einmalig, danach einfach |
| Overhead | Mehr Abstraktion als einfacher Branch | Lohnt sich ab ~5 Clients |
| Breaking Changes | Core-Änderungen betreffen alle | Semantic Versioning + Tests |
| Learning Curve | Team muss Architektur verstehen | Dokumentation + Training |
🔀 Vergleich: Branch vs. Core+Extension vs. Multi-Tenant¶
| Kriterium | Branch-Ansatz | Core+Extension (Single-Tenant) | Multi-Tenant |
|---|---|---|---|
| Setup-Zeit | ✅ Schnell (5 Min) | ⚠️ Einmalig aufwändiger | ⚠️ Komplex |
| Bugfixes | ❌ Manuell in jeden Branch | ✅ Automatisch für alle | ✅ Einmal für alle |
| Datenisolation | ✅ Total (separate Projekte) | ✅ Total (separate Projekte) | ❌ Logisch (tenant_id) |
| Sicherheit | ✅ Sehr hoch | ✅ Sehr hoch | ⚠️ Risiko bei Bugs |
| Performance | ✅ Isoliert | ✅ Isoliert | ❌ Shared Resources |
| DSGVO/Compliance | ✅ Einfach | ✅ Einfach | ⚠️ Komplex |
| Individualisierung | ✅ Total frei | ⭐ Über Extensions | ❌ Sehr limitiert |
| Wartbarkeit (5 Clients) | ⚠️ Noch ok | ✅ Sehr gut | ✅ Sehr gut |
| Wartbarkeit (50 Clients) | ❌ Unmöglich | ✅ Gut | ✅ Gut |
| Code Divergenz | ❌ Nach 6 Mon. komplett anders | ✅ Core bleibt gleich | ✅ Kein Problem |
| Testing-Aufwand | ❌ Jeder Branch einzeln | ✅ Core + Stichproben | ✅ Einmal |
| Kosten (Infrastruktur) | 💰💰💰 Hoch (N×Firebase) | 💰💰💰 Hoch (N×Firebase) | 💰 Niedrig (1×Firebase) |
| Ausfallsicherheit | ✅ Total isoliert | ✅ Total isoliert | ❌ Ein Ausfall = alle betroffen |
Unser Ansatz = Beste Balance:
- Single-Tenant Sicherheit & Isolation ✅
- Shared Codebase Wartbarkeit ✅
- Höhere Infrastruktur-Kosten in Kauf nehmen für Sicherheit & Compliance
📊 Entscheidungsmatrix¶
Wann Branch-Ansatz: - ✅ Nur 1-3 Kunden - ✅ Komplett unterschiedliche Apps - ✅ Keine gemeinsame Codebasis gewünscht
Wann Core+Extension: - ✅ 5+ Kunden (oder geplant) - ✅ 80% gemeinsame Funktionen - ✅ Regelmäßige Bugfixes - ✅ Langfristige Wartbarkeit wichtig
Unsere Empfehlung: Core+Extension mit Hybrid-Ansatz - Standard-Kunden → Core+Extension - Special-Cases → Fork erlaubt
Migration & Rollout¶
Rollout-Strategie¶
Empfohlenes Vorgehen:
Phase 1: Vorbereitung (1 Woche)
├── Core Extension Points einbauen ✓
├── Scripts & Tools vorbereiten ✓
└── Demo-Client als Proof of Concept ✓
Phase 2: Pilot (1-2 Wochen)
├── 1 bestehenden Kunden migrieren
├── Learnings sammeln
└── Dokumentation verbessern
Phase 3: Rollout (4-8 Wochen)
├── Weitere Kunden migrieren
├── Parallel: Neue Kunden im neuen System
└── Alte Branches deprecaten
Phase 4: Vollständig (3 Monate)
├── Alle Kunden migriert
├── Alte Branches archivieren
└── Nur noch Core+Extension
Migration-Checklist (pro Kunde)¶
Kunde: _________________
□ Firebase-Projekt analysiert
□ Kundenspezifische Anpassungen identifiziert
□ Models erweitert?
□ UI angepasst?
□ Business Logic geändert?
□ Eigene Features?
□ Client-Ordner angelegt
□ Firebase Config migriert
□ Anpassungen → Extensions umgewandelt
□ Model Extensions (/lib/extensions/)
□ UI Overrides (/lib/pages/)
□ BLoC Extensions (/lib/blocs/)
□ Service Extensions (/lib/services/)
□ Testing
□ Unit Tests
□ Integration Tests
□ UAT mit Kunde
□ Deployment
□ Staging deployed
□ Kunde getestet
□ Production deployed
□ Alter Branch archiviert
Best Practices¶
DO ✅¶
- Core schlank halten - Nur gemeinsame Funktionen
- Extension Points großzügig einbauen - Mehr ist besser
- Feature Flags nutzen - Für optionale Features
- Semantic Versioning - Breaking Changes klar kommunizieren
- Testing - Core Tests = alle Clients getestet
- Dokumentation - Jede Extension gut dokumentieren
- Code Reviews - Besonders bei Core-Änderungen
DON'T ❌¶
- Client-Code in Core - Niemals kundenbezogene Logik im Core
- Breaking Changes ohne Plan - Migration-Strategie erforderlich
- Zu viele Abstraktionen - Balance zwischen Flexibilität und Einfachheit
- Ungenutztes im Core - Entfernen wenn kein Client es braucht
- Direkte Firebase-Calls in UI - Immer über Services/BLoCs
- Hardcoded Werte - Immer über Config
- Unsichere customData - Validierung wichtig!
Nächste Schritte¶
Sofort¶
- Team-Meeting - Konzept mit allen durchgehen
- Entscheidung - Go/No-Go für Migration
- Priorisierung - Welche Clients zuerst?
Diese Woche¶
- Proof of Concept - Demo-Client aufsetzen
- Core erweitern - customData hinzufügen
- Scripts schreiben - create_client.sh
Nächste 2 Wochen¶
- Pilot-Migration - Einen Kunden migrieren
- Dokumentation - Developer Guide schreiben
- CI/CD Setup - Automatisches Deployment
Fragen & Antworten¶
F: Ist das Single-Tenant oder Multi-Tenant?¶
A: Definitiv Single-Tenant! Jeder Kunde bekommt: - ✅ Eigene Firebase-Instanz (eigene Datenbank) - ✅ Eigene App-URL / Domain - ✅ Eigene User-Accounts (getrennte Auth) - ✅ Eigene Deployment-Pipeline (eigenes Git-Repo + GitHub Actions) - ✅ Totale Datenisolation
Wir teilen nur den Quellcode (per Git-Dependency), nicht die Laufzeit-Umgebung.
F: Wenn es Single-Tenant ist, warum nicht einfach Branches?¶
A: Single-Tenant ≠ Separate Codebases!
Unser Ansatz:
Branch-Ansatz würde bedeuten:
Der Unterschied: Wir sparen uns die Mehrfach-Wartung von fast identischem Code!
F: Was wenn ein Kunde wirklich ALLES anders will?¶
A: Dann bekommt er einen Fork. Das Hybrid-Modell erlaubt beides. Für 80% der Kunden reicht Core+Extension, für die 20% Special-Cases gibt's Forks.
F: Wie gehen wir mit Breaking Changes im Core um?¶
A:
1. Semantic Versioning verwenden (Core-Branches: main, v1, v2)
2. Migrations-Guide schreiben
3. Deprecation Warnings
4. Clients können alte Version pinnen per ref: in pubspec.yaml
F: Kann ein Client mehrere Extensions kombinieren?¶
A: Ja! Ein Kunde kann gleichzeitig: - Model Extensions nutzen (Pharma-Felder) - UI überschreiben (eigenes Design) - BLoCs erweitern (eigene Validierung) - Services überschreiben (eigene Logik)
F: Was kostet die Migration?¶
A: - Core vorbereiten: 2-3 Tage - Scripts/Tools: 1-2 Tage - Pro Client migrieren: 2-4 Stunden - Bei 10 Kunden: ~1-2 Wochen Gesamt
F: Müssen alle Clients zur gleichen Zeit migriert werden?¶
A: Nein! Schrittweise möglich: - Neue Kunden → sofort im neuen System - Bestehende Kunden → nach und nach - Alte Branches → solange parallel bis alle migriert
Kontakt & Feedback¶
Dokumentations-Owner: Stefan Hafner
Datum: 24. Februar 2026
Review-Datum: _____
Feedback bitte an: [Team-Email/Slack-Channel]
Anhang¶
A: Beispiel-Code¶
Vollständige Beispiele sind in:
- clients/pharma_ag_demo/ - Demo-Implementation
- docs/examples/ - Code-Snippets
- docs/tutorials/ - Video-Tutorials
B: Technologie-Stack¶
- Monorepo: Melos (nur im Core-Repo)
- Multi-Repo: Separate Git-Repos per Client (Git-Dependencies)
- Frontend: Flutter (ERP + Shop)
- Backend: Firebase (Firestore, Functions, Auth)
- State Management: flutter_bloc
- DI: Provider / GetIt
- CI/CD: GitHub Actions (notify-clients + auto-deploy Pattern)
- Deployment: Firebase Hosting
C: Glossar¶
| Begriff | Bedeutung |
|---|---|
| Single-Tenant | Jeder Kunde hat eigene, isolierte Instanz |
| Multi-Instance | Viele separate Instanzen, ein Codebase |
| Multi-Repo | Separate Git-Repos für Core und Clients |
| Multi-Tenant | ❌ NICHT unser Konzept (viele Kunden, eine Instanz) |
| Core | Gemeinsame Basis-Funktionalität |
| Extension | Kundenspezifische Erweiterung |
| Overlay | Client-Layer über Core |
| Fork | Separater Branch für Special-Client |
| customData | Flexibles Daten-Feld für Extensions |
| Plugin | Komplettes Feature-Modul |
| Shared Codebase | Gemeinsamer Code, separate Deployments |
D: Single-Tenant vs. Multi-Tenant Klarstellung¶
❌ Was wir NICHT haben (Multi-Tenant):
Eine zentrale App/Datenbank
└── tenant_id: "kunde_a"
└── tenant_id: "kunde_b"
└── tenant_id: "kunde_c"
✅ Was wir haben (Single-Tenant):
Kunde A: Firebase A → App A → Daten A (isoliert)
Kunde B: Firebase B → App B → Daten B (isoliert)
Kunde C: Firebase C → App C → Daten C (isoliert)
💡 Der Trick: Wir teilen den Quellcode (Development & Maintenance), aber deployen separate Instanzen (Runtime).
Ende der Dokumentation