One Repo to Rule Them All

Java und Angular unter einem Dach mit Nx

Java und Angular unter einem Dach mit Nx

One Repo to Rule Them All

Java und Angular unter einem Dach mit Nx


Die Zeiten der Monolithen sind vorbei. Heute hat man meist ein Backend und ein Frontend, oft auch mehrere Microservices und -frontends – und für jedes ein eigenes Repository. Diese Trennung bringt aber auch einige Probleme mit sich. Nx verspricht, die meisten davon durch seinen Ansatz eines integrierten Monorepos zu lösen und darüber hinaus einen Mehrwert zu liefern. Schauen wir uns einmal genauer an, was dahintersteckt.

Die Strategie „Ein Repo für ein Projekt“ ist weit verbreitet und hat sich über viele Jahre bewährt. Warum also etwas daran ändern? Wenn wir ehrlich sind, fallen uns wahrscheinlich sofort einzelne Fälle ein, in denen wir uns über die verschiedenen Repos geärgert haben und unsere Arbeit dadurch ineffizienter wurde. Anhand einer kleinen Beispielanwendung wollen wir uns das genauer anschauen und einen Finger auf die Probleme des Multi-Repo-Ansatzes legen. Anschließend werden wir sehen, wie wir zu einem Monorepo migrieren können, das mit Nx verwaltet wird, und welche Verbesserungen wir dadurch erreichen können.

Die minimale Beispielanwendung

Wir haben ein Java-Backend mit Spring-Boot, das über einen Controller einen Benutzer zurückgeben kann. Der folgende Code zeigt das User-Modell im Backend, Listing 1 den Controller im Backend:

public record User(String id, String userName) {
}

Listing 1: Controller im Backend

@RestController()
@RequestMapping("/api/user")
public class UserController {
  @GetMapping(value = "/current", produces = "application/json")
  public User getCurrentUser() {
    return new User("01", "admin");
  }
}

Das Frontend besteht aus einer Angular-Anwendung, die diesen Benutzer abruft und darstellt. Der folgende Code zeigt das User-Modell im Frontend, Listing 2 die Komponente im Frontend.

export type User = {
  id: string
  userName: string
}

Listing 2: Komponente im Frontend

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [AsyncPipe],
  template: `
    @if (user$ | async; as user) {
      <h1>Welcome, {{ user.userName }}</h1>
    }
  `,
})
export class AppComponent {
  private httpClient = inject(HttpClient)

  user$ = this.httpClient.get<User>(`${environment.baseUrl}/api/user/current`).pipe(
    shareReplay({bufferSize: 1, refCount: true})
  )
}

Kein gemeinsames Projekt-Set-up

Aufgrund der unterschiedlichen Programmiersprachen zeigt sich hier bereits die erste kleine Hürde: Das Backend wird mit Gradle gebaut, das Frontend mit npm. Je nach Projekt müssen also unterschiedliche Befehle zum lokalen Starten (./gradlew bootRun vs npm run start) oder zum Ausführen von Tests (./gradlew test vs npm run test) verwendet werden. Hätten wir noch weitere Java- oder TypeScript-Projekte, müssten wir unsere IDE und Linter für jedes dieser Projekte separat konfigurieren und bei Änderungen darauf achten, immer alles synchron zu halten. Gleiches gilt für die Versionen der verwendeten Bibliotheken oder gar der Programmiersprache. Verwenden wir hingegen überall die gleiche Version, erhöht das die Kompatibilität, reduziert möglicherweise die Größe des Bundles und vereinfacht Dinge, wie z. B. Open-Source-Software-Clearing-Prozesse.

Das Problem mit der Konsistenz

Wenn wir nun in unserer Anwendung eine Änderung vornehmen, die sowohl das Backend als auch das Frontend betrifft, treten Probleme auf, die wohl jeder Entwickler kennt. Angenommen, wir wollen unser Datenmodell anpassen und das Attribut userName in name umbenennen. Damit Backend und Frontend weiterhin miteinander kommunizieren können, muss diese Umbenennung in beiden Projekten gleichzeitig vonstattengehen. Ansonsten versucht das Frontend, in der Anzeige auf ein Attribut zuzugreifen, das vom Backend unter einem anderen Namen gesendet wird.

„Gleichzeitig“ ist in zwei verschiedenen Repositories aber nicht möglich. Und da macht es auch keinen Unterschied, ob man als Fullstack-Entwickler beide Änderungen sofort selbst durchführen kann. Sie müssen notgedrungen in zwei einzelne Commits aufgeteilt werden – einen im Backend und einen im Frontend. Und plötzlich entstehen Probleme: Bei einem Kollegen, der nur eines der Projekte lokal aktualisiert hat, funktioniert plötzlich etwas nicht mehr. Die End-to-End-Tests in der Pipeline schlagen unweigerlich beim Push der ersten Änderung fehl. Beim Debuggen möchte man noch einmal eine ältere Version der Projekte auschecken, um zu sehen, ob der Bug dort auch schon vorhanden war – hat aber Schwierigkeiten, miteinander kompatible Versionen zu finden. Das alles ist nicht das Ende der Welt und für einen erfahrenen Entwickler lediglich lästig. Aber es kostet Zeit und Aufmerksamkeit, die man besser in die Weiterentwicklung der Anwendung gesteckt hätte.

