# Wie wir Webanwendungen bauen: die ersten drei Wochen und das Jahr danach

In diesem Jahr haben wir zwei sehr unterschiedliche Webplattformen mit derselben Methode ausgeliefert. Die eine ist eine Multi-Tenant-Storefront-Plattform für eine rumänische Handelsgruppe: Laravel, eine Datenbank pro Mandant, drei Admin-Panels, ein öffentlicher Shop pro Marke. Die andere ist eine Partner- und Auszahlungsplattform für einen US-Kunden: drei Next.js-Anwendungen auf AWS App Runner, eine TypeScript-API, DynamoDB und PostgreSQL nebeneinander, mit Geld, das jeden Monat hindurchfließt.

Unterschiedliche Stacks, unterschiedliche Länder, unterschiedliche Budgets. Die Art, wie wir sie bauen, ist identisch, und genau darum geht es in diesem Artikel. Nicht „wir sind agil" und keine Werkzeugliste, sondern was in den ersten drei Wochen tatsächlich passiert und was der Kunde im Jahr danach erwarten kann.

## Woche 0: Discovery, die in einem Repository endet

Die meisten Briefings, die wir erhalten, sind ein Absatz und ein Termin. Discovery sind ein bis zwei bezahlte Wochen, die aus diesem Absatz drei Dinge machen: ein Datenmodell, eine Liste von Umgebungen und einen ersten Meilenstein, der klein genug ist, um real zu sein.

Das Datenmodell ist der Teil, den man unterschätzt. Bei der Storefront-Plattform wurde die Entscheidung, dass jeder Mandant seine eigene Datenbank bekommt, in der Discovery-Phase getroffen, an einem Whiteboard, nach einer einzigen Frage: Wird ein Mandant jemals mit seinen Daten aussteigen müssen? Die Antwort war ja, also musste die Isolation physisch sein, keine `tenant_id`-Spalte. Diese eine Entscheidung prägte das ORM-Setup, die Admin-Panels, die Backup-Strategie und die Preisseite.

Bei der Partnerplattform lautete die entsprechende Frage: Welche Zugriffsmuster stehen für immer fest? Bestellabfragen nach Partner und Monat ändern sich nie, also wanderten sie mit einem Single-Table-Design in DynamoDB. Alles mit Reporting-Charakter, wo sich die Fragen jedes Quartal ändern, landete in PostgreSQL. Wir haben über diese Entscheidung geschrieben in [DynamoDB Single Table oder Postgres: wie wir uns entschieden haben](/de/blog/dynamodb-single-table-or-postgres-how-we-chose).

Discovery endet, wenn es ein Repository mit einem README gibt, einen leeren CDK-Stack (oder ein Laravel-Grundgerüst) und einen Meilenstein, den der Kunde in einem Satz beschreiben kann. Endet sie stattdessen mit einer Folienpräsentation, war es ein Verkaufsgespräch.

<div class="article-figure">
<svg viewBox="0 0 900 260" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Zeitleiste der ersten drei Wochen: Discovery, Grundgerüst in Produktion, erstes Feature, wöchentliche Inkremente">
<g font-family="Inter,system-ui,sans-serif">
<text x="40" y="34" fill="#f1f3ff" font-size="15" font-weight="700">Die ersten drei Wochen</text>
<line x1="40" y1="130" x2="860" y2="130" stroke="#2a3150" stroke-width="3"/>
<g>
<rect x="40" y="70" width="180" height="44" rx="8" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/>
<text x="130" y="97" text-anchor="middle" fill="#f1f3ff" font-size="13" font-weight="600">Woche 0 · Discovery</text>
<text x="130" y="160" text-anchor="middle" fill="#9aa3c7" font-size="12">Datenmodell · Umgebungen</text>
<text x="130" y="178" text-anchor="middle" fill="#9aa3c7" font-size="12">erster Meilenstein · Repo</text>
</g>
<g>
<rect x="250" y="70" width="180" height="44" rx="8" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/>
<text x="340" y="97" text-anchor="middle" fill="#f1f3ff" font-size="13" font-weight="600">Woche 1 · Gerüst live</text>
<text x="340" y="160" text-anchor="middle" fill="#9aa3c7" font-size="12">Auth · CI/CD · Dev/Staging/Prod</text>
<text x="340" y="178" text-anchor="middle" fill="#9aa3c7" font-size="12">Preview pro Pull Request</text>
</g>
<g>
<rect x="460" y="70" width="180" height="44" rx="8" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/>
<text x="550" y="97" text-anchor="middle" fill="#f1f3ff" font-size="13" font-weight="600">Woche 2–3 · Erstes Feature</text>
<text x="550" y="160" text-anchor="middle" fill="#9aa3c7" font-size="12">zentrales Domänenmodell</text>
<text x="550" y="178" text-anchor="middle" fill="#9aa3c7" font-size="12">echte Nutzer auf Staging</text>
</g>
<g>
<rect x="670" y="70" width="190" height="44" rx="8" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/>
<text x="765" y="97" text-anchor="middle" fill="#f1f3ff" font-size="13" font-weight="600" textLength="180" lengthAdjust="spacingAndGlyphs">Danach · Wöchentliche Inkremente</text>
<text x="765" y="160" text-anchor="middle" fill="#9aa3c7" font-size="12">Demo jeden Freitag</text>
<text x="765" y="178" text-anchor="middle" fill="#9aa3c7" font-size="12">Stundenbericht · Neupriorisierung</text>
</g>
<circle cx="130" cy="130" r="6" fill="#7b8cff"/>
<circle cx="340" cy="130" r="6" fill="#4fffb0"/>
<circle cx="550" cy="130" r="6" fill="#ffd166"/>
<circle cx="765" cy="130" r="6" fill="#ff6b8a"/>
<text x="450" y="230" text-anchor="middle" fill="#9aa3c7" font-size="12">Nichts auf dieser Zeitleiste ist ein Dokument. Jede Box läuft tatsächlich.</text>
</g>
</svg>
</div>

