cartXX - Deep Dive: Die Architektur hinter cartXX

Deep Dive: Die Architektur hinter cartXX

Einleitung: Das Problem der Domänenvielfalt

E-Commerce-Systeme werden traditionell für eine spezifische Domäne entwickelt. Ein Shop für physische Produkte hat andere Anforderungen als eine Buchungsplattform für Termine, eine Lizenzierungsplattform für Software oder ein Subscription-Management-System.

Die Praxis zeigt jedoch: Unternehmen verkaufen selten nur eine Art von Leistung. Ein Softwareanbieter verkauft Lizenzen und Schulungen und Support-Pakete. Ein Maschinenbauer verkauft Produkte und Ersatzteile und Wartungsverträge und Schulungen.

Die klassische Antwort darauf sind spezialisierte Systeme für jede Domäne – mit den bekannten Konsequenzen: Datensilos, Integrationsaufwand, inkonsistente Kundenerfahrung.

cartXX verfolgt einen anderen Ansatz: Abstraktion der Kaufhandlung von der Domäne.


Teil 1: Das Typsystem

1.1 Warum Typen statt Klassen?

Die erste Designentscheidung betrifft die Modellierung der verschiedenen Verkaufsobjekte. Zwei Ansätze stehen zur Wahl:

Ansatz A: Klassenbasierte Vererbung

Shopable (abstract)
├── Product
├── License
├── Subscription
├── Event
└── ...

Ansatz B: Typisierte Entitäten

Content + shopable_type = "product" | "license" | "subscription" | ...

cartXX wählt Ansatz B. Die Begründung liegt in der Natur von Content-Management-Systemen: Inhalte sind primär Inhalte – Texte, Bilder, Metadaten. Die Kaufbarkeit ist eine Eigenschaft, kein definierendes Merkmal.

Ein Blog-Artikel bleibt ein Blog-Artikel, auch wenn er einen Download-Link enthält, der 9,90 € kostet. Die Vererbungshierarchie würde hier künstliche Grenzen ziehen.

1.2 Die Typ-Definition

Jeder Typ wird deklarativ definiert:

'product' => [
    'name' => 'Physisches Produkt',
    'fields' => ['sku', 'price', 'stock', 'weight', 'shipping_class'],
    'required' => ['price'],
    'supports' => ['variants', 'inventory', 'shipping'],
    'checkout_handler' => 'fulfillment',
],

Diese Definition enthält vier Dimensionen:

  1. Felder – Welche Daten sind relevant?
  2. Pflichtfelder – Was muss mindestens vorhanden sein?
  3. Fähigkeiten – Welche Subsysteme sind anwendbar?
  4. Checkout-Ziel – Wohin führt der Kaufprozess?

1.3 Die acht Grundtypen

Die Auswahl der Grundtypen basiert auf einer Analyse realer Geschäftsmodelle:

Typ Charakteristik Erfüllungslogik
product Physisch, versendbar Logistik, Fulfillment
license Digital, aktivierbar Key-Generierung, Freischaltung
subscription Wiederkehrend, zeitbasiert Payment-Provider, Abo-Verwaltung
event Zeitpunkt, Kapazität Buchungssystem, Kalender
download Digital, sofort verfügbar Secure Link, Download-Zähler
service Individuell, beratungsintensiv CRM, Terminierung
rental Zeitraum, Verfügbarkeit Buchungssystem, Kaution
inquiry Kein direkter Kauf Lead-Management

Diese Typen sind nicht willkürlich gewählt. Sie repräsentieren fundamentale Unterschiede in der Erfüllungslogik – dem Prozess, der nach dem Checkout stattfindet.

1.4 Erweiterbarkeit

Das Typsystem ist offen für Erweiterungen. Ein neuer Typ benötigt:

  1. Einen Eintrag in config/types.php
  2. Optional: Einen spezialisierten Checkout-Handler
  3. Optional: Ein Import-Mapping

Keine Schemaänderung, keine Migration, keine Code-Änderung in bestehenden Komponenten.


Teil 2: Das Preismodell

2.1 Das Problem der Vergleichbarkeit

Subscription-Modelle stellen Kunden vor ein Vergleichbarkeitsproblem:

Welches Angebot ist günstiger? Die Antwort erfordert Kopfrechnen. Und Kopfrechnen ist Friction.

2.2 Die Normalisierung auf Monatspreise

cartXX normalisiert alle Preise auf einen gemeinsamen Nenner: den effektiven Monatspreis.

public function getPricePerMonth(): ?float
{
    if ($this->billingCycle === self::CYCLE_ONCE) {
        return null; // Einmalzahlung: kein Monatspreis
    }
    
    $months = $this->getMonthsInCycle();
    return round($this->price / $months, 2);
}

Die Entscheidung, für Einmalzahlungen null zurückzugeben statt einen amortisierten Wert zu berechnen, ist bewusst: Eine Dauerlizenz über 36 Monate zu amortisieren wäre eine willkürliche Annahme. Besser keine Information als irreführende Information.

2.3 Die Ersparnis-Berechnung

Die Ersparnis wird relativ zu einem Referenzpreis berechnet – typischerweise dem Monatspreis:

