Luca Mele
Luca Mele

Architecture

Weg mit dem Java BFF: Warum kleine Teams auf Fullstack mit Next.js setzen sollten

Weg mit dem Java BFF: Warum kleine Teams auf Fullstack mit Next.js setzen sollten
Zurück zu allen Beiträgen
·11 Min. Lesezeit
KI-generierten Podcast anhören
Alle Podcasts →

Hier ist ein Setup, das ich bei mehreren Firmen gesehen habe: ein kleines Scrum-Team — vielleicht 6 bis 8 Leute — das ein React-Frontend und ein Java Spring Boot «Backend for Frontend» (BFF) betreut. Das BFF besitzt keine Business-Logik. Es holt Daten von Microservices, formt sie um und liefert sie als JSON an die React-App. Es ist ein Proxy mit Typen.

Diese Architektur ergab Sinn, als sie eingeführt wurde. Das Backend-Team schrieb Java, das Frontend-Team schrieb JavaScript, und das BFF war der Treffpunkt. Aber 2026, mit ausgereiftem TypeScript, kampferprobtem Next.js und Node.js, das die JVM bei I/O-Workloads übertrifft, lohnt sich die Frage: Sollten wir immer noch zwei Ökosysteme für im Grunde ein Produkt pflegen?

Ich werde den Fall für die Konsolidierung auf Next.js machen — aber ich werde auch ehrlich sein, wo Java gewinnt. Das ist kein heisser Take. Es ist eine Architekturentscheidung, die mit Zahlen getroffen werden sollte, nicht mit Gefühlen.

Das BFF-Pattern: Was es ist und warum es existiert

Das Backend-for-Frontend-Pattern, geprägt von Sam Newman, existiert aus gutem Grund: Verschiedene Clients (Web, Mobile, TV) brauchen verschiedene Datenformen von denselben Backend-Services. Statt das Frontend zu zwingen, mehrere API-Aufrufe zu orchestrieren und Daten umzuwandeln, macht das BFF das serverseitig.

In der Praxis machen die meisten BFFs, die ich in kleinen Teams gesehen habe, drei Dinge: mehrere Service-Aufrufe in eine Antwort aggregieren, Daten in die Form umwandeln, die die UI braucht, und Authentifizierung/Session-Management handhaben. Das war's. Keine komplexe Business-Logik. Keine schwere Berechnung. Nur I/O-Orchestrierung.

Die Frage ist nicht, ob du eine BFF-Schicht brauchst — wahrscheinlich schon. Die Frage ist, ob diese Schicht eine separate Java-Anwendung sein muss, die von separaten Leuten mit einer separaten Build-Pipeline gewartet wird.

Derselbe Endpoint, zwei Welten

Schauen wir uns denselben BFF-Endpoint in beiden Stacks an. Hier die Java-Version — sauberer, gut strukturierter Spring Boot Code:

// Java Spring Boot BFF — ProductController.java
@RestController
@RequestMapping("/api/products")
public class ProductController {

    @GetMapping("/{id}")
    public ProductResponse getProduct(@PathVariable Long id) {
        Product product = productService.findById(id);
        return new ProductResponse(
            product.getId(),
            product.getName(),
            product.getPrice().toPlainString(),
            product.isAvailable()
        );
    }
}

// ProductResponse.java — a separate DTO class
public record ProductResponse(
    Long id,
    String name,
    String price,
    boolean available
) {}

Und hier das Next.js-Äquivalent:

// Next.js — same logic, same language as the frontend
// app/api/products/[id]/route.ts
import { getProduct } from '@/lib/products';
import type { Product } from '@/types/product'; // shared with UI

export async function GET(
  _req: Request,
  { params }: { params: { id: string } }
) {
  const product: Product = await getProduct(params.id);
  return Response.json(product);
}

// The Product type is the same one the React component uses.
// No code generation. No schema drift. No mapping layer.

Der Code ist ähnlich lang. Der Unterschied liegt nicht im Endpoint — er liegt in allem drumherum. Die Java-Version erfordert ein separates Projekt, separate Abhängigkeiten (Spring Boot, Jackson, Gradle/Maven), separates CI/CD, separates Docker-Image, separates Deployment. Die Next.js-Version ist eine Datei im selben Projekt, in dem deine React-Komponenten leben.

Argument 1: Schema-Sharing eliminiert eine ganze Fehlerklasse

