Ein mehrstöckiges Bibliotheksgebäude mit Tausenden von Büchern, die in Regalen geordnet sind

Ein gut strukturiertes Monorepo kann helfen, Ihr Projekt sauber zu halten (Foto von Niklas Ohlrogge auf Unsplash)

Stellen Sie sich folgendes Szenario vor: Sie beginnen mit der Entwicklung eines neuen webbasierten Produkts für Ihr Unternehmen. Als erfahrenes Digitalprodukt-Team starten Sie mit einem schlanken Vorgehen, indem Sie erst ein MVP mit der Kernfunktionalität erstellen, um zu prüfen ob die Produktidee es wert ist, weiter ausgebaut zu werden.

Nach dem erfolgreichen Launch stellen Sie fest, dass der Entwicklung des kompletten Produkts eine viel größere Komplexität mit sich bringt: Möglicherweise brauchen Sie weitere Backend-Dienste, eine Marketing-Webseite, eine gemeinsam genutzte UI-Komponentenbibliothek und eine hohe Testabdeckung, um das Vertrauen in Ihre Codebase für künftige Refactorings zu erhalten. Wie gehen Sie vor?

Ein Ansatz, wie man es (nicht) macht

Ein scheinbar einfacher Ansatz wäre es, die neuen Funktionen einfach zur bestehenden Codebase hinzuzufügen, oder? Installieren Sie einfach die benötigten Packages, wie…

  • Jest and Cypress zum Testen,
  • ESLint and Prettier, um sich in Pull Requests nicht über Semikolons und Einrückungen streiten zu müssen,
  • Storybook zur Entwicklung und Dokumentation der UI-Komponenten,
  • TypeScript, um nervige Bugs schon zur Buildzeit zu finden,
  • und vielleicht dockerisieren Sie alles für konsistente Deployments und Developer Experience.

Jetzt tauchen schon die ersten Herausforderungen auf:

  1. Ihre Frontend-Anwendung braucht vielleicht ein etwas anderes Build-Setup als Ihr Backend, und Ihre Tests und Storybook ein noch anderes. Plötzlich haben Sie Webpack, Parcel, esbuild, Babel und ihre jeweiligen Plugins als Dependencies in Ihrer package.json. Aber welche dieser Dependencies gehören zu welchem Service und werden sie tatsächlich alle noch im Projekt verwendet? Schwer zu sagen für jeden, der neu in das Projekt einsteigt und auch für Sie selbst, wenn ein paar Wochen später die automatischen Sicherheitsprüfungen in Ihrem Repository (die Sie hoffentlich eingerichtet haben) eine Sicherheitslücke in einer dieser Dependencies melden.
  2. Sie stellen fest, dass verschiedene Teile Ihrer Anwendung unterschiedliche Tooling-Konfigurationen benötigen, z.B. unterschiedliche Jest-Einstellungen für Backend- und Frontend-Code, die Linting-Konfiguration für Ihren Quellcode funktioniert möglicherweise nicht gut mit Ihren Jest- oder Cypress-Testdateien oder Ihre einzelnen Services benötigen unterschiedliche TypeScript-Konfigurationen. Also fangen Sie an, zusätzliche Konfigurationsdateien in Unterordnern abzulegen, um jene in übergeordneten Ordnern zu überschreiben. Bald wird es schwierig herauszufinden, welche Einstellungen genau für welche Quellcodedateien angewandt werden.
  3. Da Sie Ihr MVP schnell auf den Markt bringen mussten, haben Sie noch keine Tests hinzugefügt. Als Sie nun damit beginnen, welche hinzuzufügen, stellen Sie fest, dass Ihr Code ziemlich schwer zu testen ist, da alles eng miteinander gekoppelt ist und viele Abhängigkeiten zwischen verschiedenen Dateien bestehen. Das war kein Problem, als Sie den ersten MVP geschrieben haben, aber jetzt wird das Testen sehr zeitaufwändig, weil Sie merken, dass Sie viele Dinge aus Dateien mocken müssen, die eigentlich nicht viel mit dem Code zu tun haben, den Sie gerade testen wollen.
  4. Sie haben eine Menge Dockerfiles im Root-Ordner Ihres Repositories, da alle Ihre Services (Frontend, Backend, Cypress, Storybook usw.) unterschiedliche Anforderungen haben, einige benötigen sogar ein anderes Setup, wenn sie in der CI laufen, und Sie möchten die einzelnen Docker-Images so klein wie möglich halten. Außerdem dauert es in letzter Zeit ewig, bis die CI-Pipeline durchgelaufen ist, weil zuerst immer ein vollständiges npm install durchgeführt werden muss und die package.json stetig wächst mit bereits 70 Dependencies, die wiederum selbst mehrere Dependencies haben und nach der Installation eigene Build-Skripte ausführen müssen. Das verlangsamt Sie massiv, da Ihr Entwicklerteam in der Zwischenzeit gewachsen ist um das Produkt zu skalieren, und Sie haben in der Regel etwa 10 Pull-Requests gleichzeitig offen, was entweder eine Menge Merge-Konflikte oder einen hohen Kommunikationsaufwand bedeutet.