## Woche 1: Das Grundgerüst ist vor dem ersten Feature in Produktion

Das Erste, was wir bauen, ist kein Feature. Es ist der unspektakuläre Rahmen, an dem jedes Feature später hängt: Authentifizierung, Rollen, die drei Umgebungen, eine CI-Pipeline, die bei jedem Merge deployt, und eine Preview-Umgebung pro Pull Request. Am Ende der ersten Woche gibt es eine URL, bei der sich der Kunde einloggen kann. Sie tut noch nichts Nützliches, und genau das ist der Punkt: Jedes Stück Verkabelung, das einen Launch verzögern könnte, wurde bereits getestet.

Bei der Partnerplattform bedeutete das eine CDK-Codebasis mit drei AWS-Konten und GitHub Actions, die über OIDC-Rollen deployen, ohne langlebige Schlüssel irgendwo im System. Wir haben das Setup beschrieben in [drei AWS-Konten, eine CDK-Codebasis](/de/blog/aws-three-accounts-one-cdk-codebase). Bei der Storefront-Plattform bedeutete es ein Laravel-Grundgerüst mit eingerichtetem Tenancy-Paket, einem Super-Admin-Panel, einem Workspace-Panel pro Mandant und einem Deploy-Skript, das mit einem Befehl eine Mandanten-Datenbank erstellt.

Der Grund, warum wir auf diese Reihenfolge bestehen, ist einfach: Im Grundgerüst leben die Überraschungen. Build-Time- versus Runtime-Konfiguration in Next.js, Cookie-Domains über Subdomains hinweg, ein Health-Check, der „gesund" meldet, während die Datenbank unerreichbar ist. Wir begegnen diesen Dingen lieber in Woche eins, auf einer Seite, die „Hallo" sagt, als in der Woche des Launches.

## Die Wahl des Stacks: TypeScript standardmäßig, Laravel wenn es sich lohnt

Wir sind ein TypeScript-first-Team. Eine Node.js-API, ein React- oder Next.js-Frontend und PostgreSQL teilen sich eine Sprache, einen Linter, einen Test-Runner und einen Reviewer. Für ein Team aus zwei oder drei Personen ist das ein echter Geschwindigkeitsvorteil, und typisierte Verträge zwischen Frontend und Backend entfernen eine ganze Klasse von Fehlern, bevor sie überhaupt entstehen.

Wir setzen Laravel ein, wenn das Projekt besser dazu passt, und bei der Storefront-Plattform war das der Fall. Sie brauchte drei Admin-Panels mit Dutzenden CRUD-Bildschirmen, Datenbanken pro Mandant, Queues, geplante Jobs und eine CMS-artige Content-Ebene. Laravel mit Filament lieferte uns die Admin-Panels in Tagen statt Wochen, und das Tenancy-Paket übernahm den Datenbankwechsel, Migrationen pro Mandant und die Asset-Isolation, ohne dass wir ein eigenes Framework schreiben mussten. Hätten wir dort Next.js gewählt, hätten wir erst ein Admin-Framework bauen müssen, bevor wir das Produkt hätten bauen können.

Die Regel, die wir anwenden: Wähle den Stack, der das zweite Jahr günstig macht, nicht den, der die erste Demo schnell macht. Und schreibe den Grund dafür auf, damit ihn im sechsten Monat niemand neu verhandelt.

