Fallstudie

Nomad TravelGuide: eine App, allein gebaut

Wie aus einem Reiseplaner eine App mit Backend, Moderation und Rechtstexten wurde — und welche drei technischen Entscheidungen dabei den Ausschlag gaben.

Umfang
131 Dart-Dateien, 55.373 Zeilen, 55 Screens
Backend
23 Cloud Functions auf Firebase
Sprachen
1.176 Übersetzungsschlüssel je Sprache
Zeitraum
Erster Commit Februar 2026, im App Store seit Juli 2026

Ausgangslage

Eine Reise zu planen heißt normalerweise, zwischen Suchmaschine, Karten, Buchungsseiten und einem Notizdokument hin- und herzuspringen. Nomad legt die Planung, die Inhalte und die Leute, die gerade dieselbe Strecke fahren, in eine App: einen Reiseplaner, der aus Zeitraum, Ländern und Verkehrsmitteln einen Tagesplan baut, eine Karte mit Spots aus der Community, Chat, Quests und ein Freundebuch. Geschrieben in Flutter, iOS und Android aus einer Codebasis, Backend auf Firebase.

Ich habe die App allein gebaut, neben der Technikerschule: Idee, Architektur, Code, Store-Auftritt und Rechtstexte.

Architektur im Überblick

Im Client liegt die Oberfläche und alles, was ohne Netz funktionieren muss. Die Firestore-Persistence ist ausdrücklich eingeschaltet, mit unbegrenztem Cache. Dadurch liefern alle Listener zuerst aus dem lokalen Cache und aktualisieren danach im Hintergrund vom Server. Das war weniger eine Optimierung als eine Notwendigkeit: Für Nutzer weit weg von der europäischen Region ist der erste Roundtrip zum Server deutlich zu spüren, und eine Reise-App wird nun einmal dort benutzt, wo man nicht zu Hause ist.

Auf dem Server liegt alles, was der Client nicht entscheiden darf. Die 23 Cloud Functions verteilen sich auf Moderation, Push-Auslöser, Aufräumjobs, geteilte Reisepläne, den Suchindex, den Nutzungszähler, Reise-Erinnerungen und die Kontolöschung. Die Trennlinie dazwischen ist keine Geschmacksfrage: Sobald eine Entscheidung ein Recht durchsetzt — wer darf lesen, wer darf löschen, was passiert bei einer Meldung —, gehört sie auf den Server, weil ein Client sich verändern lässt und ein Sicherheitsregelwerk nicht.

Am deutlichsten steht das in den Firestore-Regeln. Die Sammlung reports etwa ist für den Client vollständig gesperrt: Lesen darf nur ein Administrator, Schreiben niemand. Eine Meldung erreicht die Datenbank nur über eine Cloud Function, die vorher prüft, was sie tut.

Die Offline-Fähigkeit endet genau dort, wo diese Funktionen anfangen: KI-Chat, Reiseplan teilen, Melden und Ping beantworten brauchen eine Verbindung. Alles andere — lesen, planen, schreiben — läuft weiter und wird nachgeliefert, sobald wieder Netz da ist.

Drei Entscheidungen

01

Der Tagesplan war das schwerste Stück

Aus Zeitraum, Ländern, Verkehrsmitteln und einer Handvoll ausgewählter Aktivitäten einen Plan zu bauen, klingt nach Verteilen. Der naheliegende Weg — Aktivitäten der Reihe nach auf Tage legen — fällt an vier Stellen auseinander, und jede davon habe ich mir einmal eingefangen.

Erstens kommen die Dauern als Freitext: „3 Tage", „2-3 days", „90 Minuten". Ein Parser, der nur Stunden kennt, liest die 90 Minuten als 90 Stunden und blockiert damit eine ganze Woche. Community-Spots liefern ihre Dauer deshalb ausschließlich numerisch, nie als Text. Zweitens landen mehrtägige Aktivitäten im normalen Durchlauf wie eine Ganztags-Aktivität auf einem einzigen Tag — sie müssen nachträglich über die Folgetage gezogen werden, und dabei verdrängen sie, was dort schon eingeplant war. Drittens gehört ein Länderwechseltag der Reise und nicht den Sehenswürdigkeiten. Und viertens ist die Reisezeit zwischen zwei Stationen kein Rundungsfehler, sondern der Grund, warum ein Plan im Alltag scheitert.