Zu diesem Zeitpunkt ist Ihr Repository bereits so komplex, dass Sie einfach ein neues Repository anlegen, als Sie einen weiteren Backend-Service benötigen. Dadurch wird die Erstellung des neuen Services viel schneller, richtig? Immerhin handelt es sich um einen unabhängigen Microservice, und wer weiß, vielleicht ist er ja irgendwann für ein anderes Produkt nützlich?

Sie nehmen also Ihren neuen Service in Betrieb, und es ging tatsächlich viel schneller als vorher, aber dann stellen Sie fest, dass die Dinge auf einmal noch komplizierter geworden sind:

  • Es wäre sinnvoll, die TypeScript-Types für die Daten aus Ihren Backend-Endpoints mit Ihrem Frontend zu teilen, das die Daten empfängt, aber die Types befinden sich jetzt nur in Ihrem Backend-Service-Repository. Was kann man da tun? Ein weiteres Repository für gemeinsam genutzte Dinge hinzufügen und sie als internes npm-Package veröffentlichen? Plötzlich müssen Sie sich um die Versionierung und die Synchronisierung von Dependencies zwischen Ihren Repos kümmern.
  • In Entwicklungsumgebungen möchten Sie Ihren neuen Backend-Service zusammen mit Ihren bestehenden Diensten ausführen, können aber nicht einfach eine Docker Compose-Datei für beide hinzufügen, da Sie nicht wissen können, wo sich das andere Repo mit dem Dockerfile auf den Rechnern der anderen Entwickler:innen befindet oder ob es überhaupt vorhanden ist. Und nun? Das Docker-Image erstellen und in einer internen Registry veröffentlichen? Das wollen Sie sicher nicht bei jeder Änderung machen.
  • Irgendwie ist Ihr Projekt schon wieder deutlich kommunikationsintensiver geworden, denn jetzt müssen Sie sich auch um die Versionierung kümmern und vermeiden, dass Änderungen an Ihrem Backend-Service zu einem Breaking Change führen, da Sie den Service nicht mehr zusammen mit dem Rest Ihrer Anwendung deployen können. Wenn Sie also nicht aufpassen, könnte irgendwann eine Version Ihres Frontends online sein, die Daten erwartet, die die neue Version des Backend-Services nicht mehr hat, was bedeutet, dass ihre Seite wahrscheinlich kaputtgeht.
  • Zu guter Letzt hat jetzt auch die Arbeit an der neuen Marketing-Website begonnen und das Team fragt, ob Sie die UI-Komponentenbibliothek irgendwie teilen können. Schließlich wollen sie die Komponenten nicht noch einmal selbst implementieren. Offenbar müssen Sie jetzt noch ein weiteres Package veröffentlichen und versionieren. Aber Sie können die Komponenten nicht einfach weitergeben oder sie sogar in ein anderes Repository auslagern, da sie von Ihrer Frontend-Anwendung abhängig sind, die Sie mit Next.js gebaut haben, aber das Team der Marketing-WebSeite verwendet Gatsby.