## Daten: standardmäßig unspektakulär, gezielt exotisch

PostgreSQL ist die Standardwahl. Es ist die Datenbank, die noch verstanden wird von jedem, der das System einmal übernimmt, und sie deckt die Reporting-Fragen ab, die während der Discovery niemand vorhersehen konnte. Zu DynamoDB greifen wir nur, wenn die Zugriffsmuster feststehen, der Umfang es rechtfertigt und jemand im Team bereit ist, das Single-Table-Design zu verantworten.

Zwei Lektionen aus diesem Jahr sind es wert, weitergegeben zu werden. Erstens: Wenn Geld im Spiel ist, friere jeden Kurs im Moment der Transaktion ein. Die Partnerplattform rechnet Bestellungen aus mehreren Währungen um; eine frühe Version wandte den aktuellen Wechselkurs zum Zeitpunkt der Auszahlung an statt den Kurs vom Bestelldatum, und ein Partner bemerkte eine Abweichung auf seiner Abrechnung. Wir haben die Berechnung korrigiert, die Historie nachträglich befüllt, und jetzt wird der Kurs in der Bestellzeile gespeichert und nie neu berechnet. Zweitens: Bei Nutzung einer Datenbank über eine HTTP-Daten-API sollte man damit rechnen, dass JSON-Spalten als Strings ankommen. Ein fünfzeiliger Helper hat uns ein Dutzend verstreute `JSON.parse`-Aufrufe erspart, samt der Fehler, die damit einhergehen.

## Woche 2 bis 12: sichtbare Inkremente

Ab Woche zwei ist der Rhythmus festgelegt. Jeder Pull Request bekommt eine Preview-Umgebung, sodass Review an einer laufenden Anwendung stattfindet statt an einem Diff. Jeden Freitag gibt es eine Demo auf Staging von dem, was diese Woche geändert wurde. Jeden Montag bekommt der Kunde einen Bericht der geleisteten Stunden, pro Aufgabe, mit dem Plan für die nächste Woche. Das Backlog kann der Kunde jederzeit neu ordnen.

Hier zahlt sich das stundenbasierte Time-and-Materials-Modell aus. Es gibt keinen fixen Scope zu verteidigen, also haben wir, als der Kunde der Partnerplattform mitten im Projekt einen Datenfeed anfragte, den seine Analysten in ihrem BI-Tool nutzen können, keinen Change Request eröffnet. Wir haben es geschätzt, sie haben es priorisiert, und zwei Wochen später war es live.

<div class="article-figure">
<svg viewBox="0 0 900 300" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Anfragepfad einer typischen von uns gebauten Anwendung: Browser, CDN und WAF, App-Runner- oder ECS-Container, API, PostgreSQL und DynamoDB, Queue und Worker">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="40" y="34" fill="#f1f3ff" font-size="15" font-weight="700">Was eine Anfrage durchläuft</text>
<rect x="40" y="110" width="120" height="50" rx="8" fill="#151b2e" stroke="#9aa3c7"/>
<text x="100" y="140" text-anchor="middle" fill="#f1f3ff">Browser</text>
<rect x="200" y="110" width="140" height="50" rx="8" fill="#151b2e" stroke="#7b8cff"/>
<text x="270" y="132" text-anchor="middle" fill="#f1f3ff">CloudFront + WAF</text>
<text x="270" y="150" text-anchor="middle" fill="#9aa3c7">Managed Rules</text>
<rect x="380" y="110" width="150" height="50" rx="8" fill="#151b2e" stroke="#4fffb0"/>
<text x="455" y="132" text-anchor="middle" fill="#f1f3ff">Container</text>
<text x="455" y="150" text-anchor="middle" fill="#9aa3c7">App Runner oder ECS</text>
<rect x="570" y="110" width="130" height="50" rx="8" fill="#151b2e" stroke="#ffd166"/>
<text x="635" y="132" text-anchor="middle" fill="#f1f3ff">Typisierte API</text>
<text x="635" y="150" text-anchor="middle" fill="#9aa3c7">Node.js oder Laravel</text>
<rect x="740" y="60" width="120" height="44" rx="8" fill="#151b2e" stroke="#ff6b8a"/>
<text x="800" y="87" text-anchor="middle" fill="#f1f3ff">PostgreSQL</text>
<rect x="740" y="118" width="120" height="44" rx="8" fill="#151b2e" stroke="#ff6b8a"/>
<text x="800" y="145" text-anchor="middle" fill="#f1f3ff">DynamoDB</text>
<rect x="740" y="176" width="120" height="44" rx="8" fill="#151b2e" stroke="#ff6b8a"/>
<text x="800" y="203" text-anchor="middle" fill="#f1f3ff">Queue + Worker</text>
<g stroke="#2a3150" stroke-width="2" fill="none">
<line x1="160" y1="135" x2="200" y2="135"/><line x1="340" y1="135" x2="380" y2="135"/><line x1="530" y1="135" x2="570" y2="135"/>
<line x1="700" y1="135" x2="740" y2="82"/><line x1="700" y1="135" x2="740" y2="140"/><line x1="700" y1="135" x2="740" y2="198"/>
</g>
<text x="455" y="200" text-anchor="middle" fill="#9aa3c7">lange Exporte streamen Zeile für Zeile: der Proxy davor schließt inaktive Antworten nach 120 s</text>
<text x="455" y="250" text-anchor="middle" fill="#9aa3c7">jede Box ist in CDK definiert; Dev, Staging und Prod sind derselbe Code mit unterschiedlichen Parametern</text>
</g>
</svg>
</div>