Gebaut habe ich deshalb keinen Verteiler, sondern drei aufeinanderfolgende Durchläufe: Der erste belegt die Tage gegen ein Zeitbudget, der zweite zieht mehrtägige Aktivitäten über die Folgetage, der dritte verteilt alles, was dabei herausgefallen ist, auf leere Tage im selben Land — sofern das Budget es hergibt. Die Reisezeiten lädt der Generator vorher gebündelt über die Google Directions API; fällt sie aus, rechnet er mit der Luftlinie weiter, statt aufzugeben.

Der Preis: drei Durchläufe statt einem und ein Budget an API-Aufrufen. Gewonnen: Der Generator gibt zurück, was er weggelassen hat. Ein Plan, der verschweigt, dass drei Aktivitäten nicht mehr hineinpassten, ist kein Plan, sondern eine Enttäuschung mit Zeitverzögerung.

02

Ein Suchindex statt einer Präfixsuche

Firestore kennt kein „enthält". Die übliche Abkürzung ist eine Bereichsabfrage über einen kleingeschriebenen Anzeigenamen: Sie findet „daniel_w", wenn jemand „daniel" eingibt — aber „nomad_daniel" findet sie nie, weil der Treffer nicht am Anfang steht.

Stattdessen legt ein Trigger für jeden Nutzer ein Feld searchTokens an. Der Name wird normalisiert, an allen Nicht-Buchstaben zerlegt, und aus jedem Wort entstehen die Präfixe von drei bis fünfzehn Zeichen, dazu der zusammengezogene Gesamtname. Die Umlaute werden vor der Unicode-Zerlegung ersetzt, sonst wäre „Müller" unter „mueller" unauffindbar; für durchgestrichene Buchstaben wie ł oder đ gibt es eigene Regeln, weil die Zerlegung sie nicht auflöst. Gedeckelt ist das Ganze bei 120 Einträgen — „Daniel Wulf" erzeugt zehn.

Der Preis: Der Trigger läuft bei jedem Schreibvorgang auf dem Nutzerdokument, auch wenn sich der Name gar nicht geändert hat; dazu kommt ein Indexeintrag pro Token. Gewonnen: Treffer unabhängig von der Schreibweise, ohne einen weiteren Dienst einzubinden — einen, den ich bezahlen, betreiben und in der Datenschutzerklärung als Datenempfänger erklären müsste.

03

Ein Schutzgitter gegen einen Fehler, der nie passiert ist

Wenn ein Administrator eine Meldung bearbeitet, ruft er deleteContent mit dem Pfad des gemeldeten Inhalts auf. Bei einem gemeldeten Profil ist dieser Pfad users/{uid} — und ein rekursives Löschen darauf hätte das komplette Nutzerdokument samt allen Subcollections entfernt. Unwiderruflich, und ohne das dazugehörige Auth-Konto: zurück geblieben wäre ein login-fähiges Konto ohne jeden Inhalt.

Die Funktion prüft deshalb drei Dinge, bevor sie irgendetwas anfasst: Der Pfad muss auf ein Dokument zeigen und nicht auf eine Sammlung, seine Wurzel muss auf einer Liste löschbarer Bereiche stehen, und alles andere wird mit einer Fehlermeldung abgewiesen, die auf die richtigen Werkzeuge verweist — Profilinhalte zurücksetzen, Nutzer sperren, Nutzer bannen.

Der Preis: drei Funktionen statt einer und mehr Code. Gewonnen: Der Fehler kann nicht passieren — nicht bei Müdigkeit, nicht bei Eile, nicht in einem halben Jahr, wenn ich vergessen habe, dass ein Profil-Report anders aussieht als ein gemeldeter Kommentar.

Nutzergenerierte Inhalte sind ein Betrieb, kein Feature

In dem Moment, in dem Nutzer Fotos und Texte hochladen können, hört eine App auf, ein Produkt zu sein, und wird zu einem Betrieb — mit Meldungen, Prüfung und Haftung.

