Software Architektur Gepinnter Artikel

Sieben Routen, ein CMS, mehrere Marken: das Backend-for-Frontend-Muster im Enterprise-CMS

Von hyretic

Ein regionaler Energieversorger mit mehreren Marken, ein Redaktionsteam, ein Entwicklerteam. Im Lastenheft stehen zwei Welten, die angeblich nicht zusammenpassen: redaktionell pflegbare Marketing-Seiten und daneben transaktionale Strecken wie Tarifabschluss, Bestellstrecke und Anfrage-Rechner, angebunden an ein externes Vertriebs-SaaS und interne Backoffice-Systeme. Die Reflexantwort der Branche auf diese Mischung heißt Headless-SPA mit separatem API-Gateway. Geworden ist es ein CMS mit sieben eigenen Routen, und ich würde es heute wieder so bauen.

Key Takeaways (TL;DR)

  • BFF vor SaaS: Lass den Browser nie direkt mit einem Vertriebs-SaaS sprechen. Ein serverseitiger Proxy im CMS hält Credentials, Token-Handling und Vendor-Details vollständig aus dem Client.
  • Overrides statt if-Kaskaden: Schneide Mandanten als eigene Composer-Pakete und überschreibe Content-Strukturen, Komponenten-Templates und Settings dort, statt Markenlogik als Bedingungen im Basis-Code zu sammeln.
  • Integrationsmuster nach Antwortzeit wählen: Reiche synchron durch, wenn der Nutzer die Antwort des Fremdsystems sofort braucht. Persistiere lokal und exportiere asynchron, wenn das Zielsystem batch-orientiert arbeitet.
  • Texte gehören der Redaktion: Modelliere Fehlermeldungen, Rechtstexte und Filterwerte als redaktionelle Felder mit Defaults, dann ändert das Redaktionsteam Formulartexte ohne Deployment.
  • Prüfe Konfigurations-Hygiene, bevor du Endpunkte anbindest: Lege URLs und Secrets von Anfang an in Umgebungsvariablen und Kontext-Settings ab, nicht in Code oder Kommentaren.

Marketing-Seiten und Vertragsabschluss gehören hier in dasselbe System

Die Ausgangslage war unspektakulär und genau deshalb typisch: Der Versorger betreibt mehrere Marken-Sites, die sich Komponenten, Formulare und Rechner teilen, aber eigene Domains, eigene Styles und teils eigene Inhalte haben. Das Redaktionsteam pflegt alle Marken. Das Entwicklerteam ist eines, nicht drei.

Fachlich zerfällt die Anforderung in zwei Kategorien. Erstens klassischer Content: Landingpages, Magazin, Karriere, Suche. Zweitens Transaktionen: Ein Interessent gibt Postleitzahl und Verbrauch ein, bekommt Tarife aus dem Vertriebs-SaaS, schließt in einer mehrstufigen Bestellstrecke ab, und die Bestellung landet im SaaS. Dazu kommen Formulare, deren Ergebnis als PDF per E-Mail verschickt wird, und ein Anfrage-Rechner, dessen Datensätze ein Backoffice-System als CSV-Datei über SFTP abholt.

Wer diese zwei Kategorien auf zwei Systeme verteilt, baut zwei Deployments, zwei Sessions, zwei Fehlerquellen. Ich habe sie in eine Enterprise-CMS-Distribution gelegt (auf moderner PHP-8.2-Basis mit Composer-Platform-Pinning) und die Transaktionen als schmale API-Schicht im selben Prozess umgesetzt.

Die Gesamtarchitektur: ein Deployment, viele Marken