Wow, das hat sich zu einem echtes Chaos entwickelt, oder? Irgendwie verbringen alle Ihre Entwickler:innen die meiste Zeit mit administrativen Aufgaben und streiten sich über die Art der Zusammenarbeit, anstatt tatsächlich an neuen Features zu arbeiten. Wie sind wir hier gelandet? Es gibt doch sicher einen besseren Weg?

Serverschränke in einem dunklen Raum mit sauber strukturierter Verkabelung

Foto von Taylor Vick auf Unsplash

Eine gute Analogie für diese Situation ist ein Serverschrank: Bei einem sehr kleinen Netzwerk reicht es aus, alle Kabel direkt auf dem kürzesten Weg zu verbinden, um schnell voranzukommen. Sobald Ihr Netzwerk jedoch wächst, kann dieser Ansatz zu einem unkontrollierbaren Durcheinander führen, bei dem niemand mehr weiß, welches Kabel wohin gehört und warum, was die Fehlersuche oder das Hinzufügen einer neuen Verbindung zu einer sehr komplexen und zeitaufwändigen Aufgabe macht.

Ein nachhaltigerer Weg wäre es, zuerst zu überlegen, wie die verschiedenen Teile des Systems verbunden werden sollen, dann zu entscheiden, wo die Kabel langlaufen sollen und wo nicht und sie dann zu gruppieren, zuzuschneiden und gut zu beschriften.

In unserer Webanwendung sind die Kabel die Abhängigkeiten zwischen verschiedenen Teilen der Software. Schauen wir uns an, wie wir sie mit Hilfe eines Monorepo-Ansatzes entwirren können:


Wie funktionieren Monorepos?

Ein Monorepo ist ein einzelnes Git-Repository, das mehrere npm-Packages in einer strukturierten Weise enthält. Packages sind im Wesentlichen Unterordner mit ihrer eigenen package.json Datei, die vom Package Manager erkannt werden und als Dependencies zu anderen Packages im gleichen Repository hinzugefügt werden können. Die Packages werden vom Package Manager automatisch miteinander verknüpft. Bei diesen Packages kann es sich um Libraries oder Anwendungen wie einen Backend-Service handeln. Die einzelnen Packages können (müssen aber nicht) in einer npm-Registry veröffentlicht werden. Ein einzelnes Package innerhalb eines Monorepos wird oft auch als Workspace bezeichnet.

Die beliebtesten Anwendungsfälle von Workspaces sind Libraries mit optionalen Plugins oder Tools, die als einzelne npm-Packages verteilt werden. Namhafte Beispiel-Repositories sind React, Babel und Storybook. Sie werden in diesem Artikel allerdings lernen, wie dieses Setup auch die meisten Probleme im obigen Beispielszenario verhindern kann.

Verzeichnisstruktur des React-Repositories auf GitHub

Das React-Repository ist ein Monorepo mit momentan 37 Packages

Was brauche ich, um Workspaces einzurichten?

Alle gängigen Package Manager unterstützen bereits Workspaces: Yarn v1 und v2, npm ab Version 7 (die mit Node.js v15 veröffentlicht wurde) und pnpm.

Die Implementierung von Workspaces und deren Funktionen können sich zwischen den Package Managern unterscheiden. Wir werden in unseren Beispielen Yarn v1 verwenden.

Es gibt verschiedene andere Tools, die zusätzlich zu den Package Managern weitere Funktionen für Monorepos anbieten, wie z.B.:

  • Lerna, das bei der Versionierung und Veröffentlichung der Packages in Ihrem Monorepo helfen kann
  • Nx und Turborepo, die helfen, Multi-Package-Builds in Monorepos zu beschleunigen. Nx kann auch beliebte Services wie ein React-Frontend, ein Express-Backend, ein Storybook und ein komplettes Testing-Setup in Ihrem Monorepo bootstrappen.