Das ist das stärkste Argument für die Vereinheitlichung, und es ist nicht knapp. Wenn dein BFF Java und dein Frontend TypeScript ist, hast du zwei Typsysteme, die synchron bleiben müssen. Die typische Lösung ist OpenAPI/Swagger-Codegenerierung: Die Java-Seite generiert eine Spec, das Frontend generiert TypeScript-Typen daraus.

Das funktioniert — bis es nicht mehr funktioniert. Die Spec wird veraltet. Jemand ändert das Java-DTO, vergisst aber die Neugenerierung. Der CI-Schritt, der Typen generiert, läuft nach dem Merge des PRs. Ein Feld wird in Java umbenannt, aber der alte Name bleibt in den generierten Typen bis zum nächsten vollständigen Rebuild. Ich habe Produktions-Bugs aus jedem einzelnen dieser Szenarien gesehen.

Mit einem einheitlichen TypeScript-Stack verschwindet dieses gesamte Problem. Nicht «wird besser» — verschwindet. Du definierst den Typ einmal, und sowohl die API-Route als auch die React-Komponente importieren dieselbe Datei. Benenne ein Feld um, und der Compiler findet sofort jede Verwendung. Teile eine Validierungsfunktion zwischen Server und Client. Kein Generierungsschritt, keine Synchronisation, kein Drift.

// types/product.ts — ONE type, used everywhere
export interface Product {
  id: string;
  name: string;
  price: number;
  available: boolean;
  variants: Variant[];
}

// Server: used in API route, validation, DB query typing
// Client: used in React component props, form state, tests
// No OpenAPI generation, no code-gen CI step, no version mismatch

// Shared validation — also runs on both sides
export function validateProduct(p: Partial<Product>): string[] {
  const errors: string[] = [];
  if (!p.name?.trim()) errors.push('Name is required');
  if (p.price != null && p.price < 0) errors.push('Price must be positive');
  return errors;
}

Argument 2: Team-Effizienz und Bus-Faktor

In einem traditionellen Split brauchst du Spezialisten auf beiden Seiten. Die Java-Entwickler können nicht sinnvoll zum React-Code beitragen (und wollen es meist nicht). Die Frontend-Entwickler können das BFF nicht anfassen (und dürfen es oft nicht). Das erzeugt Engpässe: Ein Backend-Dev ist krank, und niemand kann die API ändern. Ein Frontend-Dev ist im Urlaub, und ein kritischer UI-Fix wartet.

Mit einem einheitlichen Stack kann jeder Entwickler an jeder Schicht arbeiten. Eine «Frontend»-Aufgabe, die eine API-Änderung braucht? Ein Entwickler, ein PR, ein Review. Keine teamübergreifende Koordination, keine API-Contract-Verhandlung, kein Warten darauf, dass die andere Seite zuerst deployed.

Der Bus-Faktor — die Anzahl der Leute, die von einem Bus erwischt werden können, bevor das Projekt steht — verbessert sich dramatisch. Statt 1-2 Leuten, die jede Seite verstehen, hast du 3-4 Leute, die das Ganze verstehen.

Und dann das Headcount-Argument. Wenn dein BFF eine dünne I/O-Schicht ist (was die meisten sind), brauchst du keine dedizierten Backend-Entwickler dafür. Drei starke TypeScript-Entwickler können zwei Backend- + zwei Frontend-Entwickler ersetzen, mit besserem Durchsatz, weil sie nicht durch Übergaben blockiert werden.

Traditional BFF setup (8 people):
┌─────────────────────────────────┐
│  2 Backend Devs (Java/Spring)   │ ← Own ecosystem, own tooling
│  1 Backend Lead                 │
├─────────────────────────────────┤
│  2 Frontend Devs (React/TS)     │ ← Different ecosystem
│  1 Frontend Lead                │
├─────────────────────────────────┤
│  1 QA · 1 Scrum Master          │
└─────────────────────────────────┘
  API contract sync: OpenAPI / Swagger
  Schema drift: constant risk
  Bus factor: 1 per side

Unified fullstack setup (6 people):
┌─────────────────────────────────┐
│  3 Fullstack Devs (TS/Next.js)  │ ← One ecosystem
│  1 Tech Lead                    │
├─────────────────────────────────┤
│  1 QA · 1 Scrum Master          │
└─────────────────────────────────┘
  Schema: shared TypeScript types
  Bus factor: any dev can work on any layer
  25% less headcount, same throughput

Argument 3: Ein Ökosystem statt zwei

