lars
webmobiledatenbankendevopsarchitektur
hello (at) larskoelpin.de

Vom Multirepo zum Monorepo

September 25, 2022
typescript

JavaScript hat sich nicht zuletzt durch den Single-Application-Hype Client-seitig etabliert und wurde durch NodeJS sogar server-seitig salonfähig. Trotzdem nutzen nicht alle Unternehmen die Vorteile einer Sprache aus. In diesem Beitrag wollen schaue ich mir den Wandel vom Multi-Repo zum Mono-Repo an und welche Vorteile eigentlich das Verwenden einer einzigen Sprache mitbringt.

Eine Applikation - viele Packages und Repositories

Klassischerweise werden, wenn eine Singe Page Application (SPA), die einen Server benötigt um zu Daten zu verwalten, entwickelt wird, zwei Repositories gepflegt: Eines mit dem Server-Code (z.B. in Java), eines mit dem Client/SPA-Code (meistens TypeScript oder JavaScript).

Der Server-Code definiert häufig eine Schnittstelle (häufig modelliert durch sogenannte DTO), die eine Form für bestimmte Operationen definiert. Meistens ist es so, dass die SPA der einzige Konsument der Server-API ist. Beispielsweise könnte eine HTTP-Schnittstelle GET /user/1 eine Nutzer-Repräsentation wiedergeben. Das JSON würde entsprechend so aussehen:

{
  "name": "lars"
}

Damit das Serialisierungsformat (z.B. JSON) in der Sprache des Servers, verarbeitet werden kann, existiert dazu eine entsprechende Modell-Repräsentation im Code. Das ist bei TypeScript z.B. ein Typing (da JSON nativ unterstützt wird) oder sogar ein Modell in Form einer Klasse.

class UserDto {
   constructor(
     private name: string
  )
}

Da das Nutzer/User-Modell auch auf dem Frontend genutzt werden soll, gibt hier abermals ein Modell. Dieses Modell gleicht immer strikt dem Modell des Servers. Es existiert also auch hier eine Datei UserDto.ts. 

Multi Repo ohne Sharing
Multi Repo ohne Sharing

Problematisch wird es dann, wenn sich die API des Server weiterentwickelt, z.B. weil der Client mehr Daten braucht. Addiert der Server beispielsweise das Feld emailAddresse, muss der Entwickler im Backend-Code die Datei UserDto.ts anpassen, sowie im Client-Repository die Datei UserDto.ts anpassen, wobei in beiden Fällen das entsprechende Feld des internen Modells hinzugefügt wurde. Das Problem ist also, dass Server-Modell und Client-Modell in Sync gehalten werden müssen.

Und es existiert noch ein viel gravierenderes Problem. Das Teilen von Daten-Shapes, also welche Felder im Serialisierunfsformat (z.B. JSON) existieren, ist weitesgehend trivial. Spannend wird es genau dann, wenn neben dem Daten-Shape (also ganze Klassen) auch Operationen geteilt werden sollen. Dass ein Feld “emailAddress” in einem JSON-Objekt existiert ist also relativ einfach, herauszufinden, ob “thisisnotamailfortheserver” auch als valide Email-Adresse auf dem Server interpretiert wird ist nicht ganz so einfach. Eine solche Logik nur per Copy&Paste in Sync zu halten ist eine Sisyphus-Aufgabe.

Package Modell

Da aber Typescript in vielen Applikationen jedoch sowohl auf Client-, als auch Server-seite zum Einsatz kommt, liegt es nahe ein NPM-Package zu extrahieren: shared. Ist NPM-Package mit den entsprechenden Dateien user-dto.ts und den Validierungsregeln isEmail.ts erstellt.

Multi Repo mit Sharing
Multi Repo mit Sharing

Die beiden NPM-Packages Client und Server deklarieren entsprechend eine Dependency auf das shared-Package, das wiederum in einem eigenen git-repository liegt. Das Code-Sharing Problem scheint damit gelöst.

Developer Experience

