
Internes System
Ein System aus vier Anwendungen, das ich für den Betrieb der Molkerei Bergaz Gıda entwickelt habe: Milchannahme und Erzeugerkonten-Buchhaltung, Qualitätsanalyse, Lager und ein nur lesendes Management-Panel für das Telefon. Alle vier laufen auf getrennten Neon-Datenbanken; 19 Jahre Daten aus dem seit 2007 genutzten Paradox-Desktopsystem wurden per ETL übernommen.
Der Shop gehört nicht zu diesem System und wird separat betrieben — siehe die Bergaz-Gıda-E-Commerce-Fallstudie.
Der Betrieb arbeitete seit 2007 mit einem Desktopsystem auf einem einzigen Rechner: Suttek.exe, Paradox-Dateien auf Delphi/BDE. Alles Übrige verteilte sich auf Papier und Tabellenkalkulationen. Die Probleme verstärkten einander — Zugriff nur von einem Punkt, kein Mehrbenutzerbetrieb, kein mobiler Zugriff, das Korruptionsrisiko dateibasierter Speicherung (bei der Datenübernahme stimmte die Datensatzzahl einer Tabelle in zwei Lesevorgängen nicht überein) und die manuelle Übertragung der Belege in das Buchhaltungsprogramm. Darüber lag ein leiseres Problem: Sämtliche vorhandenen Masken waren für den Operator entworfen — dichte Formulare, viele Reiter, Tastaturkürzel. Die Geschäftsführung kam über diese Masken nicht an Informationen; die Antwort lag im System, aber der einzige Weg dorthin führte über eine Bitte an jemand anderen.
Vier Rollen, vier unterschiedliche Bedürfnisse. Operator: erfasst im Tagesverlauf sehr viele gleichartige Milchannahmen, hohes Risiko für Doppelerfassungen, Geschwindigkeit zählt am meisten. Buchhalter: verantwortet Vorschussverrechnung, Monatsabschluss, Abzugsberechnung und Belegerstellung — und akzeptiert keinen Fehler bis auf den letzten Kuruş. Qualitätsbeauftragter: muss noch vor Abschluss der Erfassung sehen, dass ein Probenwert außerhalb des Referenzbereichs liegt, und der Referenzbereich unterscheidet sich je nach Milchart. Geschäftsführung: technisch nicht affin, schaut vom Telefon, will wenige Masken und große Zahlen — und will, dass die Wahrscheinlichkeit, versehentlich etwas zu verändern, bei null liegt.
Alleiniger Entwickler, vier Repositories. Ziehen der Systemgrenzen, vier Datenmodelle samt Migrationen, die Paradox-ETL-Strecke und ihre Verifikation, die Rechen-Engines für Geld und Abzüge, zwei getrennte Auth-Schichten, die Operator-Oberflächen, die Management-PWA und das Deployment.
Vier unabhängige Next.js-16-App-Router-Anwendungen, vier getrennte Neon-Postgres-Datenbanken. Zwischen ihnen gibt es keine Fremdschlüssel, keine datenbankübergreifenden Abfragen und keinen Synchronisationsmechanismus. Der Schreibpfad ist in jeder Anwendung derselbe: Server Action → Zod-Validierung → db.transaction(). Der Lesepfad ist Server Component → Abfrageschicht. Auf der Buchhaltungsseite läuft die Vorschussverrechnung nach FIFO mit SELECT ... FOR UPDATE innerhalb einer einzigen Transaktion; die Milcherfassung ist über einen Advisory Lock auf das Paar (Erzeuger, Datum) geschützt; Belegnummern kommen aus einer Postgres-SEQUENCE. Auf der Analyseseite werden die Abzugsparameter im Moment der Erfassung in die Zeile kopiert (Abzugs-Snapshot), eine Periode wird mit COMPLETED → LOCKED gesperrt, und eine gesperrte Periode ist sowohl für Proben- als auch für Produktionsdatensätze geschlossen; die Tagesaggregat-Tabelle wird beim Schreiben aktualisiert, damit Regions- und Erzeugerauswertungen nicht die gesamte Historie erneut durchsuchen. Die Management-Anwendung führt vier Verbindungen: lesend und schreibend auf die eigene Auth-Datenbank, nur lesend auf die anderen drei. Die Spiegel der übrigen Schemata werden Spalte für Spalte kopiert, Index- und Relationsdefinitionen entfallen; users und audit_log werden in keinen Spiegel übernommen.
Der frühere Entwurf sah vor, dass sich zwei Systeme ein gemeinsames Schema teilen; das wurde verworfen. Jede Anwendung ist in ihrem eigenen Migrations-, Backup- und PITR-Zyklus unabhängig; fällt eine aus, bleiben die anderen unberührt, und ihre Entwicklungsgeschwindigkeiten dürfen auseinanderlaufen — die Analyseseite konnte auf Auth.js und bcrypt bleiben, während die Buchhaltung auf argon2id wechselte. Die Grenze ist nicht willkürlich, sie folgt der fachlichen Grenze: Geld auf der einen Seite, Qualität und Produktion auf der anderen.
KompromissDieselbe Person existiert als zwei getrennte Datensätze in zwei Systemen; ein neuer Erzeuger oder ein neues Dorf wird an beiden Stellen von Hand angelegt, und die Zuordnung erfolgt manuell über Name und Dorf. Eine Auswertung über beide Systeme lässt sich nicht mit einer einzigen Abfrage erzeugen — man exportiert aus jeder Anwendung getrennt und führt außerhalb zusammen. Die Nutzer melden sich in jedem System mit einem eigenen Konto an; ein Single Sign-on gibt es nicht.
Numeric-Spalten aus Postgres werden als String gelesen und an keiner Stelle in Number umgewandelt; die gesamte Arithmetik läuft mit voller Präzision über decimal.js. Gerundet wird nur beim Schreiben in die Datenbank und bei der Ausgabe am Bildschirm, mit HALF_UP — nicht kaufmännisch nach Banker's Rounding, weil der Kuruş auf dem Beleg exakt mit einer Handrechnung übereinstimmen muss. Die Akkumulation von Rundungsfehlern ist ein eigener Punkt: multipliziert die Verrechnung für jede Zeile Preis × kg, kann die letzte Zuordnung einen Kuruş zu niedrig ausfallen, deshalb wird die letzte Zeile aus dem Restbetrag berechnet.
KompromissJeder arithmetische Ausdruck braucht eine Decimal-Hülle; `a + b` lässt sich nicht schreiben, und der Rechencode wird spürbar länger und mühsamer zu lesen. Schlimmer noch: Die Regel wird vom Compiler nicht erzwungen — eine einzelne Zeile, die einen String in Number wandelt, läuft stillschweigend durch, und der Fehler fällt erst auf, wenn der Kuruş nicht mehr stimmt.
Gebraucht wurde keine weitere Erfassungsmaske, sondern ein lesbares Fenster auf vorhandene Daten; Schreibrechte hätten nur das Risiko gebracht, versehentlich das Falsche zu berühren. Die eigentliche Entscheidung ist, wo die Nur-Lese-Eigenschaft garantiert wird: Der Plan sah dafür eine eigene Read-only-Rolle auf Neon-Seite vor, doch da die Verbindungen praktisch weiterhin unter der Eigentümerrolle laufen, fiel die Garantie an einen Abfrageschild innerhalb der Anwendung. Der Schild verlangt, dass eine Abfrage mit `select` oder `with` beginnt, und weist sie bei einem schreibenden Schlüsselwort ab, bevor sie ins Netz geht — schreibfähige CTEs eingeschlossen.
KompromissDie Garantie liegt in der Anwendung und nicht in der Datenbank; in dem Moment, in dem ein neuer Abfragepfad hinzukommt, der nicht durch den Schild läuft, ist der Schutz weg, und nichts erinnert daran. Die schlüsselwortbasierte Abweisung kann außerdem falsch positiv auslösen: Eine legitime Spalte oder ein CTE, dessen Name ein verbotenes Wort enthält, blockiert die Abfrage, und dann muss man sie so umschreiben, dass der Schild sie versteht.
Der Paradox-Reader liefert den Bytestrom als Mojibake-Mischung aus latin1 und cp437; eine reine cp857- oder cp1254-Dekodierung schlägt fehl. Der Decoder arbeitet zweistufig: zuerst eine deterministische Abbildung beobachteter Verfälschungen auf türkische Buchstaben, dann für die verbleibenden hohen Bytes ein Punktevergleich zwischen cp1254 und cp857. Der Import allein galt nicht als ausreichend — Summenvergleich je Jahr, Anzahlvergleich und ein eigener Verifikationslauf kamen hinzu, nicht auflösbare Zeilen landeten auf einer Liste zur manuellen Prüfung.
KompromissDie erste Stufe ist eine beobachtungsbasierte Abbildung: Taucht in der Quelle eine bislang ungesehene Verfälschung auf, verallgemeinert sie sich nicht von selbst und muss von Hand ergänzt werden. Die zweite Stufe ist probabilistisch; in kurzen Feldern, in denen beide Kodierungen plausibel wirken, kann sie auf die falsche Seite fallen. Deshalb war die Migration nie ein „einmal starten und vergessen“ — jeder Lauf verlangte einen Prüfdurchgang und eine menschliche Entscheidung. Der Reader selbst braucht zudem x86_64-Python, was auf Apple Silicon eine Rosetta-Schicht in die Entwicklungsumgebung gebracht hat.
Zur Laufzeit nutzt die Anwendung die gepoolte Verbindung; das verhindert die Verbindungsexplosion, die entsteht, wenn auf Vercel jede Serverless-Instanz eine eigene Verbindung öffnet. Migrationen brauchen dagegen eine direkte Verbindung: Der Transaction Mode von pgbouncer bricht mehranweisige Migrationen. Deshalb werden zwei getrennte Verbindungszeichenfolgen gehalten, und der Migrationspfad verweigert den Start, wenn die direkte Verbindung nicht definiert ist.
KompromissStatt einer werden zwei Verbindungszeichenfolgen mitgeführt, und beide müssen in jeder Umgebung (lokal, Preview, Produktion) korrekt gesetzt sein. Eine Verwechslung zeigt sich nicht beim Kompilieren, sondern zur Laufzeit — und meist mitten in einer Migration, also an der teuersten Stelle.
Dass alle vier Anwendungen auf Next.js 16 App Router laufen, ist Absicht: Dasselbe Schreibmuster (Server Action → Zod → Transaktion) wiederholt sich in vier Repositories, sodass sich das mentale Modell beim Wechsel nicht ändert. Neon bleibt im selben Serverless-Modell wie das Deployment und liefert über den WebSocket-Treiber dennoch echte Transaktionen — das SELECT ... FOR UPDATE der FIFO-Verrechnung und die mehranweisigen Schreibvorgänge machten das zwingend. Drizzle ORM bleibt nah an SQL: Partielle Unique-Indizes, Enums und FK-Verhalten sind in der Migrationsdatei sichtbar, und in einem System, in dem Datenintegrität im Mittelpunkt steht, war es wichtig, dass Constraints nicht hinter einer ORM-Abstraktion verschwinden. decimal.js hält Float-Rundung von Anfang an aus der Geldrechnung heraus. Zod dient als ein einziges Schema sowohl im Formular als auch für die Eingabe der Server Action. Auf der Management-Seite macht Serwist die Anwendung auf dem Telefon installierbar und hält die zuletzt gesehenen Daten im Cache. Der einzige Grund, warum das ETL in Python geschrieben ist, heißt Paradox: Der einzige funktionierende Reader liegt dort.
Zwei getrennte Auth-Stacks stehen bewusst nebeneinander. Auf der Analyse- und Lagerseite Auth.js Credentials mit bcrypt und JWT-Session; die Berechtigung wird an drei Stellen geprüft — optimistischer Routenschutz in der Proxy-Schicht, vollständige Rollenprüfung auf Layout-Ebene und eine Eigentümerprüfung innerhalb der Server Action, sodass Erfassungspersonal nur eigene Datensätze bearbeiten und löschen kann. Auf der Buchhaltungs- und Management-Seite eine von mir selbst geschriebene Auth: argon2id mit den OWASP-2024-Parametern, vier Rollen in der Buchhaltung (Admin / Buchhaltung / Operator / Betrachter) sowie eine langlebige Sitzung plus sechsstellige PIN im Management. Im Audit-Log werden Felder wie Ausweisnummer, IBAN und Versicherungsnummer nie im Klartext geschrieben, sondern nur als „geändert“ vermerkt; jedes andere INSERT, UPDATE und DELETE wird mit altem und neuem Wert als JSONB protokolliert, und alte Einträge werden über einen Batch-Befehl bereinigt. Rate Limiting greift beim Login und bei allen Schreib-Actions, gelöschte Benutzer können sich nicht anmelden, und dynamische Routenparameter werden als UUID validiert. Alle vier sind geschlossene Systeme: in robots.txt vollständig gesperrt und noindex im Antwort-Header. Jede Abfrage, die die Management-Anwendung verlässt, passiert zusätzlich den Abfrageschild.
Keine dieser vier Anwendungen ist öffentlich, also existiert keine gespeicherte Lighthouse-Messung, und einen Wert, den es nicht gibt, schreibe ich nicht hin; das Maß ist hier das Abfrageverhalten, nicht die Seitengeschwindigkeit. Auf der Analyseseite wird die Tagesaggregat-Tabelle beim Schreiben aktualisiert, sodass Regions- und Erzeugerauswertungen nicht die gesamte Historie durchsuchen; langsame Abfragen werden ab einem konfigurierbaren Schwellwert protokolliert, und das Repository bringt einen Befehl mit, um ein EXPLAIN-ANALYZE-Profil der Admin-Abfragen zu ziehen. Auf der Buchhaltungsseite wurde keine Materialized View gebaut, die Entscheidung aber offengelassen: Sie wird neu bewertet, sobald die vollständigen historischen Daten produktiv sind und p95 zwei Sekunden übersteigt. Die Management-Anwendung wurde ausschließlich für Telefone entworfen — 390px Entwurfsbreite, keine Breakpoints, 17px Fließtext und 34–40px KPI-Zahlen, Touch-Ziele von mindestens 48×48px; Zoomen wurde bewusst nicht unterbunden. Die zuletzt gesehenen Daten liegen im Service-Worker-Cache, und auf dem Bildschirm steht ein Stempel „vor N Minuten aktualisiert“.
Die Nur-Lese-Eigenschaft liegt heute als Abfrageschild in der Anwendungsschicht, obwohl ihr richtiger Ort die Datenbank ist: Wäre auf Neon eine eigene Read-only-Rolle angelegt worden, läge die Garantie außerhalb der Anwendung und kein neuer Abfragepfad könnte sie durchbrechen. Dieselbe Lektion hatte ich innerhalb dieses Systems schon einmal gelernt — als ich die Doppelprüfung der Proben aus der Formularvalidierung herausnahm und in einen partiellen Unique-Index verlegte, galt die Regel unabhängig davon, über welchen Weg man kam. In Görev Kahramanı zeigte sich dasselbe Muster auf der Nebenläufigkeitsseite: Schützt man Belohnungsschreibvorgänge mit einem Compare-and-Set in der Anwendungsschicht, muss jeder neue Schreibpfad denselben Schutz erneut aufbauen. Das wiederkehrende Ergebnis lautet: Solange die Garantie in der Anwendung liegt, muss jeder neue Pfad sie erneut bestätigen — und irgendwann vergisst es jemand. Der zweite Punkt sind Tests: Nur eines der vier Repositories hat automatisierte Tests. Geldrechnungen — Quellensteuer, Preis, Abweichung — sind reine Funktionen und am leichtesten zu testen; im Management habe ich dort angefangen, in der Buchhaltung aber nicht dasselbe getan. Heute würde ich die Reihenfolge umdrehen und die Tests in dem Repository schreiben, in dem das meiste Geld bewegt wird.
Sagen Sie mir, was Sie bauen wollen; ich sage Ihnen vorab, wie lange es dauert und wo man anfängt.