Monorepo oder nicht — ich habe mich falsch entschieden und dann richtig
Bot, Dashboard und gemeinsamer Code lagen in drei Repositories. Jede Änderung an geteilten Funktionen war eine Kopieraktion. Wie ich das zusammengelegt habe und was dabei schiefging.
990 Wörter · 5 Min. Lesezeit
- architektur
- werkzeuge
- yurna
Anfang 2022 sah mein Projekt so aus: ein Repository für den Bot, eines für das Dashboard, und in beiden ein Ordner utils, dessen Inhalt zu etwa siebzig Prozent identisch war.
Formatierungshilfen, Berechnung der Levelschwellen, Rechteprüfungen, Standardwerte für Einstellungen. Jedes Mal, wenn ich an einer dieser Funktionen etwas änderte, musste ich es an zwei Stellen tun. Und jedes Mal, wenn ich es vergaß, hatten Bot und Dashboard verschiedene Meinungen über dieselbe Sache.
Der Auslöser für die Umstellung war ein Fehler, der genau daher kam: Das Dashboard zeigte für einen Nutzer Level 14, der Bot sagte Level 13. Beide hatten recht — sie rechneten mit verschiedenen Fassungen derselben Formel.
Was ein Monorepo ist und was nicht
Ein Monorepo ist ein Repository, das mehrere Pakete enthält. Es ist ausdrücklich nicht: alles in einem Ordner, ein einziges großes Programm, oder ein Grund, Grenzen zwischen Teilen aufzugeben.
Der Aufbau bei mir:
apps/
bot/ eigenständige Anwendung
dashboard/ eigenständige Anwendung
packages/
database/ Schema und Datenzugriff
util/ gemeinsame Funktionen
config/ Konstanten und Standardwerte
i18n/ Übersetzungen
Die Anwendungen benutzen die Pakete als Abhängigkeiten, so als wären sie aus dem Netz installiert — nur dass sie im selben Repository liegen und Änderungen sofort wirken.
Was sofort besser wurde
Eine Änderung, eine Stelle. Die Levelformel liegt in packages/util und wird von beiden benutzt. Der Fehler, der den Umbau ausgelöst hat, kann strukturell nicht mehr auftreten.
Ein gemeinsames Datenbankschema. Vorher hatte jedes Projekt seine eigene Schemadatei, die „eigentlich gleich" war. Das ist eine der gefährlichsten Formulierungen überhaupt.
Änderungen über Grenzen hinweg in einem Commit. Wenn ich ein Feld umbenenne, ändere ich Schema, Bot und Dashboard zusammen. Vorher waren das drei Commits in drei Repositories, die zusammengehörten und es keinem sagten.
Ein Befehl für alles. pnpm dev startet, was zu starten ist. pnpm lint prüft alles. Das klingt banal und spart täglich Zeit.
Was schiefging
Der erste Anlauf war ein Kopierfehler. Ich habe die Ordner zusammengezogen, ohne die Historie mitzunehmen. Zwei Jahre Commit-Verlauf des Dashboards waren damit weg. Ich habe das zwei Tage später gemerkt, den Stand nochmal aufgesetzt und diesmal mit dem richtigen Vorgehen zusammengeführt. Wer das vorhat: Es gibt Wege, Historien zusammenzuführen — man muss sie nur vorher kennen.
Nicht alles gehört in ein gemeinsames Paket. Meine erste Fassung hatte ein Paket namens shared, in das alles wanderte, was mehr als einmal gebraucht wurde. Nach zwei Monaten war das eine Sammlung ohne Zusammenhang, die von überall abhängig war. Ich habe es später in mehrere Pakete mit klaren Themen aufgeteilt — Datenzugriff, Formatierung, Konfiguration, Übersetzungen.
Alles baut alles. Ohne Vorkehrung führt jede Änderung dazu, dass sämtliche Pakete neu gebaut werden. Bei vier Paketen ist das lästig, bei zehn unbrauchbar. Ein Build-Werkzeug mit Zwischenspeicher löst das — es baut nur, was sich tatsächlich geändert hat.
Warnung
Ein Monorepo verführt dazu, Grenzen weich zu machen. Wenn alles im selben Repository liegt, ist es sehr einfach, aus dem Dashboard direkt in einen internen Teil des Bots zu greifen. Genau das darf man nicht — sonst hat man ein großes Programm mit Ordnern statt eines Systems mit Teilen. Ich lasse deshalb Abhängigkeiten nur in eine Richtung laufen: Anwendungen dürfen Pakete benutzen, Pakete keine Anwendungen, und Pakete untereinander nur nach einer festen Reihenfolge.
Wann ein Monorepo nicht die Antwort ist
Ich bin kein Verfechter davon, dass alles in ein Repository gehört. Was bei mir passte, hatte konkrete Gründe:
- Es gibt echten geteilten Code, nicht nur ähnliche Aufgaben.
- Die Teile werden von einer Person entwickelt und gemeinsam veröffentlicht.
- Änderungen betreffen regelmäßig mehrere Teile gleichzeitig.
Fällt einer dieser Punkte weg, sieht die Rechnung anders aus. Wenn zwei Projekte nur zufällig ähnlichen Code haben, ist ein gemeinsames Paket der falsche Weg — dann teilt man sich eine Abstraktion, die beide Seiten einschränkt.
Was beim Zusammenführen der Historien wichtig war
Der Teil, den ich beim ersten Anlauf verpatzt habe, ist der, der am wenigsten dokumentiert ist: Wie kommen zwei Projekte in ein Repository, ohne dass ihre Vorgeschichte verlorengeht?
Das naive Vorgehen — Dateien kopieren, einmal committen — erzeugt ein Repository, in dem das Dashboard am ersten Tag entstanden zu sein scheint. Zwei Jahre Änderungsverlauf sind damit nicht gelöscht, aber unerreichbar, weil das alte Repository irgendwann verschwindet.
Der bessere Weg ist, die Historie mitzunehmen: das zweite Projekt als zusätzliche Quelle einbinden, seine Änderungen in ein Unterverzeichnis verschieben, und danach zusammenführen. Das Ergebnis ist ein Verlauf, in dem man jede Zeile bis zu ihrem Ursprung zurückverfolgen kann.
Das ist mehr als Nostalgie. Die Frage „warum steht das hier so?" beantwortet man am schnellsten über den Änderungsverlauf — und zwar über die Commit-Nachricht, die dazugehört. Ein Verlauf, der bei einem Sammelcommit endet, beantwortet gar nichts.
Die Grenze, die ich nachträglich ziehen musste
Nach ein paar Monaten im Monorepo hatte sich etwas eingeschlichen, das ich nicht bemerkt hatte: Das Dashboard griff an zwei Stellen direkt auf Dateien des Bots zu.
Das ist möglich, weil alles im selben Repository liegt, und es ist genau das, was ein Monorepo nicht sein soll. Aus zwei Anwendungen mit gemeinsamen Paketen wird so schleichend ein großes Programm.
Ich habe daraufhin eine Regel eingeführt und sie mit einer Prüfung abgesichert: Anwendungen dürfen Pakete benutzen. Pakete dürfen keine Anwendungen benutzen. Anwendungen dürfen einander nicht kennen.
Die Prüfung ist simpel — eine Regel im Linter, die Importpfade kontrolliert. Sie hat seitdem mehrfach angeschlagen, jedes Mal bei einer Abkürzung, die im Moment sinnvoll erschien. Genau dafür sind solche Regeln da: nicht gegen Unwissen, sondern gegen Bequemlichkeit unter Zeitdruck.
Was ich rückblickend als eigentliche Lehre sehe
Der Fehler mit den zwei Levelformeln war kein Zufall. Er war die logische Folge einer Struktur, in der es zwei Wahrheiten gab.
Seitdem stelle ich bei jedem doppelten Code eine Frage, bevor ich ihn zusammenführe: Sind das zufällig zwei gleiche Dinge oder ein Ding an zwei Orten? Bei der Levelformel war klar, dass es ein Ding ist — es muss überall dasselbe herauskommen. Bei zwei Formularvalidierungen, die zufällig ähnlich aussehen, ist es das oft nicht, und ein gemeinsames Paket wäre dann eine Fessel.
Die Frage klingt nach Haarspalterei und ist der Unterschied zwischen einer nützlichen und einer lästigen Abstraktion.