
Terminbuchungs-Prototyp
Ein Prototyp für Terminbuchung, den ich für einen Friseurbetrieb geschrieben habe. Kein Kundenprojekt — ich habe ihn gebaut, um auszuprobieren, wo Terminüberschneidungen verhindert werden sollten, und habe die Regel aus der Anwendungsschicht in eine Postgres-Exclusion-Constraint verlegt.
Buchungssysteme brechen an einer Stelle: Zwei Personen nehmen gleichzeitig denselben Termin bei derselben Fachkraft. Das ist kein Fehler, den man durch Szenarien entdeckt; er entsteht aus zwei sich überschneidenden Anfragen und zeigt sich beim Test mit nur einer Person nie. Die übliche Lösung lautet "erst lesen, bei frei schreiben" — und diese Lösung ist falsch, denn im Fenster zwischen Lesen und Schreiben kann eine andere Anfrage denselben Platz belegen. Der zweite Punkt ist die Zeit: Ein Buchungssystem muss Sommerzeitumstellung, Gerätezeitzone und Serverzeitzone gleichzeitig richtig behandeln. Dieses Projekt ist keine Kundenarbeit, sondern ein Prototyp, mit dem ich ausprobiert habe, wo beide Probleme hingehören.
Zwei Parteien. Die Kundschaft: kommt vom Telefon, wählt eine Leistung, sieht freie Zeiten und bucht ohne Konto; will sie später stornieren, braucht sie einen Link. Der Betrieb: verwaltet Personal, Leistungsdauern, Arbeitszeiten, Pausen und geschlossene Tage im Panel; verfolgt den Tag in einer Kalenderansicht und kann Termine manuell eintragen.
Einzelner Entwickler, der gesamte Prototyp. Datenmodell und Constraints, Verfügbarkeitsberechnung, Buchungs- und Stornoabläufe, Verwaltungspanel, Benachrichtigungsschicht.
Next.js 16 App Router, Neon Postgres und Drizzle. Der Schreibpfad ist Server Action → Zod → Transaktion. Die zentrale Entscheidung liegt im Datenmodell: Die Tabelle `appointments` trägt eine Exclusion-Constraint über Personal-ID und Terminintervall; die Constraint gilt nur für bestätigte Termine, sodass ein stornierter Datensatz keinen Platz blockiert. Die Verfügbarkeitsberechnung liegt vollständig getrennt von der Datenbank: Arbeitszeiten, Pausen, Termine je Fachkraft, Betriebssperren, Slot-Schritt, Mindestvorlauf und Buchungsfenster kommen als Eingabe herein, heraus kommt eine Liste freier Startzeiten. Da die Funktion rein ist, laufen ihre Tests ohne Datenbank. Auf der Zeitseite gilt eine Regel: Alles wird als zeitzonenbehafteter Zeitstempel in UTC gespeichert und in der Oberfläche nach Europe/Istanbul umgerechnet; Tagesgrenzen werden gegen den lokalen Tag berechnet, nicht gegen die Serverzeitzone.
Die Regel ist als Exclusion-Constraint definiert: Zwei sich überschneidende Zeitintervalle können für dieselbe Fachkraft nicht gleichzeitig existieren. Da die Prüfung beim Schreiben in der Datenbank stattfindet, verschwindet das Wettlauffenster zwischen Lesen und Schreiben. Der eigentliche Gewinn ist nicht nur Nebenläufigkeit, sondern Abdeckung: Webformular, manuell im Panel eingetragener Termin, Seed-Skript und ein von Hand abgesetztes SQL unterliegen alle derselben Regel. Eine Prüfung in der Anwendungsschicht schützt nur ihren eigenen Pfad.
KompromissDer Fehler kommt nun nicht als Validierungsmeldung, sondern als Verletzung einer Datenbank-Constraint; die aufrufende Seite muss sie abfangen und in einen verständlichen Satz übersetzen. Die Constraint verlangt zudem eine Index-Erweiterung für den Intervalltyp, und die Regel selbst steht in einer Migrationsdatei — wer den Code liest, findet sie nicht dort, wo er sie sucht: in der Anwendungsschicht.
Verfügbarkeit ist die Stelle mit den meisten Sonderfällen: geschlossene Tage, Pausen, Belegung je Fachkraft, bereits vergangene Zeiten, Mindestvorlauf und das Ende des Buchungsfensters. Diese in eine Datenbankabfrage zu vergraben, hätte jedes Szenario nur mit echten Daten testbar gemacht. Die Berechnung wurde als Ein- und Ausgabe herausgezogen; Tests bauen ein Szenario mit einem einzigen Aufruf auf.
KompromissDie aus der Datenbank gelesenen Intervalle korrekt in die Funktion zu überführen, liegt in der Verantwortung der aufrufenden Seite; die Funktion merkt nicht, dass sie unvollständige Daten bekommen hat, sie rechnet nur mit dem Gegebenen. Auch wenn die Berechnung getestet ist, muss also separat geprüft werden, dass sie mit der richtigen Eingabe aufgerufen wird.
UTC speichern, Europe/Istanbul anzeigen. Terminintervalle werden als zeitzonenbehaftete Zeitstempel geschrieben, Tagesgrenzen gegen den lokalen Tag berechnet, die Anzeige läuft über eine feste Zone. Dass die Regel ein einziger Satz ist, verhindert, dass jede neue Ansicht ihre eigene Umrechnung erfindet.
KompromissEine feste Anzeigezone genügt nicht, wenn der Betrieb in einer anderen Zone zu arbeiten beginnt; heute ist die Zone eine Konstante und keine Betriebseinstellung. Auch in Tests muss der Versatz ausdrücklich angegeben werden, sonst hängt ein Test von der Zone der ausführenden Maschine ab.
Da das Konfigurationsschema aus der Konfigurationsdatei importiert wird, scheitert eine fehlerhafte Umgebung beim Build oder Start und nicht zur Laufzeit. Pflichtvariablen sind nur die, die zum Starten der Anwendung nötig sind; Funktionen wie E-Mail, Erinnerungen und Benachrichtigungen sind optional und bleiben aus, bis ihre Schlüssel vorliegen. So lässt sich ein Produktions-Build auch ohne Secrets erzeugen.
KompromissEin fehlender optionaler Schlüssel schaltet eine Funktion still ab; ein falsch eingetragener und ein nie eingetragener Schlüssel sehen von außen gleich aus. Es gibt außerdem einen Notausgang zum Überspringen der Prüfung, und bleibt dieser Schalter in der Produktion aktiv, entfällt die Garantie des Schemas.
Postgres ist hier keine Vorliebe, sondern Voraussetzung: Exclusion-Constraints und Intervalltypen stehen im Zentrum dieser Entscheidung, und dieselbe Garantie lässt sich auf einer anderen Datenbank nicht ebenso günstig herstellen. Drizzle wurde gewählt, weil es erlaubt, die Constraint als reines SQL in einer Migrationsdatei zu schreiben — die Regel verschwindet nicht hinter einer ORM-Abstraktion, sie bleibt lesbar. date-fns-tz bündelt die Umrechnung zwischen lokalem Tag und zeitzonenbehaftetem Zeitstempel an einer Stelle. Auth.js kommt nur auf der Verwaltungsseite zum Einsatz; im Kundenablauf gibt es kein Konto, storniert wird über einen Einmal-Link.
Das Verwaltungspanel ist sitzungsgeschützt; auf Kundenseite gibt es kein Konto, entsprechend minimal sind die gespeicherten Identitätsdaten. Die Stornierung läuft über einen Link mit einem nicht erratbaren Einmal-Token — die Termin-ID erscheint nie in der Adresszeile. Der Einwilligungstext wird am Datensatz gespeichert: Wer wann welchem Text zugestimmt hat, bleibt nachträglich nicht strittig, und ein eigener Test schützt das. Die Schreibpfade laufen in einer einzigen Transaktion; ein für den Fall gleichzeitiger Buchung geschriebener Test bestätigt, dass die zweite Anfrage an der Datenbank-Constraint scheitert.
Dies ist ein Prototyp ohne veröffentlichte Installation, und da keine mit Datum versehene Lighthouse-Messung vorliegt, steht hier kein Seitengeschwindigkeits-Score. Messbar ist die Verfügbarkeitsberechnung: An die Datenbank geht eine einzige Abfrage für die Termine und Sperren des Tages, die Slot-Erzeugung passiert im Speicher. Dadurch löst ein Tageswechsel im Kalender keine neue Abfragerunde aus.
Das eigentliche Ergebnis dieses Prototyps ist kein Code, sondern eine Gewohnheit: zu fragen, ob eine Regel in die Anwendungsschicht oder in die Datenbank gehört. Die Antwort von hier habe ich in spätere Arbeiten mitgenommen — bei Bergaz Lebensmittel habe ich die Idempotenz des Zahlungs-Callbacks auf eine Eindeutigkeits-Constraint reduziert, bei Bergaz Operations die Prüfung auf doppelte Proben auf einen partiellen Unique Index. Es gibt auch Belege in die Gegenrichtung: Bei Taxilos öffnete ein einziges Prädikat, das in dreiwertige Logik fiel, 45 Tore gleichzeitig, weil das Berechtigungs-Gate über Funktionen verteilt war. Anders machen würde ich hier die Zustandsseite: Ein Termin trägt eine Statusmaschine, und die erlaubten Übergänge liegen weiterhin verstreut im Code; auch sie würde ich auf eine einzige Definition reduzieren — genau wie bei der Überschneidungsregel.
Sagen Sie mir, was Sie bauen wollen; ich sage Ihnen vorab, wie lange es dauert und wo man anfängt.