public function calculateSavings(float $referenceMonthlyPrice): void
{
    $monthlyPrice = $this->getPricePerMonth();
    
    if ($monthlyPrice >= $referenceMonthlyPrice) {
        $this->savingsPercent = 0;
        return;
    }
    
    $this->savingsPercent = round(
        ($referenceMonthlyPrice - $monthlyPrice) / $referenceMonthlyPrice * 100,
        1
    );
}

Beispielrechnung:

Plan Preis Monate €/Monat vs. 29 €/Mon
Monatlich 29 € 1 29,00 € 0%
Jährlich 290 € 12 24,17 € 16,7%
2-Jährlich 490 € 24 20,42 € 29,6%

2.4 Die Referenzpreis-Ermittlung

Wenn kein expliziter Referenzpreis definiert ist, ermittelt das System ihn automatisch:

private function findReferencePrice(): ?float
{
    // 1. Monatlichen Plan suchen
    foreach ($this->plans as $plan) {
        if ($plan->billingCycle === 'monthly' && $plan->isActive) {
            return $plan->price;
        }
    }
    
    // 2. Kürzesten Zyklus nehmen
    // ...
}

Die Priorisierung des Monatsplans ist keine technische Notwendigkeit, sondern eine UX-Entscheidung: Der Monatspreis ist der intuitivste Vergleichswert.


Teil 3: Der Warenkorb als Zustandsmaschine

3.1 Das CartItem-Modell

Ein Warenkorb-Eintrag ist mehr als eine Produkt-ID und eine Menge:

class CartItem
{
    public int $contentId;
    public int $quantity;
    public ?string $variantId;
    public ?string $planId;
    public array $context;
    public string $addedAt;
}

Das context-Array nimmt typ-spezifische Daten auf:

3.2 Heterogene Warenkörbe

Ein Warenkorb kann Einträge verschiedener Typen enthalten:

{
  "items": [
    {"type": "product", "content_id": 1, "quantity": 2},
    {"type": "subscription", "content_id": 2, "plan_id": "yearly"},
    {"type": "event", "content_id": 3, "context": {"date": "2025-02-15"}}
  ]
}

Dies erzeugt eine Herausforderung: Wie wird ein solcher Warenkorb abgewickelt?

3.3 Checkout-Routing

Der CheckoutRouter analysiert den Warenkorb und delegiert an spezialisierte Handler:

public function route(Cart $cart): array
{
    $handlers = [];
    
    foreach ($cart->getItemsByType() as $type => $items) {
        $handler = $this->resolveHandler($type);
        $handlers[$type] = $handler;
    }
    
    return $handlers;
}

Ein gemischter Warenkorb wird nicht als Sonderfall behandelt, sondern als Normalfall: Jeder Typ wird von seinem Handler verarbeitet, die Ergebnisse werden aggregiert.

3.4 Handler-Architektur

Handler implementieren ein gemeinsames Interface:

interface HandlerInterface
{
    public function handle(CheckoutResult $result): void;
}

Die bewusste Einfachheit dieses Interfaces ermöglicht maximale Flexibilität in der Implementierung. Ein Handler kann:


Teil 4: Content-as-Code

4.1 Das Speichermodell

cartXX speichert Produktdaten nicht in einer spezialisierten Produkttabelle, sondern in einer generalisierten Content-Tabelle:

CREATE TABLE content (
    id INT PRIMARY KEY,
    title VARCHAR(255),
    slug VARCHAR(255),
    body TEXT,
    
    -- Shop-Felder als Spalten
    shopable BOOLEAN,
    shopable_type VARCHAR(50),
    shopable_action VARCHAR(50),
    price DECIMAL(10,2),
    
    -- Flexibles Kontext-Feld
    context JSON,
    
    -- Metadaten
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

Die Entscheidung für eine flache Struktur mit JSON-Kontext statt normalisierter Tabellen ist pragmatisch begründet:

  1. Lesbarkeit: Ein SELECT * FROM content WHERE id = 1 liefert alle Daten.
  2. Flexibilität: Neue Felder erfordern keine Schema-Migration.
  3. Performance: Keine JOINs für die häufigsten Queries.

4.2 Import und Mapping

Das Import-System übersetzt externe Datenstrukturen in das interne Modell:

'mapping' => [
    'title' => ['source' => 'Produktname'],
    'price' => ['source' => 'VK-Preis', 'transform' => 'decimal'],
    'shopable_type' => ['default' => 'product'],
    'context.color' => ['source' => 'Farbe'],
]

Die Punkt-Notation (context.color) ermöglicht das Mapping in verschachtelte Strukturen ohne zusätzliche Konfiguration.

4.3 Transformationen

Transformationen konvertieren Eingabedaten in das erwartete Format:

Transformation Eingabe Ausgabe
decimal "49,90" 49.90
boolean "ja" true
date "15.02.2025" "2025-02-15"
split "rot,blau,grün" ["rot","blau","grün"]
slugify "Mein Produkt" "mein-produkt"

Teil 5: Varianten und Kombinatorik

5.1 Das Dimensionsproblem

Ein T-Shirt in 4 Farben und 5 Größen ergibt 20 Varianten. Ein Fahrrad mit 3 Rahmengrößen, 4 Farben und 2 Schaltungstypen ergibt 24 Varianten.

Die Frage ist: Wie werden diese Kombinationen verwaltet?

5.2 Zwei Ansätze

Ansatz A: Explizite Varianten Jede Kombination wird als eigene Zeile gespeichert.

Ansatz B: Kombinatorische Generierung Die Dimensionen werden separat gespeichert, Kombinationen werden berechnet.

cartXX unterstützt beide Ansätze:

// Explizit: Jede Variante einzeln
$variants = $manager->getVariants($contentId);

// Generiert: Matrix aus Dimensionen
$matrix = $manager->generateMatrix($contentId);

5.3 Die Varianten-Matrix

Die Matrix-Generierung ist eine kartesische Produktbildung:

Dimensionen:
  Farbe: [Rot, Blau]
  Größe: [S, M, L]

Matrix:
  Rot-S, Rot-M, Rot-L
  Blau-S, Blau-M, Blau-L

Jede Zelle der Matrix kann eigene Werte haben:


Teil 6: Die Intent-Ebene

6.1 Von Klicks zu Absichten

Die klassische E-Commerce-Interaktion ist navigationsbasiert:

Kategorie → Unterkategorie → Produkt → Variante → Warenkorb → Checkout

Jeder Schritt ist ein Klick. Jeder Klick ist Friction. Jede Friction ist ein potenzieller Abbruch.

Die Intent-Ebene abstrahiert diese Navigation:

"Ich brauche ein rotes T-Shirt in Größe M" → Warenkorb

6.2 Intent-Erkennung

Der IntentRecognizer analysiert natürlichsprachliche Eingaben:

public function recognize(string $input): Intent
{
    $tokens = $this->tokenize($input);
    $entities = $this->extractEntities($tokens);
    $action = $this->inferAction($entities);
    
    return new Intent($action, $entities);
}

Die Erkennung basiert auf:

  1. Entitätsextraktion: Produkte, Mengen, Eigenschaften
  2. Aktionsinferenz: kaufen, buchen, anfragen, informieren
  3. Kontextanreicherung: Was weiß das System bereits?

6.3 Konversationeller Checkout

Wenn die Eingabe nicht eindeutig ist, fragt das System nach:

User: "Ich brauche das rote Shirt"
System: "Welche Größe? S, M, L oder XL?"
User: "M"
System: "Rotes T-Shirt in M für 29 €. In den Warenkorb?"

Der Zustand dieser Konversation wird im IntentConversation-Objekt gehalten:

class IntentConversation
{
    public array $history = [];
    public array $entities = [];
    public ?string $pendingQuestion = null;
    public ?int $candidateContentId = null;
}

Teil 7: Architekturprinzipien

7.1 Keine Framework-Abhängigkeit

cartXX verwendet keine externen Frameworks. Die Begründung ist dreifach:

  1. Langlebigkeit: Frameworks haben Lebenszyklen. Code ohne Framework-Abhängigkeit überlebt Framework-Wechsel.

  2. Verständlichkeit: Ein ContentRepository mit PDO ist für jeden PHP-Entwickler lesbar. Ein Repository mit Doctrine, Eloquent oder Propel setzt Framework-Wissen voraus.

  3. Portabilität: cartXX läuft auf jedem PHP-fähigen Server. Keine Composer-Installation, kein Dependency-Management erforderlich.

7.2 Explizit vor implizit

Das System bevorzugt explizite Konfiguration vor Konvention:

// Explizit: Sichtbar in der Konfiguration
'checkout_handler' => 'fulfillment',

// Implizit: Versteckt in Namenskonventionen
// (ProductHandler für type="product")

Explizite Konfiguration erzeugt mehr Code, aber weniger Magie. Debugging wird einfacher, Onboarding schneller.

7.3 Daten über Verhalten

Wo möglich, werden Regeln als Daten statt als Code ausgedrückt:

// Daten: Deklarativ, änderbar, versionierbar
'billing_cycles' => [
    'monthly' => ['months' => 1, 'label' => 'Monatlich'],
    'yearly' => ['months' => 12, 'label' => 'Jährlich'],
]

// Code: Imperativ, kompiliert, versteckt
function getMonths($cycle) {
    switch ($cycle) {
        case 'monthly': return 1;
        case 'yearly': return 12;
    }
}

Fazit: Shopable Everything

cartXX ist keine Revolution, sondern eine Reduktion. Es reduziert die Komplexität verschiedener Verkaufsdomänen auf ein gemeinsames Modell:

Content + Typ + Preis + Handler = Shopable

Diese Reduktion ermöglicht:

Die Vision von cartXX ist nicht der perfekte Shop für eine Domäne. Es ist die Erkenntnis, dass die Domäne irrelevant ist – solange am Ende jemand etwas kaufen möchte.

Alles ist shopable. Die Infrastruktur sollte dem nicht im Weg stehen.


Diese Dokumentation beschreibt den Stand von cartXX zum Dezember 2025. Die Konzepte sind stabil, die Implementierung entwickelt sich weiter.