Das Herzstück ist eine einzige CMS-Distribution. Ein Basis-Modul (nennen wir es acme/platform-base) liefert alles Geteilte: gut hundert Content-Elemente, UI-Komponenten, SCSS, Vanilla-JS-Module, die PHP-Controller der API-Schicht und die Konfiguration. Pro Marke existiert ein eigenes Composer-Paket (acme/brand-a, acme/brand-b, dazu interne Pakete für Entwicklung und Styleguide), das vom Basis-Modul abhängt und gezielt überschreibt. Die zentrale composer.json bindet alle Pakete über ein Path-Repository auf ./packages/* ein.

flowchart LR
    subgraph Browser
        UI["Vanilla-JS-Module"]
    end
    subgraph CMS["Enterprise-CMS, ein Deployment"]
        TPL["Template-Engine & Components"]
        API["PHP-Controller hinter /api/*"]
        CR[("Content-Storage")]
        DB[("MySQL, eigene Entities")]
        CLI["CLI-Export-Command"]
    end
    SAAS["Vertriebs-SaaS: Tarife, Aufträge"]
    ES[("Elasticsearch")]
    BO["Backoffice, SFTP-Eingang"]
    UI -->|Same-Origin-Fetch| API
    TPL --> CR
    TPL --> ES
    API -->|Token-Login, REST| SAAS
    API --> DB
    API -->|liest Redaktions-Properties| CR
    CLI --> DB
    CLI -->|CSV per SFTP| BO

Laufzeitseitig ist jede Marke eine eigene Website mit eigener Domain, aber alle laufen auf derselben Codebasis. Die Trennung der Umgebungen und Datenbanken passiert über kontextbezogene Konfigurationsschichten: Pro Subdomain existiert eine Struktur nach dem Schema config/environments/{HTTP_HOST}/settings.yaml mit den jeweiligen Datenbank-Zugängen. Das Deployment (Deployer, angestoßen aus der CI-Pipeline des Git-Hostings: develop auf die Dev-Umgebung, Release-Branches auf Staging, GoLive als manueller Trigger) iteriert beim Rollout über genau diese Kontexte und führt Datenbank-Migrationen sowie Asset-Publishing je Mandant aus. Drei Releases bleiben auf dem Server liegen, Rollback ist ein Symlink-Wechsel.

Auch das Frontend-Build ist mandantenfähig geschnitten: eine Webpack-Config pro Paket. Das Basis-Paket baut site.js und site.css, jedes Marken-Paket baut aus seinem eigenen assets/js-Einstieg eine custom.js und custom.css. Das Hauptlayout des Marken-Pakets lädt beides nacheinander, Basis zuerst, Marke danach. Ein npm run build zieht alle Configs in einem Rutsch durch, für die lokale Arbeit gibt es pro Marke ein Watch-Script. Lokal läuft das Ganze in Docker Compose mit Apache/PHP, MySQL 8 und Elasticsearch.

Mandanten sind Composer-Pakete, keine if-Kaskaden

Das Multi-Tenant-Muster lebt von drei Schicht-Mechanismen, die ein modernes Enterprise-CMS und sein Framework von Haus aus mitbringen. Ich habe die Mandanten bewusst als eigene Composer-Pakete geschnitten, weil jede der drei Schichten dann eine definierte Override-Richtung hat: Das Basis-Paket definiert, das Marken-Paket überschreibt, und nichts davon steht in einer Bedingung.

Erstens Content-Strukturen. Das Basis-Paket definiert abstrakte Dokument-Schablonen, etwa eine OrderFunnelPage für die Bestellstrecke. Jedes Marken-Paket deklariert eine eigene, konkrete Variante, erbt vom Basis-Typ und kann dabei Properties vorbelegen. So sieht das im Marken-Paket aus (gekürzt und anonymisiert):

# acme/brand-a/config/content_types/order_funnel_page.yaml
OrderFunnelPage:
  parent: Page
  label: 'Bestellstrecke Marke A'
  sections:
    main:
      allowed_components:
        - OrderFunnel
  default_layout:
    main:
      - type: OrderFunnel
        settings:
          deliveryStartMaxFutureMonths: 12

Zwei Details daran tragen das Muster. Die Begrenzungsregeln (allowed_components) erlauben im Hauptbereich ausschließlich das Bestellstrecken-Element, ein Redakteur kann die Seite also gar nicht falsch zusammenbauen. Und die default_layout-Konstruktion legt beim Anlegen der Seite das Element automatisch an, inklusive markenspezifischer Vorbelegung wie dem maximalen Lieferbeginn. Die Fachlogik des Elements bleibt im Basis-Paket, die Marke steuert nur Parameter bei.

Zweitens Templates und Komponenten-Rendering. Jedes Paket bringt seine Template-Strukturen mit und wird automatisch im Rendering-Tree registriert. Marken-Komponenten erben von der Basis-Komponente und überschreiben nur, was abweicht. Im harmlosesten Fall ist ein Marken-Override eine einzige Zeile Vererbung, und genau so soll es sein.

Drittens Settings und Presets. Wiederkehrende Konfigurationen wie Überschriften-Editoren für das WYSIWYG-Interface liegen als Presets in einer zentralen editor_presets.yaml und werden in den Inhaltsfeldern nur noch referenziert. Das hält hundert YAML-Dateien konsistent, ohne dass jemand Editor-Optionen kopiert.

Die Alternative zu diesem Schichtenmodell wäre ein einziges Paket mit Marken-Weichen im Code gewesen: if ($brand === 'a'). Solche Kaskaden wachsen mit jeder Marke quadratisch und sind der Grund, warum Multi-Tenant-Projekte kippen. Das Paket-Modell zwingt jede Abweichung an eine deklarierte Stelle mit klarem Besitzer. Wo wir dieses Prinzip verletzt haben, hat es sich prompt gerächt, dazu unten mehr.

Der Browser spricht ausschließlich mit der eigenen Domain

Der wichtigste Baustein der Architektur ist die API-Schicht, und sie ist bewusst klein. In der routes.yaml des Basis-Pakets stehen sieben eigene Routen, sechs davon unter /api/*, die siebte liefert das zuletzt erzeugte Formular-PDF aus. Jede Route zeigt auf einen gewöhnlichen PHP-Controller (gekürzt und anonymisiert):

# acme/platform-base/config/routes.yaml
tariff_api:
  path: /api/tariff/{action}
  controller: Acme\Platform\Controller\TariffController
checkout_api:
  path: /api/checkout/{action}
  controller: Acme\Platform\Controller\CheckoutController
lead_api:
  path: /api/leads/{action}
  controller: Acme\Platform\Controller\LeadController

Damit diese Routen vor den CMS-eigenen Frontend-Routen greifen, werden sie in der Hauptkonfiguration des Systems mit höchster Routing-Priorität ganz am Anfang eingearbeitet. Das ist der gesamte Routing-Aufwand.

Das Muster dahinter ist ein Backend-for-Frontend im engsten Sinn: Das Frontend-JavaScript kennt genau eine Gegenstelle, nämlich die eigene Domain. Alle Aufrufe sind Same-Origin-Fetches. Der Browser kennt vom Vertriebs-SaaS exakt nichts: keine Domain, keinen Token, keinen API-Vertrag. Drei Problemklassen verschwinden dadurch strukturell statt durch Disziplin. CORS-Konfiguration entfällt, weil es keinen Cross-Origin-Request gibt. Secret-Leaks ins Frontend entfallen, weil Credentials den Server nie verlassen. Und die Vendor-Kopplung des Clients entfällt, weil ein SaaS-Wechsel nur die Controller trifft, nicht die ausgelieferten Bundles.

Die Gegenseite der SaaS-Anbindung lebt vollständig in den Applikations-Settings. Pro Mandant und Umgebung zeigen die Schlüssel auf andere Endpunkte, übergeben wird das Ganze per Dependency Injection (gekürzt und anonymisiert):

# acme/platform-base/config/settings.yaml
acme_platform:
  tariff_api:
    base_url: 'https://brand-a.pricing-provider.example/api/v1/'
    auth_url: 'https://brand-a.pricing-provider.example/api/auth/login'
    username: 'ws-website-brand-a'
    password: '%env(TARIFF_API_SECRET)%'
    query_defaults:
      energyType: electricity
      customerType: private
      consumption: 4000
  checkout_api:
    base_url: 'https://brand-a.pricing-provider.example/api/v1/'
    supplier_blacklist: 'brand-a'

Weil das Framework Konfigurationen hierarchisch mergt, ist das Settings-Binding gleichzeitig der Mandanten-Schalter: Dieselbe Controller-Klasse bedient jede Marke, nur die injizierten Parameter unterscheiden sich pro Kontext. Ein Detail wie supplier_blacklist zeigt, wie fachlich diese Schicht denkt: Beim Lieferantenwechsel-Formular filtert der Server die eigene Marke aus der Vorlieferanten-Liste des SaaS, bevor sie den Browser erreicht.

An dieser Stelle gehört das stärkste Gegenargument auf den Tisch: Headless mit separatem Frontend entkoppelt Releases und skaliert auf mehrere Teams besser. Das stimmt, und ab zwei getrennten Frontend- und Backend-Teams würde ich die Abwägung neu aufmachen. Hier gab es aber ein Entwicklerteam und ein Redaktionsteam. Die Kosten der Entkopplung (zweite Deploy-Pipeline, Content-API, Preview-Infrastruktur, doppeltes Error-Handling) wären voll angefallen, der Nutzen nicht. Die Release-Entkopplung, die tatsächlich gebraucht wurde, liefert das CMS selbst: Redakteure ändern Texte, Filterwerte und ganze Seitenstrukturen ohne Deployment, weil diese Dinge als Content modelliert sind.

Deep-Dive Tarifrechner: ein Proxy mit Meinung

Der Tarifrechner ist das Kernstück der Transaktionsseite und ein gutes Beispiel dafür, dass ein BFF-Proxy mehr ist als ein Durchreicher. Der Request-Fluss: Das Frontend sammelt Energieart, Kundentyp, Verbrauch und Postleitzahl ein und ruft die Same-Origin-Route auf. Der PHP-Controller holt sich per Token-Login Zugang zum Vertriebs-SaaS, fragt die Tarif-API ab, filtert und sortiert die Antwort serverseitig und liefert aufbereitetes JSON zurück.

Der Controller-Kern sieht so aus (gekürzt und anonymisiert):

// acme/platform-base/src/Controller/TariffController.php
class TariffController extends AbstractController
{
    private Client $httpClient;

    public function __construct(
        private readonly array $config,
        private readonly TokenStorage $tokenStorage
    ) {
        $this->httpClient = new Client(['base_uri' => $this->config['base_url'], 'timeout' => 2.0]);
    }

    public function ratesAction(Request $request): JsonResponse
    {
        $query = array_merge($this->config['query_defaults'], $request->getQueryParams());
        
        $response = $this->httpClient->request('GET', 'rates', [
            'headers' => ['Authorization' => 'Bearer ' . $this->tokenStorage->getToken($this->config)],
            'query'   => $query,
        ]);

        return new JsonResponse($this->filterRates(json_decode($response->getBody()->getContents()), $query));
    }
}

Der HTTP-Client bekommt seine Basis-URL aus den Settings und einen bewusst knappen Timeout von zwei Sekunden: Wenn das SaaS lahmt, soll der Rechner schnell in seinen Fehlerzustand fallen statt den PHP-Worker zu blockieren. Der Token wird über einen temporären Speicher vergeben und frisch gehalten. Das hält jeden Authenifizierungszustand aus der User-Session heraus und schützt vor Token-Expiry.

Interessanter als der Proxy selbst ist, woher seine Filterregeln kommen. Welche Tarife eine Marke anzeigen will, welche auf einer Blacklist stehen und wie viele Ergebnisse maximal erscheinen, entscheidet nicht der Code, sondern der Content: Der Rechner ist ein eigenständiges Inhalts-Element mit eigenen Inspector-Tabs im WYSIWYG-Backend für Einstellungen, Fehlermeldungen und Boni. Redakteure pflegen dort unter anderem Filter-Query-Werte, die das Frontend beim API-Aufruf mitschickt. Der Controller wiederum liest ergänzende Properties wie Tarif-Blacklists direkt aus der Inhaltsdatenbank, aufgelöst über eine eindeutige Element-ID (content_id), die das Frontend als Parameter mitgibt. Ein neuer Tarif erscheint, ein alter verschwindet, eine Marke ändert ihre Auswahl: alles ohne Deployment.

Das Frontend bleibt dabei bewusst dumm. Es kennt die Route, seine Formularwerte und den eigenen Redaktions-Kontext (gekürzt und anonymisiert):

// acme/platform-base/assets/js/tariff-results.js
const resultsRoot = document.querySelector('.tariff-results');
const contentId = resultsRoot?.dataset.contentId || '';

let url = `/api/tariff/rates?sort=priceAnnual&direction=asc`
    + `&energyType=${energyType}&customerType=${customerType}`
    + `&consumption=${consumption}&zip=${zip}&reason=${reason}`;

if (contentId) {
    url += `&contentId=${encodeURIComponent(contentId)}`;
}
if (filterQueryElectricity) {
    url += `&filterQuery=${encodeURIComponent(filterQueryElectricity)}`;
}

const response = await fetch(url);
if (!response.ok) {
    renderErrorState();
    return;
}
renderRates(await response.json());

Die contentId ist der Kniff, der CMS und API verbindet: Sie sagt dem Controller, in welchem redaktionellen Kontext die Anfrage entstand, damit serverseitig die Properties genau dieses Rechner-Elements gelten. Zwei Marken können denselben Controller mit völlig unterschiedlichen Regeln betreiben, ohne dass er sie kennt.

Gerendert wird der Rechner als wiederverwendbares Komponenten-Template, das die Redaktions-Properties einsammelt und als Data-Attribute beziehungsweise Unterkollektionen ans Markup gibt (gekürzt und anonymisiert):

# acme/platform-base/templates/components/tariff_calculator.yaml
TariffCalculatorComponent:
  context:
    isEditMode: 'cms.backend.is_edit_mode'
  properties:
    isHeroLayout: 'content.isHeroLayout'
    headline: 'content.headline'
    filterQueryElectricity: 'content.filterQueryElectricity'
  slots:
    energyTypes: 'content.energyTypes'
    serviceButtons: 'content.serviceLinks'

Die Energiearten sind dabei selbst Content-Elemente in einem eigenen Bereich des Moduls mit festen Validierungsregeln. Welche Tabs der Rechner einer Marke anbietet, ist damit ebenfalls eine redaktionelle Entscheidung, keine Codeänderung.

Deep-Dive Bestellstrecke: Fachlichkeit im Backend, Texte in der Redaktion, Zustand im Frontend

Die Bestellstrecke ist eine mehrstufige Abschlussstrecke: Lieferadresse, Vertragspartner, Zahlungsdaten, Bestätigung. Die Arbeitsteilung folgt einer klaren Linie, die ich für das übertragbarste Muster des Projekts halte.

Der Zustand lebt im Frontend. Ein Vanilla-JS-Modul von gut 3.000 Zeilen führt durch die Schritte, validiert Eingaben clientseitig und sammelt das Bestellobjekt zusammen. Zwischendaten holt es über dieselbe BFF-Schicht: Vorlieferanten, Anreden, Orte zur Postleitzahl und Straßen kommen über GET-Aufrufe wie /api/checkout/lookup?entity=streets, die IBAN-Prüfung läuft über einen eigenen Endpunkt gegen den Bankdaten-Check des SaaS. Am Ende schickt das Modul das komplette Bestellobjekt als JSON an den Server, der es nach Token-Login an den Auftrags-Endpunkt des SaaS übergibt. Der zugehörige Checkout-Controller ist bewusst schlicht: HTTP-Client mit Basis-URL aus den Settings, ein gecachter Token pro Request, eine Handvoll Feld-Getter, ein POST.

Die Texte leben im CMS, und zwar vollständig. Das Bestellstrecken-Element ist ein einziges Content-Element, das seine Felder aus neun wiederverwendbaren Trait-Modulen zusammensetzt: Vertragspartner, Lieferadresse, abweichende Adresse, weitere Person, Geschäftsdaten, Antragsinformationen, Checkbox- und Rechtstexte, Bestätigungsseite und Fehlermeldungen. Allein das Fehlermeldungs-Modul umfasst über 300 Zeilen YAML, weil jede einzelne Validierungsmeldung eine eigene redaktionelle Property mit Default ist (gekürzt und anonymisiert):

# acme/platform-base/config/content_types/traits/order_funnel_errors.yaml
OrderFunnelErrorsTrait:
  abstract: true
  fields:
    errorFirstName:
      type: text_single_line
      default: 'Bitte gib einen Vornamen an.'
      editor:
        label: Vorname
        group: error_messages
    errorCustomerNumber:
      type: text_single_line
      default: 'Die Kundennummer besteht aus neun Ziffern.'
      editor:
        label: Kundennummer
        group: error_messages

Das wirkt auf den ersten Blick wie YAML-Fleißarbeit, und ehrlich gesagt ist es das auch. Aber es ist die Fleißarbeit, die sich auszahlt: Wenn die Rechtsabteilung eine Checkbox-Formulierung nachschärft, ist das ein Redaktions-Edit im WYSIWYG-Inspector, kein Ticket, kein Deployment, kein Release-Fenster. Bei einer Strecke voller Rechtstexte ist genau das der Unterschied zwischen einem Tages- und einem Minutenvorgang.

Die Fachlichkeit lebt im Backend und im SaaS. Was ein gültiger Auftrag ist, entscheidet die Auftrags-API des Anbieters; der Controller reicht durch und übersetzt Fehler. Markenspezifische Regeln wie der maximale Lieferbeginn kommen als Schablonen-Preset aus dem Marken-Paket, wie oben gezeigt. Ein hübsches Detail am Rande: Die Gültigkeit von Promo-Codes prüft der Server gegen eine Property am Root-Element der Website. Das Redaktionsteam pflegt die gültigen Codes als kommaseparierte Liste im Backend, und der Client bekommt auf seine Anfrage nur ein {"valid": true} oder {"valid": false} zu sehen, niemals die Liste selbst.

Deep-Dive Anfrage-Rechner: erst speichern, dann exportieren

Das dritte Feature bricht bewusst mit dem Proxy-Muster der ersten beiden. Der Anfrage-Rechner nimmt Datensätze entgegen, die kein synchrones Gegenüber haben: Das Zielsystem ist ein Backoffice, das Dateien batchweise über SFTP importiert. Synchrones Durchreichen wäre hier nicht nur unmöglich, es wäre auch falsch, denn kein Nutzer soll auf einen Dateitransfer warten.

Also bekommt dieses Feature eine eigene Persistenz. Der Endpunkt /api/leads/submit nimmt JSON entgegen, baut daraus eine ORM-Entity und lässt sie vom internen Validierungs-Service gegen die Attribute der Entity validieren, bevor sie gespeichert wird. Die Validierungsregeln stehen damit genau einmal im System, direkt am Datenmodell (gekürzt und anonymisiert):

// acme/platform-base/src/Entity/LeadSubmission.php
#[ORM\Entity, ORM\Table(name: 'lead_submissions')]
class LeadSubmission
{
    #[ORM\Column(length: 255), Assert\Email]
    private ?string $email = null;

    #[ORM\Column(length: 255), Assert\Regex('/^[0-9]{9}$/')]
    private ?string $customerNumber = null;

    #[ORM\Column(length: 255)]
    private string $tenantId;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeInterface $exportedAt = null;

    public function markExported(): self
    {
        $this->exportedAt = new \DateTimeImmutable();
        return $this;
    }
}

Die beiden Spalten formId und tenantId machen die Tabelle mandantenfähig: Jedes Formular jeder Marke schreibt in dieselbe Tabelle, exportiert wird aber getrennt. Und exportedAt ist das gesamte Zustandsmodell des Exports. Kein Status-Enum, keine Queue, nur ein Zeitstempel, der null ist, solange der Datensatz das Haus nicht verlassen hat.

Den Export übernimmt ein CLI-Command, der per Cron läuft und sich per --dry-run gefahrlos testen lässt (gekürzt und anonymisiert):

// acme/platform-base/src/Command/LeadExportCommand.php
public function execute(string $formId = '', string $tenantId = '', bool $dryRun = false): void
{
    foreach ($this->collectExportTargets($formId, $tenantId) as $target) {
        $submissions = $this->repository->findUnexported($target['formId'], $target['tenantId']);
        if ($submissions === []) {
            continue;
        }

        $csv = $this->buildCsv($submissions);
        $fileName = sprintf('%s_%s_%s.csv', date('Ymd_His'), $target['tenantId'], $target['formId']);
        $sftp = $this->sftpConfigForTarget($target);

        if (!$dryRun) {
            // Upload unter temporärem Namen, danach Rename als Handover-Schutz
            $this->sftpService->uploadContent($csv, $fileName . '.temp', $sftp);
            $this->sftpService->rename($fileName . '.temp', $fileName, $sftp);

            array_walk($submissions, fn($s) => $this->repository->save($s->markExported()));
        }
    }
}

Der SFTP-Transfer selbst steckt in einem Service auf Basis von phpseclib. Zwei unscheinbare Details haben sich als die wichtigsten erwiesen. Erstens der Upload unter temporärem Namen mit anschließendem Rename: Der Import-Job der Gegenseite pollt das Verzeichnis, und ohne dieses Handover-Muster liest er früher oder später eine halbe Datei. Zweitens das Dateiformat: Das Backoffice erwartet Semikolon-getrennte Zeilen mit Windows-Zeilenenden in CP1252-Kodierung. Wer mit Legacy-Backoffice-Systemen integriert, verbringt mehr Zeit mit Encoding-Fragen als mit Architektur, und es ist besser, das vorher zu wissen.

Wann also welches Muster? Meine Faustregel aus diesem Projekt: Synchron durchreichen, wenn der Nutzer die Antwort des Fremdsystems im selben Moment braucht (Tarife, Bestellbestätigung) und das Fremdsystem eine Request-Response-API anbietet. Eigene Persistenz plus asynchronen Export, wenn das Zielsystem batch-orientiert ist, wenn Datensätze auch bei SaaS- oder Backoffice-Ausfall nicht verloren gehen dürfen, oder wenn der Abnehmer die Daten in einem Format will, das mit HTTP-Latenz nichts zu tun hat. Der Preis der zweiten Variante ist real: Migrationstabelle, Export-Zustand, Monitoring des Cron-Jobs. Er ist aber kalkulierbar, während ein synchroner SFTP-Upload im Request-Zyklus ein Verfügbarkeits-Risiko ohne Gegenwert wäre.

Querschnitt: abgesicherte Routen, Honeypots, Finisher und ein AOP-Notausgang

Vier Querschnittsthemen runden das Bild ab, jedes für sich klein, zusammen aber der Unterschied zwischen Prototyp und Betrieb.

Die API-Routen sind über eine zentrale Access-Control-Konfiguration abgesichert, und zwar als Whitelist auf Methoden-Ebene. Nur explizit aufgeführte Endpunkte sind für anonyme Besucher erreichbar, alles andere an den Controllern bleibt geschlossen (gekürzt und anonymisiert):

# acme/platform-base/config/access_control.yaml
security_rules:
  api_endpoints:
    - target: "Acme\\Platform\\Controller\\TariffController::ratesAction"
      access: PUBLIC
    - target: "Acme\\Platform\\Controller\\CheckoutController::*"
      methods: ["submit", "lookup", "verifyIban"]
      access: PUBLIC

Die Formular-Pipeline baut auf einer erweiterbaren Form-Builder-Bibliothek auf und nutzt deren Finisher-Kette als Erweiterungspunkt. Ein eigener PDF-E-Mail-Finisher rendert die Formulardaten mit dompdf, verschickt das PDF und legt es zusätzlich mit einem Session-Token und einer TTL von 30 Minuten ab; die siebte Route liefert es unter forms/pdf/latest mit Cache-Control: no-store zum Download aus. Ein Template-Helper reicht die Zusammenfassung der letzten Einreichung ins Rendering der Bestätigungsseite, mit einer harten Methoden-Whitelist im Zugriff. Spam-Schutz übernimmt ein Honeypot-Feld-Paket: Füllt ein Bot das unsichtbare Feld, bricht die Finisher-Kette vor E-Mail und Speicherung ab. Auch die Anbindung des Marketing-Automation-Dienstes für Newsletter-Anmeldungen ist nur ein weiterer Finisher plus ein schmaler HTTP-Client, dessen Basis-URL wieder aus den Settings kommt. Gleiches Spiel beim extern gehosteten Chatbot-Backend: eine Proxy-Route, ein Controller, kein Frontend-Kontakt zum Anbieter.

Die Suche läuft über Elasticsearch mit einem flexiblen Such-Adapter für die Inhaltsdatenbank. Erwähnenswert ist hier weniger die Anbindung als ein Reparatur-Muster: Der Adapter hatte in der frühen Phase des CMS eine Schwachstelle im Umgang mit mehrsprachigen Content-Strukturen. Statt einen Fork zu pflegen, fängt ein kleiner AOP-Aspekt (Aspect-Oriented Programming) mit einem Around-Advice genau die betroffene Methode ab und korrigiert das Verhalten, mit einem Kommentar, der den Rückbau nach dem Upstream-Fix anweist. AOP ist als Alltagswerkzeug zu Recht aus der Mode, als chirurgischer Notausgang für Fremdcode ist es Gold wert.

Was ich heute anders schneiden würde

Drei Punkte aus der echten Projektpraxis, alle schmerzhaft, alle lehrreich.

Erstens: Wir haben unser eigenes Override-Prinzip an einer Stelle verletzt, und genau dort tut es weh. Für eine Marke mit abweichender Tarif-Filterlogik landete die Sonderbehandlung nicht im Marken-Paket, sondern als Mandanten-Erkennung im Basis-Controller, die den Hostnamen und die Element-ID per String-Vergleich auf den Markennamen prüft und dann in einen markenspezifischen Filter-Service verzweigt. Das funktioniert, aber es ist exakt die if-Kaskade, die das Paket-Modell verhindern sollte, nur höflicher formuliert. Heute würde ich die Filter-Strategie pro Mandant in den Kontext-Settings deklarieren und im Basis-Paket nur noch die Registry dafür bereitstellen.

Zweitens: Konfigurations-Hygiene ist kein Selbstläufer, auch wenn das Muster steht. Die Settings-Injection war von Anfang an das erklärte Vorgehen, trotzdem stehen im Chatbot-Proxy Endpunkt und Client-Kennung hart im Code, direkt neben dem Kommentar, der verspricht, sie in die Settings zu verschieben. In den zentralen Settings wiederum lagen zeitweise auskommentierte Blöcke mit den Zugangsdaten anderer Mandanten zum Umschalten per Kommentarzeichen. Und die SFTP-Zugangsdaten des Lead-Exports sind als redaktionelle Felder modelliert, liegen also in Reichweite des Admin-Backends. Jede dieser Abkürzungen war im Moment ihres Entstehens bequem. Die Lehre: Secrets gehören von Tag eins in die Umgebungsvariablen und Kontext-Konfiguration der Umgebung, und ein Review-Veto auf jedes Credential außerhalb davon ist billiger als jede spätere Aufräumaktion.

Drittens: Der Tarif-Controller ist auf über 2.000 Zeilen gewachsen, weil Filterregeln als Namens-Heuristiken im Code leben. Ob ein Tarif für Gewerbekunden erscheint, entscheidet teilweise, ob sein Name bestimmte Wortbestandteile enthält. Solche Regeln beginnen als eine Zeile und enden als Filterkette mit Fallback-Pfaden, die niemand mehr vollständig im Kopf hat. Heute würde ich vom SaaS strukturierte Tarif-Attribute einfordern oder die Regeln als deklarative Konfiguration am Content-Typ modellieren, wo die einfacheren unter ihnen längst liegen.

In zehn Schritten zur eigenen Umsetzung

  1. Schneide ein Basis-Modul für alles Geteilte und je ein Composer-Paket pro Mandant; binde sie über ein Path-Repository in die Distribution ein.
  2. Definiere Dokument-Schablonen im Basis-Paket abstrakt und konkretisiere sie pro Mandant, inklusive Begrenzungsregeln und default_layout für vorbelegte Strukturen.
  3. Zentralisiere wiederkehrende Konfigurationen als Presets, bevor die YAML-Duplikate entstehen.
  4. Lege pro Mandant und Umgebung einen eigenen Applikations-Kontext mit eigener Settings-Datei und eigener Datenbank an; iteriere im Deployment über diese Kontexte.
  5. Richte eine Webpack-Config pro Paket ein: Basis-Bundle plus schlankes Mandanten-Bundle, nacheinander geladen.
  6. Baue für jede externe Integration eine Same-Origin-Route auf einen PHP-Controller; der Browser spricht nie direkt mit einem Drittsystem.
  7. Injiziere alle Endpunkte und Credentials über Dependency Injection aus den Kontext-Settings; lehne jedes Secret in Code, Kommentar oder im CMS-Backend im Review ab.
  8. Sichere die API-Routen über eine zentrale Access-Control-Konfiguration als Methoden-Whitelist ab und setze kurze HTTP-Timeouts auf alle ausgehenden Requests.
  9. Entscheide pro Integration bewusst zwischen synchronem Proxy und eigener Persistenz mit asynchronem Export; beim Export: Validierung am Datenmodell, Zustands-Zeitstempel, Upload mit Temp-Datei und Rename.
  10. Modelliere jeden Text, den Redaktion oder Rechtsabteilung ändern könnten, als Content-Field mit Default, nicht als String im Code.

Am Ende steht eine unbequeme, aber belastbare These: Für Websites, die gleichzeitig Marketing machen und Verträge verkaufen, ist die schmale, selbst kontrollierte API-Schicht im CMS kein Kompromiss zwischen Headless und Monolith, sondern die Architektur, die beiden ihre Lieblingsversprechen abnimmt, der SPA die saubere API und dem Monolithen das eine Deployment. Wer den Browser direkt mit seinem Vertriebs-SaaS reden lässt, hat keine Architekturentscheidung getroffen, sondern nur eine aufgeschoben.

Autor hyretic

Senior Full-Stack Developer mit Fokus auf stabiler Software-Architektur, pragmatischem Engineering und der Realität von KI im Entwickler-Alltag. Seine Wurzeln liegen im praktischen Lösen komplexer Probleme unter realen Bedingungen.

github.com/hyretic-dev

Ähnliche Artikel