Aber nicht nur das lokale Set-up ist davon betroffen: Auch beim Deployment muss immer darauf geachtet werden, kompatible Backend- und Frontend-Stände zu deployen. Da diese in unterschiedlichen Repositories liegen und daher jeweils eigene Pipelines haben, muss für das Deployment eine dritte Pipeline eingerichtet werden, die die Artefakte der beiden anderen verwendet und gleichzeitig deployt. Das erschwert zwangsläufig die Nachvollziehbarkeit des Build-Prozesses.

Geteilter Code? Einfach nur umständlich!

Zugegeben, in diesem Beispiel ist es eher abwegig, Code zwischen dem Backend in Java und dem Frontend in TypeScript zu teilen. Hätten wir stattdessen ein NestJS-Backend, sähe die Sache ganz anders aus, genauso bei Microservices und Microfrontends. Der übliche Weg, Code in einem solchen Fall zu teilen, besteht darin, eine eigene Bibliothek zu erstellen, den geteilten Code darin abzulegen und die Bibliothek in eine Artifactory zu laden. Die eigentlichen Projekte verwenden die Bibliothek dann als Abhängigkeit. Änderungen am gemeinsam genutzten Code ziehen jedoch neue Versionen der Bibliothek, Aktualisierungen der Abhängigkeiten und damit viel zusätzlichen Aufwand nach sich. Die Alternative, den geteilten Code als Git-Submodule einzubinden, ist leider ähnlich umständlich.

Aber auch zwischen unserem Backend und dem Frontend gibt es Dinge, die wir gern teilen würden. So müssen wir derzeit das User-Modell in beiden Projekten definieren und darauf achten, dass die Definitionen konsistent sind. Einfacher und fehlerresistenter wäre es, wenn wir aus unserem Backend eine OpenAPI-Spezifikation generieren könnten, die wir dann zur automatischen Codegenerierung im Frontend verwenden. Das ist aber in der aktuellen Konfiguration mit zwei Repositories nicht ohne Umwege möglich.

Erster Lösungsansatz: ein Monorepo

Als ersten, einfachen Schritt können wir Backend und Frontend einfach in ein gemeinsames Repository verschieben. Damit haben wir bereits die meisten Probleme gelöst: Backend und Frontend sind immer synchron und auch das Teilen von Code oder Ressourcen ist nun einfacher, da wir sie einfach in einen Ordner verschieben können, auf den alle Projekte Zugriff haben. Auch das Deployment verläuft nun automatisch synchron, da sich beide Projekte eine Pipeline teilen. Allerdings haben wir uns damit ein neues Problem ins Haus geholt: Durch die gemeinsame Pipeline bauen wir dort nun immer Backend und Frontend – selbst wenn sich in einem der Projekte gar nichts geändert hat. Auch das Tooling der einzelnen Teilanwendungen ist nach wie vor unterschiedlich und projektübergreifende, voneinander abhängige Aufgaben lassen sich nicht so einfach umsetzen.

Auftritt Nx

Hier kommt Nx (Kasten: „Was ist Nx?“) ins Spiel und bietet uns die Möglichkeit, unser Set-up weiter zu optimieren. Im weiteren Verlauf des Artikels werden wir die beiden Projekte in ein mit Nx verwaltetes Monorepo umwandeln, dieses konfigurieren und nach und nach die Vorteile dieses Set-ups kennenlernen.

Was ist Nx?

Nx [1] ist ein Open-Source-Build-System, das Werkzeuge und Techniken für die effiziente Verwaltung von Multi-Projekt-Repositorys bereitstellt. Ursprünglich für Angular-Anwendungen konzipiert, bietet es mittlerweile über entsprechende Plug-ins auch Unterstützung für andere Frontend-Frameworks wie React sowie für gänzlich andere Programmiersprachen wie Java oder C#.

Wir könnten nun einen komplett neuen Nx Workspace anlegen und unsere beiden Projekte nacheinander in diesen integrieren. Nx unterstützt auch nativ die Migration eines Angular-Projekts in ein integriertes Monorepo, sodass wir hier diesen Schritt wählen und uns etwas Arbeit sparen können.

Dazu führen wir im Frontend-Repository einfach npx nx@latest init --integrated aus und beantworten die Frage nach dem Remotecache mit skip – diese Option werden wir uns später genauer ansehen. Nx installiert zusätzliche Abhängigkeiten und passt anschließend die Projektstruktur etwas an. So wurde unser Frontend in einen Nx Workspace umgewandelt, der bereits eine Angular-App enthält. In Abbildung 1 sehen wir, wie sich die Projektstruktur dadurch geändert hat: Der Anwendungscode wurde in den Unterordner apps/frontend verschoben, angular.json wurde zu project.json und tsconfig.json wurde in einen projektspezifischen Teil (tsconfig.app.json und tsconfig.spec.json) sowie einen übergreifenden Teil (tsconfig.base.json) aufgeteilt. Hinweis: Es gibt weiterhin nur eine package.json auf Root-Ebene, Abhängigkeiten werden also global verwaltet.

sieben_nx_1

Abb. 1: Die Projektstruktur unseres Frontend-Projekts vor (links) und nach (rechts) der Migration nach Nx

Nach der Migration starten wir das Frontend nicht mehr mit npm run start, sondern mit npx nx serve frontend, Tests werden mit npx nx test frontend ausgeführt, gebaut wird mit npx nx build frontend. Um uns das npx in den Befehlen...