Wir werden diese Tools später betrachten. Sie sind nicht unbedingt nötig, um mit einem Monorepo zu beginnen, aber sie können sehr hilfreich sein, sobald das Monorepo weiter wächst.

Dies ist nur eine kleine Auswahl der Tools, die bei der Verwaltung von Monorepos helfen können. Für fortgeschrittene Anwendungsfälle ist die kuratierte Awesome Monorepo-Liste ein guter Ausgangspunkt.

Eine beispielhafte Repository-Struktur

Um auf unser Beispiel vom Anfang zurückzukommen, könnte ein Ansatz zur Strukturierung der Anwendung folgender sein:

(repository root directory)
└── packages
    ├── apps
    │   ├── e2e-tests
    │   ├── marketing-site
    │   ├── payment-service
    │   ├── storybook
    │   ├── user-service
    │   └── web-app
    └── libs
        ├── cms-client
        ├── payment-client
        ├── shared
        └── ui-components

Es wäre auch möglich, alles direkt in den /packages-Ordner zu legen, aber die Unterscheidung zwischen Apps und Libs kann es einfacher machen, einen Überblick darüber zu bekommen, was jedes Package tut:

  • Apps: sind Dienste, die unabhängig voneinander gebaut, ausgeführt und deployed werden können: Webanwendungen, Backend-Dienste, Marketing-Landingpages, Dokumentations-Websites oder eine End-to-End-Testsuite
  • Libs: können als Dependencies zu Apps oder anderen Libs hinzugefügt werden

Jedes Package kann so initialisiert werden, als wäre es in einem eigenen Repository: Verzeichnis anlegen, eine package.json Datei hinzufügen, Dependencies installieren und Skripte wie build, start usw. hinzufügen, je nachdem was das Package tut.

Zusätzlich fügen wir nun eine weitere package.json-Datei im Rootverzeichnis des Monorepos mit folgendem Inhalt hinzu:

{
  "name": "my-product",
  "version": "0.0.0",
  "license": "UNLICENSED",
  "private": true,
  "engines": {
    "yarn": "1"
  },
  "workspaces": [
    "packages/apps/*",
    "packages/libs/*"
  ],
  "scripts": {},
  "devDependencies": {}
}

Das Feld workspaces teilt dem Package Manager mit, wo sich unsere Packages befinden. Indem wir "private": true setzen, verhindern wir, dass das Root Package in der npm-Registry veröffentlicht wird. Wenn wir das nächste Mal yarn im Rootverzeichnis ausführen, können wir die Workspace-Funktionen nutzen.

Wir haben im Moment noch keine scripts oder devDependencies; wir werden sie später in Teil 2 des Artikels hinzufügen, um Tasks anzulegen, die mehrere Packages betreffen, wie z.B. die Ausführung aller Testsuites oder die Veröffentlichung aller aktualisierten Packages.

Und wie hilft mir das jetzt?

Zunächst einmal ist die Verwendung von Workspaces allein kein Allheilmittel für all die potenziellen Probleme, die ich zu Beginn des Artikels erwähnt habe. Sie können Ihr Projekt auch mit Workspaces noch verpfuschen, aber mit diesem Ansatz können Sie eine saubere Strukturierung Ihres Projekts bewahren, was dazu beitragen kann, die Komplexität des Projekts überschaubar zu halten, selbst wenn es auf mehrere Dienste anwächst, die zusammenarbeiten müssen um neue Full-Stack-Features zu ermöglichen.

Hier sind einige der wichtigsten Vorteile:

Vorteil 1: Besserer Überblick über Abhängigkeiten

Indem wir unsere Anwendung in mehrere Packages aufteilen, können wir automatisch einen besseren Überblick darüber behalten, wofür bestimmte Dependencies verwendet werden, da die Abhängigkeitsliste jedes Packages viel kürzer sein wird als die eine riesige Liste, die Sie vorher hatten. Das bietet bereits eine gewisse Form der Dokumentation, ohne dass man tatsächlich eine Dokumentation schreiben muss. Sie ist bereits im Code enthalten.