Eine Meldung geht nicht in die Datenbank, sondern an eine Cloud Function. Die legt pro gemeldetem Inhalt genau ein Dokument an, sammelt die Melder als Menge — mehrfaches Melden durch dieselbe Person ändert also nichts — und zieht eine Beweiskopie von Text und Bild, weil der Inhalt bis zur Prüfung längst gelöscht sein kann. Ab drei verschiedenen Meldern wird der Inhalt automatisch verborgen, allerdings nur dort, wo Verbergen etwas bewirkt: bei Spots, Kommentaren und Chatnachrichten. Bei Profilen und Gruppen wird der Fall stattdessen als dringend markiert, damit er in der Prüfliste nach oben rutscht. Am Ende steht eine von drei Entscheidungen: gelöscht, verworfen — dann wird das Verbergen zurückgenommen —, oder eine Maßnahme gegen den Autor, befristete Sperre oder Bann.

Jede Maßnahme landet in einem Protokoll, das festhält, wer wann was und warum getan hat. Der betroffene Nutzer bekommt seine Begründung in eine eigene Subcollection, die er lesen und als gelesen markieren, aber nicht löschen kann: Das ist seine Begründung, die darf ihm niemand wegnehmen.

Datenschutz wird an zwei Stellen sehr konkret. Der zuletzt geteilte Standort liegt bewusst nicht im Nutzerdokument, denn das ist für alle Angemeldeten lesbar — jeder mit irgendeinem Konto könnte die Position jedes Nutzers auslesen. Er liegt in einer eigenen Subcollection, die nur der Besitzer und bestätigte Verbindungen lesen dürfen. Damit gilt die Zusage der Datenschutzerklärung serverseitig und nicht nur in der Oberfläche. Und die Nutzungsstatistik nimmt gar keine Nutzerkennung entgegen: Die Anmeldung dient nur als Missbrauchsbremse, gezählt werden ausschließlich Schlüssel von einer festen Liste, und die Zählerschritte sind nach oben gedeckelt.

Die Kontolöschung ist der aufwendigste Teil davon — verlangt von Art. 17 DSGVO, von Apples Richtlinie 5.1.1(v) und von Googles Account-Deletion-Policy. Sie räumt elf Subcollections, Spots, Kommentare, Konversationen, Verbindungsanfragen, Pings, Challenges und Profilmedien ab. Gelernt habe ich dabei vor allem eines: Es kommt auf die Reihenfolge an. Zuerst wird geprüft, ob sich das Auth-Konto überhaupt löschen lässt, dann werden die Daten entfernt, und erst ganz am Ende verschwindet das Konto. Andersherum konnte es passieren, dass alle Daten weg waren, das Löschen des Kontos aber daran scheiterte, dass die Anmeldung zu lange zurücklag — zurück blieb ein login-fähiges Konto ohne einen einzigen Inhalt.

Vom Code in den Store

Zwischen dem ersten Commit im Februar 2026 und der Freigabe im Juli 2026 liegen fünf Monate, in denen parallel die Technikerarbeit lief. Die Einreichung im App Store kostete drei Anläufe: zwei Ablehnungen wegen kosmetischer Mängel, eine, weil Sign in with Apple beim Prüfer nicht funktionierte. Der letzte Punkt ist der lehrreiche: Der Anmeldeweg lief auf meinen Geräten, aber nicht unter den Bedingungen der Prüfung — ein Fehler, den man nur findet, wenn man aufhört, dem eigenen Testgerät zu glauben.

Zeit gespart hat dabei ein Punkt, der oft am Ende angehängt wird: Die Datenschutzerklärung ist gegen den tatsächlichen Code geschrieben und nicht gegen eine Vorlage. Wer erst beim Ausfüllen des App-Datenschutzberichts merkt, welche Daten seine App eigentlich anfasst, erklärt der Prüfung anschließend Dinge, die er selbst nicht überblickt.

Was ich mitnehme

Zwei Dinge. Bilder und Grafiken würde ich heute nicht mehr aus dem Firebase Storage ziehen, sondern alles, was fest zum Produkt gehört, mit der App ausliefern — das spart Roundtrips und Kosten und macht die App offline vollständiger. Und die Gewohnheit, die aus vier Jahren Produktionsbetrieb stammt: Ein Fehler ist behoben, wenn die Anlage wieder läuft, nicht wenn die Ursache gefunden ist. Ein Suchindex, der die Hälfte der Namen findet, ist kein halber Erfolg.

Nomad TravelGuide ist eine Einzelarbeit von Daniel Wulf. Für Projektanfragen und Zusammenarbeit: danielwulf.dev