## Die Dinge, die zubeißen, und wie wir darum herum bauen

**Lang laufende Anfragen.** Die Partnerplattform exportiert Abrechnungen als CSV und XLSX. Die erste Version pufferte die gesamte Datei, und der Proxy vor App Runner schloss die Verbindung bei großen Monaten nach 120 Sekunden. Jetzt wird CSV zeilenweise gestreamt, sodass die Verbindung nie inaktiv wird, XLSX wird von einem Worker erzeugt und per Link zugestellt. Weitere Eigenheiten der Plattform haben wir behandelt in [App Runner nach sechs Monaten](/de/blog/aws-app-runner-review-six-months).

**Build-Time- versus Runtime-Konfiguration.** Next.js bettet öffentliche Variablen zur Build-Zeit ein. Ein Deploy, der nur eine Umgebungsvariable änderte, bewirkte nichts, und es dauerte einen Nachmittag zu verstehen, warum. Der Build nimmt seine Konfiguration jetzt explizit pro Umgebung entgegen, und das Deploy-Skript verweigert die Wiederverwendung eines Image-Tags für eine andere Konfiguration. Details in [Next.js Build-Time vs. Runtime-Konfiguration auf Containern](/de/blog/nextjs-build-time-vs-runtime-config-on-containers).

**Monorepos, die alles neu bauen.** Drei Anwendungen und ein gemeinsames Paket in einem Repository bedeuteten, dass jeder Commit drei Images neu baute. Wir haben die Pipeline so geändert, dass nur gebaut wird, was sich geändert hat, was die CI-Zeit um mehr als die Hälfte reduzierte. Siehe [Monorepo: drei Apps, nur bauen was sich geändert hat](/de/blog/monorepo-three-apps-build-only-what-changed).

**KI-gestützte Entwicklung.** Wir nutzen Claude Code täglich, und es schreibt einen echten Anteil von Routinecode, Tests und Infrastruktur. Es entscheidet nicht über das Datenmodell, die Sicherheitsgrenzen oder was deployt wird; ein Senior-Engineer prüft jede Zeile. Wir waren konkret über diese Aufteilung in [was Claude Code an unserer Infrastruktur geschrieben hat, und was wir abgelehnt haben](/de/blog/what-claude-code-wrote-of-our-infrastructure-and-what-we-refused).

## Das Jahr danach

Eine Webanwendung ist ein laufendes System, keine Lieferung. Wenn die Bauphase endet, passieren zwei Dinge. Wir übergeben eine Dokumentation, mit der ein neuer Engineer starten kann: Architekturnotizen, Entscheidungsprotokolle, das Runbook und die Deploy-Prozedur. Und der Kunde entscheidet sich zwischen einer sauberen Übergabe an sein eigenes Team oder einer Weiterbetreuung durch uns für ein paar Stunden im Monat, um das System zu betreiben und zu erweitern.

Beide Plattformen dieses Jahres haben sich für Letzteres entschieden. Die Storefront-Plattform bekommt jeden Monat neue Module, zuletzt ein Modul für Produktsteuern und dynamische Admin-Themes. Die Partnerplattform bekommt einen wöchentlichen Strom kleiner Fixes und Features, priorisiert vom Ticketboard des Kunden, jedes einzelne in Produktion verifiziert, bevor es geschlossen wird. Die Engineers sind dieselben Personen, die in Woche null die Discovery gemacht haben, und genau darum geht es.

Wenn Sie ein Produkt zu bauen haben und es auf diese Weise umgesetzt haben möchten, [sprechen Sie mit uns](/contact). Das erste Gespräch ist kostenlos; Discovery ist der Punkt, an dem die Uhr zu laufen beginnt.