In unserem Beispiel können wir das Frontend unserer Anwendung in mehrere Packages aufteilen:

  • web-app: Die Haupt-Frontendanwendung in einem Framework Ihrer Wahl, z.B. Next.js.
  • ui-components: UI-Komponenten, die in Ihrer Anwendung verwendet werden. Dieses Package benötigt nur ein paar Abhängigkeiten: React, einen Style-Processor wie Sass oder styled-components, TypeScript und vielleicht 2-3 weitere Packages.
  • storybook: hat nur Abhängigkeiten, um die Storybook-Website zu bauen und auszuführen wie Storybook selbst, sowie einige Storybook-Plugins und Webpack. Die eigentlichen Storybook-Stories verbleiben im Package ui-components zusammen mit der Implementierung der jeweiligen Komponenten.
  • e2e-tests: Enthält End-to-End-Tests für Ihre Anwendung und die Dependencies, um sie auszuführen, z.B. Cypress oder Playwright.
  • cms-client und payment-client: Clients zur Interaktion mit Ihren Backend-Services, die nur Dependencies zum Fetchen von Daten benötigen, wie z.B. Axios oder einen GraphQL-Client.
  • shared: Enthält gemeinsam genutzten Code wie Konfiguration, Übersetzungen, Typescript-Types oder Test-Fixtures und braucht neben TypeScript möglicherweise gar keine (Dev-)Dependencies.

Es ist möglich, dass einige Ihrer Packages die gleichen Dependencies haben. In diesem Fall fügen Sie sie einfach jedem Package einzeln hinzu. Das ist OK, da der Package Manager automatisch die Dependencies für das gesamte Monorepo im Ordner node_modules des Repository-Rootverzeichnisses zentralisiert und dedupliziert. Achten Sie nur darauf, dass die Versionen der gleichen Abhängigkeiten zwischen den Packages synchron bleiben, um Mehrfachinstallationen desselben Packages zu vermeiden.

Vorteil 2: Bessere Wiederverwendbarkeit

Tools wie Storybook ermöglichen es uns, unsere UI-Komponenten unabhängig von der Anwendung, in der sie verwendet werden, zu erstellen, testen und dokumentieren. Wenn wir unsere UI-Komponenten in ihrem eigenen, unabhängigen Package erstellen, gewinnen wir eine weitere Ebene der Unabhängigkeit von unserer web-app: Da die Komponenten nun nur noch auf die Dependencies ihres eigenen Packages zugreifen können, sind wir nicht mehr versucht, implizite Abhängigkeiten der Web-App wie Next.js-Funktionen oder app-spezifische Konfigurationsvariablen zu verwenden, was die Komponenten wieder an diese eine Anwendung koppeln würde. Wir könnten sie nur verwenden, indem wir Next.js oder die gesamte Webanwendung als Abhängigkeit zu unserem Package ui-components hinzufügen, was sich selbst für Junior Developer falsch anfühlen würde.

Das Storybook von IBM's Carbon Design System

Component Libraries können dabei helfen, bessere Komponenten zu erstellen, auch wenn sie nur von einer Anwendung verwendet werden.

Stattdessen sind wir gezwungen, unsere Komponenten so zu entwickeln, dass sie für sich alleine funktionieren, aber leicht in andere Anwendungen integriert werden können. Das verbessert in der Regel die Codequalität, da man sich mehr Gedanken über die Schnittstelle der Komponenten machen muss: Wenn Sie eine Hyperlink-Komponente haben, die auch in Next.js funktionieren muss, nutzen Sie forwardRef. Wenn Sie eine Input-Komponente haben, die einen State in der Zielanwendung manipulieren soll, erlauben Sie, Event-Handler als Props zu übergeben. Wenn Sie eine Komponente haben, die Sie in einem End-to-End-Test Ihrer Anwendung testen wollen, erlauben Sie, beliebige Props wie data-testid zu vergeben.

