Zum Inhalt springen

Architekturleitfaden für Micro Frontends

Praktischer Leitfaden zu Micro Frontends: ein monolithisches Frontend mit Module Federation in unabhängig deploybare Teile zerlegen.

4 Min. Lesezeit
Diagramm, das mehrere unabhängige Frontend-Anwendungen zeigt, die zu einer einzigen Benutzererfahrung zusammengesetzt werden

Micro Frontends übertragen das Prinzip der Microservices auf das Frontend: Eine große, monolithische Benutzeroberfläche wird in kleinere Anwendungen aufgeteilt, die unabhängig voneinander entwickelt, getestet und deployt werden und sich zu einer einzigen Benutzererfahrung zusammensetzen. Jedes Team verantwortet einen vertikalen Ausschnitt des Produkts — von der UI-Komponente bis zum API-Aufruf — und kann Änderungen ausliefern, ohne die Deployments mit jedem anderen Team abstimmen zu müssen.

Der Preis dafür ist echte Komplexität. Man führt Komposition zur Laufzeit, anwendungsübergreifendes State-Management und eine mögliche Uneinheitlichkeit der Benutzererfahrung ein. Micro Frontends lösen organisatorische Skalierungsprobleme, keine technischen. Wer nur ein einziges Frontend-Team hat, braucht sie nicht.

Kompositionsstrategien

Es gibt mehrere Möglichkeiten, Micro Frontends zu komponieren. Die richtige Wahl hängt von den Performance-Anforderungen, der Teamstruktur und davon ab, wie eng die einzelnen Teile der Anwendung miteinander interagieren müssen.

tstypescript
// Strategy 1: Route-based composition
// Each route loads a different application entirely
// Simplest approach — applications are fully isolated
 
interface RouteConfig {
  path: string;
  appName: string;
  appUrl: string;        // Where the micro frontend is hosted
  activeWhen: (location: Location) => boolean;
}
 
const routes: RouteConfig[] = [
  {
    path: '/dashboard',
    appName: 'dashboard-app',
    appUrl: 'https://dashboard.cdn.example.com/main.js',
    activeWhen: (loc) => loc.pathname.startsWith('/dashboard'),
  },
  {
    path: '/settings',
    appName: 'settings-app',
    appUrl: 'https://settings.cdn.example.com/main.js',
    activeWhen: (loc) => loc.pathname.startsWith('/settings'),
  },
  {
    path: '/billing',
    appName: 'billing-app',
    appUrl: 'https://billing.cdn.example.com/main.js',
    activeWhen: (loc) => loc.pathname.startsWith('/billing'),
  },
];
 
// The shell app loads and mounts the right micro frontend
// based on the current route — like a frontend load balancer
tstypescript
// Strategy 2: Component-based composition with Module Federation
// Multiple applications can share components at runtime
// Webpack 5 Module Federation enables this natively
 
// webpack.config.js for the host (shell) application
const hostConfig = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        // Load components from other micro frontends at runtime
        dashboardApp: 'dashboard@https://dashboard.cdn.example.com/remoteEntry.js',
        billingApp: 'billing@https://billing.cdn.example.com/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};
 
// webpack.config.js for the dashboard micro frontend
const dashboardConfig = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'dashboard',
      filename: 'remoteEntry.js',
      exposes: {
        './DashboardWidget': './src/components/DashboardWidget',
        './MetricsPanel': './src/components/MetricsPanel',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};

Die Shell-Anwendung

Die Shell- (oder Host-)Anwendung stellt das gemeinsame Layout bereit — Navigation, Authentifizierung, globale Fehlerbehandlung — und orchestriert das Laden der Micro Frontends.

tstypescript
// Shell application — provides layout and orchestration
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
 
// Lazy-load remote micro frontends
const DashboardApp = lazy(() => import('dashboardApp/DashboardWidget'));
const BillingApp = lazy(() => import('billingApp/BillingPage'));
const SettingsApp = lazy(() => import('settingsApp/SettingsPage'));
 
function Shell() {
  return (
    <BrowserRouter>
      <div className="app-layout">
        <SharedNavigation />
 
        <main>
          <Suspense fallback={<LoadingSkeleton />}>
            <ErrorBoundary fallback={<MicroFrontendError />}>
              <Routes>
                <Route path="/dashboard/*" element={<DashboardApp />} />
                <Route path="/billing/*" element={<BillingApp />} />
                <Route path="/settings/*" element={<SettingsApp />} />
              </Routes>
            </ErrorBoundary>
          </Suspense>
        </main>
      </div>
    </BrowserRouter>
  );
}
 
// Error boundary prevents one crashed micro frontend from taking down the whole app
class ErrorBoundary extends React.Component<
  { children: React.ReactNode; fallback: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };
 
  static getDerivedStateFromError() {
    return { hasError: true };
  }
 
  componentDidCatch(error: Error) {
    console.error('Micro frontend error:', error);
    // Report to monitoring — which micro frontend failed?
  }
 
  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}
tstypescript
// ❌ No error isolation — one micro frontend crash kills everything
function ShellNoIsolation() {
  return (
    <Routes>
      <Route path="/dashboard/*" element={<DashboardApp />} />
      <Route path="/billing/*" element={<BillingApp />} />
      {/* If BillingApp throws, the entire page goes white */}
    </Routes>
  );
}
 