Zwei Tech-Stacks bedeuten zwei Dependency-Management-Systeme (npm + Maven/Gradle), zwei CI-Pipelines, zwei Docker-Base-Images, zwei Sätze von Security-Patches zum Verfolgen, zwei Linter, zwei Formatter, zwei Test-Frameworks, zwei Debugger-Konfigurationen. Es geht nicht nur um die Runtime — es geht um den gesamten operativen Overhead.

Ein Ökosystem bedeutet eine package.json, eine CI-Konfiguration, ein Dockerfile, einen Satz Abhängigkeiten zum Auditieren, eine Linter-Konfiguration, eine Art Tests zu schreiben. Neue Entwickler arbeiten sich schneller ein, weil es eine Sache zu lernen gibt, nicht zwei.

Das klingt klein, aber es summiert sich. Jede CVE in einer Spring Boot-Abhängigkeit ist ein Ticket. Jedes grosse Java-Versions-Upgrade ist ein Projekt. Jeder Gradle-Plugin-Konflikt ist ein Nachmittag. Mit einem Ökosystem halbierst du diese operative Steuer.

Argument 4: Cloud-Performance und Kosten

Für I/O-gebundene BFF-Workloads — was die meisten BFFs sind — braucht Node.js deutlich weniger Speicher und startet dramatisch schneller als die JVM. Das ist relevant für Serverless (Cold Starts) und containerisierte Deployments (Dichte und Scale-up-Geschwindigkeit).

Der Memory-Overhead der JVM kommt von ihrer Runtime: Class-Loading, JIT-Compilation-Warmup, Garbage-Collector-Metadaten. Eine Spring Boot-App, die nichts tut, braucht 200-400 MB. Ein Node.js-Server, der nichts tut, braucht 30-50 MB. Für ein BFF, das JSON fetcht, transformiert und ausliefert, ist dieser Overhead reine Verschwendung.

Node.js' Event Loop ist auch architektonisch für BFF-Workloads geeignet. Ein BFF verbringt die meiste Zeit mit Warten auf Antworten von Upstream-Services — genau die Art von I/O-lastiger, CPU-leichter Arbeit, bei der Node.js' single-threaded async Modell Thread-per-Request-Modelle übertrifft.

Cloud cost comparison (BFF serving JSON, ~1000 req/s):

Java Spring Boot (JVM):
  Memory:     512 MB–1 GB baseline (JVM heap)
  Cold start: 3–8 seconds
  Container:  ~350 MB image (JDK + fat JAR)
  Monthly:    ~$180–280 (2 vCPU, 1 GB RAM)

Node.js (Next.js API routes):
  Memory:     80–150 MB baseline
  Cold start: 200–800 ms
  Container:  ~80 MB image (Alpine + Node)
  Monthly:    ~$60–120 (1 vCPU, 512 MB RAM)

For I/O-bound BFF workloads (fetch → transform → respond),
Node.js uses 50–70% less memory and starts 5–10x faster.
Java wins at CPU-heavy computation, not at proxying JSON.

Die ehrlichen Gegenargumente: Wo Java gewinnt

Wenn das eine einfache Entscheidung wäre, hätten alle sie schon getroffen. Java hat echte, signifikante Vorteile, die du ehrlich abwägen solltest:

Typsicherheit und Korrektheit: Javas Typsystem ist strenger und ausgereifter als TypeScripts. TypeScript hat «any»-Escape-Hatches, strukturelle Typisierung, die Mismatches verstecken kann, und keine Runtime-Typprüfung. Javas nominale Typen, Checked Exceptions und Compile-Time-Garantien fangen ganze Fehlerkategorien ab, die TypeScript durchlässt. Für sicherheitskritische Systeme (Finanz, Gesundheit) ist das wichtig.

CPU-intensive Arbeit: Wenn dein BFF signifikante Berechnungen macht — Datenaggregation, Verschlüsselung, komplexe Transformationen — wird der JIT-Compiler und das optimierte Threading-Modell der JVM Node.js deutlich übertreffen. Node.js ist von Design single-threaded. Man kann das mit Worker Threads umgehen, aber man kämpft gegen die Architektur.

Ausgereiftes Ökosystem für Enterprise: Spring Boots Ökosystem für Security (Spring Security), Observability (Micrometer/Actuator) und Enterprise-Integration (Spring Cloud) ist tief und kampferprobt. Die Node.js-Äquivalente existieren, sind aber weniger ausgereift und weniger standardisiert.

Talent und Recruiting: In vielen Unternehmen sind Java-Entwickler leichter zu finden als starke TypeScript-Fullstack-Entwickler. Wenn der Talentpool deines Unternehmens Java-lastig ist, erzeugt eine Migration Recruiting-Reibung.