Da unsere Komponente nun universell funktioniert, können wir sie ohne zusätzlichen Aufwand auch im Package marketing-site verwenden. Da die Tests und die Dokumentation der Komponenten im ui-components-Package zentralisiert sind, sparen wir uns doppelte Arbeit. Wenn später eine ganz andere Anwendung aus einer anderen Niederlassung unseres Unternehmens die Komponenten ebenfalls nutzen möchte, könnten wir dies durch die Veröffentlichung des Packages in einer (eventuell internen) npm-Registry erreichen.

Da storybook selbst auch in einem eigenen Package liegt, können wir es bei Bedarf leicht durch ein anderes Tool ersetzen, ohne das Package ui-components zu beeinflussen, oder schrittweise zu einem anderen Tool migrieren, indem wir einfach ein anderes Package hinzufügen.

Wenn wir in unserem web-app-Package noch Komponenten brauchen, die von Next.js abhängig sind, können wir sie einfach direkt in diesem Package belassen. Das ist OK, da wir sie ohnehin nicht in anderen Anwendungen verwenden können.

Vorteil 3: Bessere Entkopplung und Testbarkeit

Im vorigen Beispiel haben wir gelernt, dass wir neben einer besseren Wiederverwendbarkeit in der Regel auch klarer definierte Schnittstellen zu den Teilen unserer Anwendungen entwerfen, die in eigene Packages ausgelagert werden. Dies hat den zusätzlichen Vorteil, dass es das Testen dieser Packages sowie der Anwendungen, die die Packages verwenden, sehr viel einfacher macht. Nehmen wir ein API-Client-Package als Beispiel:

In einer typischen Webanwendung würden wir vielleicht die Logik zum Fetchen von Daten direkt in unsere Frontend-Komponenten einfügen, etwa so:

import React, { useState, useEffect } from 'react';
import axios from 'axios';
const MyComponent = () => {
  const [data, setData] = useState([]);
  useEffect(async () => {
    const result = await axios(
      'https://my.api.service.com/api/v1/my-endpoint',
    );
    setData(result.data);
  });
  return (
    // ... display data
  );
}
export default MyComponent;

Vielleicht haben wir den API-Hostname aus einer Config-Datei importiert, oder vielleicht verwenden wir einen eigenen Hook zum Fetchen von Daten, aber das generelle Problem mit diesem Code bleibt: er ist sehr schwer zu testen.

Um die Data Fetching-Logik zu testen, muss der Test wissen, wie das Data Fetching funktioniert, um die Konfiguration und vielleicht den Request zu mocken. Da das Data Fetching Teil des Lebenszyklus der Komponente ist, muss unsere Testumgebung auch in der Lage sein, die Komponente zu rendern, um das Abrufen von Daten zu testen. Daher wird das Schreiben dieses Tests einige zusätzliche Abhängigkeiten für das Rendern der UI und das Mocken benötigen. Das ist zwar möglich, kann aber bereits so viel Aufwand bedeuten, dass es verlockend ist, den Test einfach gar nicht zu schreiben. Und selbst wenn Sie das tun, müssen Sie Ihren Test möglicherweise regelmäßig anpassen, wenn Sie etwas an der Komponente ändern, da der Test von den Implementierungsdetails Ihrer Datei abhängt.

Wenn wir nun für jede spezifische API in unserer Anwendung das Data Fetching in ein API-Client-Package auslagern, können wir vieles einfacher machen:

Unser API-Client-Package muss nur eine Init-Funktion bereitstellen, die eine Konfiguration wie einen API-Key entgegennimmt und dann ein Objekt oder eine Klasseninstanz mit Data Fetching-Methoden zurückgibt, welche jeweils optional Argumente entgegennehmen und eine Promise mit den Daten zurückliefern:

// in packages/apps/web-app/src/payment-client.ts
import { createPaymentClient } from '@my-product/payment-client';
import { paymentConfig } from './config';
export const paymentClient = createPaymentApiClient(paymentConfig);

