1. Der strategische Rahmen & Die Architektur
Im High-Traffic E-Commerce ist das Design System mehr als eine UI-Bibliothek; es ist das Betriebssystem der User Experience. Zwischen Oktober 2021 und August 2022 hatte ich als Senior Software Engineer (Frontend) bei MediaMarktSaturn (MMS) die technische Verantwortung, dieses Fundament zu modernisieren.
Die Herausforderung: Ein System zu bauen, das MediaMarkt.de (Rot/Leidenschaftlich) und Saturn.de (Blau/Technisch) bedient, ohne Code zu duplizieren, und dabei die strengen WCAG 2.1 Accessibility-Standards erfüllt.
1.1 Der Tech-Stack: Skalierung im Nx Monorepo
Wir setzten auf eine Architektur, die Modularität und Typsicherheit erzwingt:
- Nx Monorepo: Das Design System lebte als isolierte Library. Nx ermöglichte uns, Abhängigkeiten zu visualisieren und Caching für Builds zu nutzen. Änderungen an einer Komponente konnten sofort gegen Webshop, Checkout und App getestet werden.
- TypeScript: Bei hunderten Entwicklern ist
anykeine Option. Wir nutzten strikte Typisierung für Props und Theme-Interfaces, um Integrationsfehler zur Build-Zeit abzufangen. - Styled Components: Die Basis für unser White-Labeling. Über
ThemeProviderinjizierten wir markenspezifische Tokens (Farben, Spacing, Typografie) in die Komponenten. - Storybook & Chromatic: Dokumentation war unser Vertrag mit den anderen Teams. Chromatic sicherte uns gegen visuelle Regressionen ab – wenn ein Pixel im Saturn-Theme verrutschte, blockierte die Pipeline.
2: Die Evolution der Produktkachel – Architektur-Refactoring
Das Herzstück jedes Shops ist die Produktkachel. Unsere Analyse der bestehenden ProductTile offenbarte massive architektonische Schulden.
2.1 Status Quo: Die "Prop-Hölle" (Legacy Code)
Die alte ProductTile.tsx war ein klassischer Monolith. Sie versuchte, jede Business-Logik (Suche, Warenkorb, Empfehlungen) intern zu lösen.
Das Problem: Das Interface war auf über 50 Props aufgebläht. Um ein Element tief im Baum zu ändern, musste man Props durch 4-5 Ebenen reichen ("Prop Drilling").
// Legacy: ProductTile.interface.ts (Auszug)
// Ein Interface, das alles wissen muss -> Wartbarkeits-Albtraum
export interface ProductTileProps extends BorderProps {
product: SharedProduct;
brandPrefixLabel?: string;
calculatedBasePrice?: (price: PriceType, basePrice: BasePriceProps) => string | undefined;
dataSheetLabel?: string;
eelSize?: EnergyEfficiencySize;
elevationHover?: ElevationLevel;
energyEfficiencySlot?: JSX.Element | null;
expanded?: boolean;
isCompact?: boolean;
isDisabled?: boolean;
isSoldOut?: boolean;
layout?: ProductTileLayout; // 'listItem' | 'gridItem' | 'sponsored'
// ... +40 weitere Props
}
Die Implementierung war imperativ und starr:
// Legacy: ProductTile.tsx
export const ProductTile: FC<ProductTileProps> = (props) => {
// Versteckte Magie: Eine Hook entscheidet, welche riesige Sub-Komponente geladen wird
const LayoutComponent = useLayoutComponent(props.layout);
return (
<LayoutComponent
// Wir müssen ALLES weiterreichen. Spread-Operator macht den Datenfluss undurchsichtig.
brandPrefixLabel={props.brandPrefixLabel}
calculatedBasePrice={props.calculatedBasePrice}
isDisabled={props.isDisabled}
isSoldOut={props.isSoldOut}
// ... 50 Zeilen Prop-Passing
{...props}>
{props.children}
</LayoutComponent>
)
}
2.2 Der Paradigmenwechsel: React Compound Components
Wir haben die Komponente nicht refactored, sondern neu gedacht. Wir wechselten zum Compound Component Pattern.
- Prinzip: Parent (
CardProduct) stellt State bereit. Children (Title,Price) konsumieren ihn. - Vorteil: Keine Props mehr durchreichen. Deklarativer Code.
Der neue Core (Context Provider):
// Modern: CardProduct.tsx
// 1. Context für impliziten State definieren
const CardProductContext = createContext({
states: {} as CardProductStateProps,
grid: Grids.COLUMN,
});
// 2. Die Parent-Komponente
export const CardProduct: FC<CardProductProps> & CardProductElements = ({
grid = Grids.COLUMN,
states = {}, // Hier kommen globale Zustände rein (Disabled, Loading, SoldOut)
children,
}) => {
// Destructuring der States für die Wrapper-Logik
const { isDisabled, isSoldOut, isBlank, elevationHover, onClick } = states || {};
return (
<CardProductContext.Provider value={{ states, grid }}>
<Card
variant={isBlank ? 'seamless' : 'white'}
// Intelligente UI-Logik direkt im Core: Keine Elevation bei Disabled/SoldOut
elevationHover={isBlank || isDisabled || isSoldOut ? 0 : elevationHover}
disabled={isDisabled}
// Accessibility & UX: Klicks verhindern, wenn SoldOut
onClick={!isSoldOut ? onClick : undefined}
data-test="mms-product-card">
{/* Layout wird komplett an CSS Grid delegiert */}
<StyledGrid grid={grid}>{children}</StyledGrid>
</Card>
</CardProductContext.Provider>
)
}
3: Deep Dive Accessibility & White-Labeling
Die technische Architektur war Mittel zum Zweck. Das Ziel war Inclusive Design in einem Multi-Brand-System.
3.1 White-Labeling: Theming Architecture
Wie bedient man MediaMarkt (Rot) und Saturn (Blau) mit einer Codebasis? Wir nutzten Theme Modes.
Theme Definition (MediaMarkt):
Wir definierten semantische Farb-Token. Statt #df0000 nutzten wir color.product.price.
// ProductTile.theme.mm.ts
const productTileModeLight: ProductTileMode = {
color: {
// Spezifischer Grauwert für MM, der WCAG Kontrast erfüllt
raeeText: theme.light.color.grey6,
product: {
manufacturer: {
heading: { default: theme.light.color.grey8 },
text: theme.light.color.grey6,
},
title: {
heading: { default: theme.light.color.black },
},
},
// ...
},
}
Implementation in Styled Components:
Eine Helper-Funktion productTileTheme wählt zur Laufzeit das korrekte Theme aus dem Context.
// ProductTile.styled.tsx
// Wählt basierend auf dem globalen ThemeProvider das MM oder SE Theme
export const productTileTheme = (theme: Theme): ProductTileMode =>
componentTheme({ theme, componentThemeMM, componentThemeSE })
export const RaeeText = styled(CopyText)<{ isMarginEnabled: boolean }>`
${({ theme, isMarginEnabled }) => css``}
`
3.2 Accessibility (WCAG 2.1) im Detail
Barrierefreiheit war "eingebacken". Hier drei konkrete Beispiele aus dem Code:
1. Semantische Ratings statt "Div-Suppe"
Visuelle Sterne sind für Screenreader unsichtbar. Unsere Komponente erzwingt ein ariaLabel.
// CardProduct.mock.tsx
export const Rating: RatingProps = {
// Screenreader liest dies vor, statt "Star Star Star"
ariaLabel: 'Bewertung: 4.5 von 5 Sternen',
value: 4.5,
description: '25 Rezensionen',
}
2. Intelligente "Sold Out" & "Disabled" Zustände
Wir verlassen uns nicht nur auf graue Farbe. Wenn der Context isSoldOut meldet, greifen CSS-Regeln, die auch die Interaktion für assistive Technologien unterbinden.
// CardProduct.styled.tsx
export const soldOut = css`
filter: grayscale(1); // Visueller Hinweis für Sehende
opacity: 0.5;
`
export const disabled = css`
${soldOut}
// Kritisch für A11y: Verhindert, dass Screenreader/Tastaturfokus hier landen
pointer-events: none;
`
3. Energieeffizienz & Datenblätter Die EU-Vorgaben für Energielabels sind strikt. Wir implementierten eine Logik, die Klicks auf das Logo (Overlay) und den Text (Datenblatt-PDF) sauber trennt und keyboard-accessible macht.
// ProductTileGridItem.tsx
// Nur rendern, wenn Daten da sind UND Produkt verfügbar ist (Vermeidung toter Links)
{showEnergyEfficiency && product.energyEfficiency && (
<ProductEnergyEfficiencyInfo
energyEfficiency={product.energyEfficiency}
dataSheetTitle={dataSheetLabel}
// Separate Event-Handler für verschiedene Ziele
handleDataSheetClick={(e) => handleCustomClicks(e, 'datasheet')}
handleLogoClick={(e) => handleCustomClicks(e, 'eel')}
hideLogo={isPermanentlyNotAvailable(onlineStatus)} // A11y: Kein Logo bei EOL Produkten
/>
)}
3.3 Automatisierte Qualitätssicherung
Vertrauen ist gut, CI/CD ist besser.
- Jest-Axe: Jeder Commit wurde gegen die WCAG-Regeln geprüft.
// ProductTile.test.tsx
it('has no A11y violations', async () => {
const { container } = render(<ProductTile {...rowProps} />)
// Dieser Test bricht den Build, wenn Labels fehlen oder Kontraste zu gering sind
expect(await axe(container)).toHaveNoViolations()
})
- Chromatic: Da wir Styles dynamisch injizieren, sind visuelle Regressionstests Pflicht. Chromatic renderte jede Story in MM-Light, MM-Dark, SE-Light und SE-Dark, um sicherzustellen, dass Accessibility-Fixes in Marke A nicht das Layout in Marke B zerstören.
4: Developer Experience (DX) & Integration
Das beste System nützt nichts, wenn Entwickler es falsch nutzen. Wir legten maximalen Wert auf DX.
4.1 Layout-Flexibilität durch CSS Grid Areas
Wir entkoppelten die DOM-Reihenfolge vom visuellen Layout. Über Enums definierten wir Grid Areas.
// CardProduct.interface.tsx
export enum CardProductGridAreas {
TITLE = 'title',
PRICE = 'price',
PRODUCTIMAGE = 'productimage',
BASKET = 'basket',
// ... sprechende Namen statt magische Zahlen
}
Das CSS Grid Template erlaubt radikale Layout-Änderungen (z.B. für den Warenkorb), ohne die Komponenten-Logik anzufassen.
// CardProduct.styled.tsx
// Layout für die Listenansicht / Warenkorb
const CartLayout = css`
grid-template-columns: fit-content(200px) 1fr 1fr;
grid-template-areas:
'${Areas.PRODUCTIMAGE} ${Areas.TITLE} ${Areas.TITLE}'
'${Areas.PRODUCTIMAGE} ${Areas.PRICE} ${Areas.EMPTY}'
'${Areas.PRODUCTIMAGE} ${Areas.AMOUNTPICKER} ${Areas.AMOUNTPICKER}' // Hier kommt der AmountPicker dazu
'${Areas.ACCORDION} ${Areas.ACCORDION} ${Areas.ACCORDION}';
gap: 0.5rem;
`;
4.2 Integration: Deklarativ & Typsicher
Dank des Compound Patterns ist der Integrations-Code selbsterklärend. Hier ein Beispiel, wie eine spezialisierte Warenkorb-Kachel gebaut wird.
// CardProduct.story.tsx
// Nutzung des 'Cart' Grids und Injection des AmountPickers
export const ExampleCart: Story = (args) => {
return (
<CardProduct {...args} grid={Grids.CART}>
{/* Area: Image - Links */}
<CardProduct.ProductImage src={src} />
{/* Area: Title - Oben Rechts */}
<CardProduct.Title title={title} />
{/* Area: Price - Mitte Rechts */}
<CardProduct.Price
prefix={prefix}
price={price}
currency={currency}
shippingInfo={shippingInfo}
/>
{/* Area: AmountPicker - Unten Rechts (Spezifisch für Cart) */}
<CardProduct.AmountPicker
amountPicker={{
min, max, quantity, onQuantityChange
}}
/>
{/* Area: Accordion - Ganz unten (Service Infos) */}
<CardProduct.Accordion entries={entries} />
</CardProduct>
)
}
Typsicherheit: Das CardProductElements Interface stellt sicher, dass Entwickler über CardProduct. Zugriff auf alle validen Sub-Komponenten haben (Autocompletion in VS Code).
4.3 Fazit & Lessons Learned
Nach 10 Monaten intensiver Architektur-Arbeit bei MediaMarktSaturn stehen drei Erkenntnisse fest:
- Architektur schlägt Geschwindigkeit: Der Wechsel auf Compound Components war initial aufwendiger als ein Hack, hat aber die Wartungskosten langfristig massiv gesenkt und die "Prop-Hölle" eliminiert.
- Inklusion ist Code-Qualität: Accessibility ist keine UX-Entscheidung, sondern eine Engineering-Disziplin. Durch die Integration von A11y in den Core (Context, Styled Components) wurde Barrierefreiheit zum Standard.
- Monorepo-Skalierung: Nx in Kombination mit Storybook und Chromatic war der Schlüssel, um ein Design System über hunderte Entwickler und zwei Marken hinweg konsistent zu halten.
Wir haben nicht nur eine Komponente neu geschrieben. Wir haben eine inklusive Sprache für das Frontend von MediaMarktSaturn entwickelt.