Bestehende Investition: Wenn du Jahre gut getesteten, gut strukturierten Java-BFF-Code hast mit umfassenden Integrationstests, solidem Monitoring und institutionellem Wissen — hat ein Rewrite reale Kosten und reale Risiken. Die neue Version wird neue Bugs haben. «Nie umschreiben» ist zu stark, aber «nicht ohne zwingenden Grund umschreiben» ist solides Engineering.

Wann migrieren, wann bleiben

Migriere wenn: Dein BFF eine dünne I/O-Schicht mit minimaler Business-Logik ist. Dein Team klein ist (unter 10) und Übergaben zwischen Frontend/Backend ein Engpass sind. Du bereits TypeScript im Frontend nutzt. Schema-Drift regelmässig Bugs verursacht. Du signifikant Zeit mit der Pflege von zwei CI/CD-Pipelines verbringst. Cloud-Kosten ein Thema sind und du I/O-gebundene Workloads fährst.

Bleib bei Java wenn: Dein BFF signifikante Business-Logik oder Berechnungen hat. Du in einer regulierten Branche bist, wo Javas Typsicherheit und Tooling (statische Analyse, formale Verifikation) Compliance-Wert bietet. Dein Team tiefe Java-Expertise und begrenzte TypeScript-Erfahrung hat. Das BFF stabil ist, sich selten ändert und keine Developer-Friction verursacht. Du Teil eines grösseren Java-Ökosystems bist, wo Spring Cloud Service Mesh, gemeinsame Libraries und Enterprise-Tooling signifikanten Wert bieten.

Die schlechteste Option ist eine Halb-Migration: einige Endpoints nach Next.js verschieben, während andere in Java bleiben. Das gibt dir drei Ökosysteme zu pflegen statt zwei. Wenn du migrierst, committe dich voll. Wenn Java die richtige Wahl ist, steh zu dieser Entscheidung und investiere darin, das Java-BFF exzellent zu machen.

Wie man inkrementell migriert

Wenn du dich für die Konsolidierung entscheidest, mach keinen Big-Bang-Rewrite. Nutze das Strangler-Fig-Pattern: Stelle Next.js als Reverse Proxy davor, migriere einen Endpoint nach dem anderen und fahre den Java-Service herunter, wenn der letzte Endpoint umgezogen ist.

Beginne mit den einfachsten, am meisten geänderten Endpoints — denen, die die meiste Developer-Friction verursachen. Migriere zuerst die Typen (die sind jetzt geteilt), dann die Routes, dann die Tests. Jede Migration ist ein kleiner, reviewbarer PR.

Betreibe beide Systeme parallel mit Traffic-Vergleich (Shadow Mode), um Verhaltensunterschiede zu finden. Das Java-BFF bedient den Produktionstraffic, während die Next.js-Version dieselben Requests im Hintergrund verarbeitet. Vergleiche die Antworten. Wenn sie übereinstimmen, schalte um.

Dieser Ansatz lässt dich das Konzept mit niedrigem Risiko beweisen, Team-Vertrauen aufbauen und jederzeit aufhören, wenn die Migration keinen Wert liefert. Architekturentscheidungen sollten wenn möglich reversibel sein — diese ist es.

Das Fazit

Für ein kleines Scrum-Team, das ein dünnes Java-BFF neben einem React-Frontend betreibt, bietet der einheitliche Next.js-Ansatz: 25% weniger Headcount bei gleichem Durchsatz, null Schema-Drift zwischen API und UI, höheren Bus-Faktor (jeder Dev kann an jeder Schicht arbeiten), 50-70% niedrigere Cloud-Kosten für I/O-Workloads und den halben operativen Overhead.

Aber es ist keine universelle Antwort. Javas Stärken bei Typsicherheit, CPU-Performance und Enterprise-Tooling sind real. Die Entscheidung hängt von deinem Team, deinem Workload und deinen Rahmenbedingungen ab.

Wogegen ich argumentiere, ist der Default — die Annahme, dass «Backend = Java, Frontend = React» der einzige Weg ist, ein Team zu strukturieren. Für viele kleine Teams ist es nicht der optimale Weg. Die beste Architektur ist die, die deinem Team erlaubt, mit der geringsten Reibung zu liefern. Manchmal sind das zwei Ökosysteme mit klaren Grenzen. Zunehmend, für BFF-Workloads, ist es eines.