// in packages/apps/web-app/src/component/my-component.tsx
import React, { useState, useEffect } from 'react';
import { paymentClient } from '../payment-client';
// ...
useEffect(async () => {
  const paymentStatus = await paymentClient.getPaymentStatus(userId, orderId);
// ...

Die Anwendung, die den Client verwendet, muss nun nicht mehr wissen, wie er intern funktioniert: Ob REST, GraphQL oder RPC verwendet wird, wohin die Requests gehen und wie die Responses aussehen spielt keine Rolle mehr. Um zu testen, wie unsere Komponente mit den API-Daten umgeht, müssen wir jetzt nur noch eine API-Client-Methode mocken, was viel weniger Arbeit bedeutet. Die Tests für die eigentliche Data Fetching-Logik würden bereits im API-Client-Package stattfinden.

Vorteil 4: Atomare Full-Stack Deployments

Das Hinzufügen neuer Funktionen zu einer Full-Stack-Anwendung erfordert normalerweise Änderungen an mehreren Stellen: Vielleicht erstellen wir einen neuen Backend-Endpunkt, ergänzen die Dokumentation (z.B. eine OpenAPI-Spezifikation), aktualisieren das Frontend, um den neuen Endpunkt abzufragen und die Response zu verarbeiten, im Client-State zu speichern und die Daten dann in einer neuen UI-Komponente mit neuen grafischen Assets anzuzeigen.

Je nach Organisationsstruktur kann dies optimalerweise von einem Full-Stack-Team allein durchgeführt werden. Wenn sich der Code für alle betroffenen Dienste im selben Repository befindet, kann das gesamte Feature mit einem einzigen Pull Request (oder Merge Request, wenn Sie GitLab nutzen) implementiert werden. Das bedeutet, dass das gesamte Full-Stack-Feature in einem Durchgang getestet, gebaut und nach Production deployed werden kann. Mithilfe von Preview-Deployments kann das interdisziplinäre Team Code- und Design-Reviews durchführen und bei Bedarf das Feature manuell testen.

Natürlich braucht man dafür ein CI-Setup, das all diese Funktionen unterstützt. Das ist für sich genommen bereits ein sehr komplexes Thema, und die Implementierung kann sich je nach CI-Provider und Deployment-Setup drastisch unterscheiden, sodass wir im Rahmen dieses Artikels nicht näher darauf eingehen können.

Der Aufwand lohnt sich jedoch, da Sie Versionskonflikte zwischen Ihren Service-Deployments weitgehend vermeiden können und nicht mehr in Situationen wie “Pull Request #452 in Repo A kann erst nach #78 in Repo B und #113 in Repo C gemerged werden” geraten, die Ihrem Team unnötige Wartezeit und Kommunikationsaufwand bescheren.

Fazit

In diesem Artikel haben wir einige Probleme vorgestellt, die bei der Skalierung von Webanwendungen von MVPs zu vollwertigen Multi-Service-Anwendungen auftreten können. Wir haben Workspaces als einen Weg vorgestellt, um implizite Abhängigkeiten und unkontrollierte Komplexität zu vermeiden, die zu diesen Problemen beitragen können und letztlich zu Anwendungen führen, die unbeherrschbar werden, wenn sie wachsen.

Anhand einer beispielhaften Webanwendung haben wir ein High-Level-Projektsetup mit einem Monorepo skizziert und einige seiner Vorteile für Teams aufgezeigt, die webbasierte Anwendungen langfristig entwickeln und ausbauen.

Lesen Sie Teil 2 des Artikels, um zu lernen…

  • wie man beliebte Tools wie TypeScript, Jest und ESlint in einer Monorepo konfiguriert
  • wie man Zeit bei der Ausführung von Tasks für mehrere Packages spart
  • wie man einzelne Packages in einem Monorepo veröffentlichen kann
  • wie man typische Herausforderungen in einem Monorepo-Setup überwindet