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:
- Felder – Welche Daten sind relevant?
- Pflichtfelder – Was muss mindestens vorhanden sein?
- Fähigkeiten – Welche Subsysteme sind anwendbar?
- 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:
- Einen Eintrag in
config/types.php - Optional: Einen spezialisierten Checkout-Handler
- 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:
- Monatsabo: 29 €/Monat
- Jahresabo: 290 €/Jahr
- 2-Jahres-Abo: 490 €/2 Jahre
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:
- Bei Events: gewähltes Datum, Teilnehmernamen
- Bei Rentals: Start- und Enddatum
- Bei Services: Anforderungsbeschreibung
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:
- Eine E-Mail senden
- Ein externes System aufrufen
- Eine Datenbankoperation ausführen
- Weitere Handler delegieren
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:
- Lesbarkeit: Ein
SELECT * FROM content WHERE id = 1liefert alle Daten. - Flexibilität: Neue Felder erfordern keine Schema-Migration.
- 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:
- Preis (Aufpreis für bestimmte Kombinationen)
- Bestand
- SKU
- Verfügbarkeit
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:
- Entitätsextraktion: Produkte, Mengen, Eigenschaften
- Aktionsinferenz: kaufen, buchen, anfragen, informieren
- 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:
Langlebigkeit: Frameworks haben Lebenszyklen. Code ohne Framework-Abhängigkeit überlebt Framework-Wechsel.
Verständlichkeit: Ein
ContentRepositorymit PDO ist für jeden PHP-Entwickler lesbar. Ein Repository mit Doctrine, Eloquent oder Propel setzt Framework-Wissen voraus.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:
- Einheitliche Kundenerfahrung über alle Produktarten
- Gemeinsamer Warenkorb für heterogene Angebote
- Zentrale Datenhaltung statt Silos
- Erweiterbarkeit ohne Grundsatzentscheidungen
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.