Das Problem, das ich an an diesem Setup ist die Developer Experience. Zunächst müssen nun drei seperate Repositories ausgecheckt werden und womöglich in 3 Fenstern innerhalb der IDE jongliert werden. Je nachdem, wie in der package.json die dependency geregelt wird (ich gehe von einer npm registry aus) sieht jetzt der Flow so aus:

  1. user-dto.ts erhält ein neues Attribut oder function
  2. Das NPM package wird getestet und gepublished
  3. Der server aktualisert von shared @ 0.0.1 auf shared @ 0.0.2
  4. Der client aktualisiert von shared @ 0.0.1 auf shared @ 0.0.2

ganz schön viele Steps.

Gott sei dank gibt es auch dafür eine Lösung (https://docs.npmjs.com/cli/v8/commands/npm-link). Doch auch hier funktionieren wichtige Dinge wie Hot-Reloading nur sperrig. Auch sind Änderungen zwischen Client/Server/shared sehr isoliert (isolierte tests, isolierte commits pro package) gehalten, als ob es sich um isolierte Pakete handelt, obwohl es eigentlich um ein großes, gemeinsames Ziel geht: Das Projekt/System und weniger ein generisches Package.

Trotzdem würde ein Update für das eine Projekt 3 Repositories (und evtl. sogar mehr, wenn wir z.B. noch einen SSR-Server oder Features feingranularer als “shared” schneiden) spannen.

MonoRepo

Ein möglicher Step wäre also eine Änderung des Blickwinkels: Warum werden Pakete isoliert betrachtet (package-Ebene) und nicht auf Gesamt-IT-Projekt-Basis. Und hier kommt das Monorepo ins Spiel. Ein Monorepo sieht es vor, viele Projekte und Pakete in einem einzelnen git-Repository zu halten. Dazu wird in der Regel ein Tool genutzt (ich nutze dafür RushJs), dass die einzelnen Dependencies untereinander verlinkt, sodass Änderungen direkt in allen anderen Paketen zu sehen sind.

Eine etwas größere Struktur könnte beispielsweise so aussehen, die eigene Ordnerstruktur ist natürlich frei wählbar:

apps/
+ my-project-e2e
+ my-project-api
+ my-project-ssr
+ my-project-client
+ ...
libs/
+ my-project-synchronsation
+ my-project-domain-model
+ my-project-server-open-api
...
toos/
+ my-project-build-chain/
+ my-project-api-ansible/
...

Dabei enthält apps deploybare artefakte. Klassischerweise den Server und den Client Code. Hier gehört z.B. auch ein e2e-Package rein.

In libs landen dabei “sharebare” Dinge, wie beispielsweise gemeinsame Modelle. Mit einem Ordner tools können gemeinsame Toolchains geshared werden.

Änderungen, wie beispielsweise das hinzufügen eines neuen Attributs zu einem Modell wäre mit dieser Struktur ein leichtes: Ich passe das Modell my-project-domain-modell und entsprechende Stellen in my-project-api und my-project-client an. Durch eine simple commit Message, kann cross-package committed werden und atomare Änderungen am Gesamtsystem vorgenommen werden.

Viele kleine Inseln werden also zu einem großen Ganzen zusammengefügt.

Natürlich hat auch ein Monorepo Nachteile. Beispielsweise können packages durch verschiedene Teams gebrochen werden, weil alle in eine Git-Base commiten. Auch der Operationsaufwand ist mehr, da CI/CD-Pipelines auf Ordner-Basis laufen müssen.

Für mich überwiegen allerdings die Vorteile.

Zusammenfassung

In diesem Beitrag habe ich verschiedene Ansätze zum Code-Sharing von Applikationen im Zeitalter von SPA Revue passieren lassen. Dabei habe ich die Ansätze vom Multi-Repo bis hin zum Multi-Repo mit Packages, bis zum MonoRepo gezeigt. Der MonoRepo-Ansatz wird dabei häufig kaum in Betracht gezogen, obwohl dieser in meinen Augen die beste Abstraktion wählt: Die Projektsicht (my-app) statt einer technischen Sicht (packages).