// ✅ Each micro frontend wrapped in its own error boundary
function ShellWithIsolation() {
  return (
    <Routes>
      <Route path="/dashboard/*" element={
        <ErrorBoundary fallback={<p>Dashboard unavailable</p>}>
          <Suspense fallback={<LoadingSkeleton />}>
            <DashboardApp />
          </Suspense>
        </ErrorBoundary>
      } />
      <Route path="/billing/*" element={
        <ErrorBoundary fallback={<p>Billing unavailable</p>}>
          <Suspense fallback={<LoadingSkeleton />}>
            <BillingApp />
          </Suspense>
        </ErrorBoundary>
      } />
    </Routes>
  );
}

Kommunikation zwischen Anwendungen

Micro Frontends müssen miteinander kommunizieren — die Navigation muss den Authentifizierungsstatus des Nutzers kennen, die Abrechnungsseite muss wissen, welches Team ausgewählt ist. Die zentrale Einschränkung: Die Kommunikation muss lose gekoppelt bleiben. Direkte Imports zwischen Micro Frontends unterlaufen genau diesen Zweck.

tstypescript
// Custom events for loose coupling between micro frontends
// Any micro frontend can emit or listen without importing another
 
interface AppEvent {
  type: string;
  payload: unknown;
  source: string;  // Which micro frontend emitted this
}
 
class EventBus {
  private listeners = new Map<string, Set<(event: AppEvent) => void>>();
 
  on(eventType: string, handler: (event: AppEvent) => void): () => void {
    if (!this.listeners.has(eventType)) {
      this.listeners.set(eventType, new Set());
    }
    this.listeners.get(eventType)!.add(handler);
 
    // Return unsubscribe function
    return () => this.listeners.get(eventType)?.delete(handler);
  }
 
  emit(event: AppEvent): void {
    const handlers = this.listeners.get(event.type) ?? [];
    for (const handler of handlers) {
      try {
        handler(event);
      } catch (error) {
        console.error(`Event handler error for "${event.type}":`, error);
      }
    }
  }
}
 
// Singleton event bus shared via window (or a shared module)
const eventBus = (window as any).__EVENT_BUS__ ??= new EventBus();
 
// Dashboard micro frontend emits team selection
eventBus.emit({
  type: 'team:selected',
  payload: { teamId: 'team-123', teamName: 'Platform' },
  source: 'dashboard-app',
});
 
// Billing micro frontend listens for team changes
eventBus.on('team:selected', (event) => {
  const { teamId } = event.payload as { teamId: string };
  loadBillingForTeam(teamId);
});

Gemeinsames Design-System

Visuelle Konsistenz über Micro Frontends hinweg erfordert ein gemeinsames Design-System — eine Bibliothek aus Komponenten, Tokens und Styles, die jede Anwendung nutzt.

tstypescript
// @company/design-system — published as an npm package
// Each micro frontend imports from this shared library
 
// Versioned and published independently
// Teams upgrade on their own schedule (within a compatibility window)
 
// design-system/src/Button.tsx
interface ButtonProps {
  variant: 'primary' | 'secondary' | 'danger';
  size: 'sm' | 'md' | 'lg';
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
}
 
export function Button({ variant, size, children, ...props }: ButtonProps) {
  return (
    <button
      className={`ds-btn ds-btn--${variant} ds-btn--${size}`}
      {...props}
    >
      {children}
    </button>
  );
}
 
// design-system/src/tokens.ts — shared design tokens
export const tokens = {
  colors: {
    primary: '#2563eb',
    danger: '#dc2626',
    neutral: {
      50: '#f8fafc',
      900: '#0f172a',
    },
  },
  spacing: {
    xs: '0.25rem',
    sm: '0.5rem',
    md: '1rem',
    lg: '1.5rem',
  },
  borderRadius: {
    sm: '0.25rem',
    md: '0.5rem',
    full: '9999px',
  },
} as const;

Wann sich Micro Frontends lohnen

Micro Frontends bringen zusätzliche Komplexität mit sich. Der Nutzen entsteht durch organisatorische Unabhängigkeit — Teams können ausliefern, ohne aufeinander zu warten. Wenn deine Organisation drei oder mehr Frontend-Teams hat, die Deployment-Koordination zum Flaschenhals wird und unterschiedliche Teile der Anwendung unterschiedliche Release-Rhythmen haben, lohnt es sich, Micro Frontends in Betracht zu ziehen.

Bei nur einem Team ist ein gut strukturierter Monolith mit Code-Splitting einfacher und genauso wirksam.

Die wichtigsten Erkenntnisse

  1. Micro Frontends lösen organisatorische Probleme, keine technischen — sie ermöglichen unabhängige Team-Deployments, keine bessere Frontend-Architektur
  2. Wähle die Kompositionsstrategie nach dem nötigen Kopplungsgrad — routenbasiert für vollständige Isolation, Module Federation für gemeinsam genutzte Komponenten
  3. Die Shell-App stellt die gemeinsame Infrastruktur bereit — Navigation, Authentifizierung, Fehlerbehandlung und die Orchestrierung der Micro Frontends
  4. Verwende pro Micro Frontend eine eigene Error Boundary — eine abgestürzte Anwendung sollte nicht die gesamte Seite mit sich reißen
  5. Kommuniziere über Events, nicht über Imports — lose Kopplung über einen Event Bus bewahrt die Unabhängigkeit der Teams
  6. Teile ein Design-System, keinen Code — eine versionierte Komponentenbibliothek sorgt für visuelle Konsistenz ohne Kopplung zur Laufzeit
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX