Luca Mele
Luca Mele

Architecture

Elimina il BFF Java: perché i piccoli team dovrebbero passare al fullstack con Next.js

Elimina il BFF Java: perché i piccoli team dovrebbero passare al fullstack con Next.js
Torna a tutti gli articoli
·11 min di lettura
Ascolta il podcast generato dall'IA
Tutti i podcast →

Ecco un setup che ho visto in diverse aziende: un piccolo team Scrum — forse 6-8 persone — che mantiene un frontend React e un «Backend for Frontend» (BFF) in Java Spring Boot. Il BFF non possiede logica di business. Recupera dati dai microservizi, li trasforma e li serve come JSON alla app React. È un proxy con tipi.

Questa architettura aveva senso quando è stata introdotta. Il team backend scriveva in Java, il team frontend in JavaScript, e il BFF era il punto d'incontro. Ma nel 2026, con TypeScript maturo, Next.js collaudato in battaglia e Node.js che supera la JVM per i workload I/O, vale la pena chiedersi: dovremmo ancora mantenere due ecosistemi per quello che è essenzialmente un unico prodotto?

Farò il caso per la consolidazione su Next.js — ma sarò anche onesto su dove Java vince. Questo non è un'opinione a caldo. È una decisione architetturale che dovrebbe essere presa con i numeri, non con i sentimenti.

Il pattern BFF: cos'è e perché esiste

Il pattern Backend for Frontend, coniato da Sam Newman, esiste per una buona ragione: client diversi (web, mobile, TV) hanno bisogno di forme di dati diverse dagli stessi servizi backend. Piuttosto che forzare il frontend a orchestrare multiple chiamate API e trasformare i dati, il BFF lo fa lato server.

In pratica, la maggior parte dei BFF che ho visto nei piccoli team fa tre cose: aggregare multiple chiamate di servizio in una risposta, trasformare i dati nella forma di cui la UI ha bisogno, e gestire autenticazione/gestione sessioni. Tutto qui. Nessuna logica di business complessa. Nessun calcolo pesante. Solo orchestrazione I/O.

La domanda non è se hai bisogno di un layer BFF — probabilmente sì. La domanda è se quel layer deve essere un'applicazione Java separata mantenuta da persone separate con una pipeline di build separata.

Lo stesso endpoint, due mondi

Guardiamo lo stesso endpoint BFF in entrambi gli stack. Ecco la versione Java — codice Spring Boot pulito e ben strutturato:

// 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
) {}

Ed ecco l'equivalente Next.js:

// 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.

Il codice è di lunghezza simile. La differenza non è nell'endpoint — è in tutto ciò che lo circonda. La versione Java richiede un progetto separato, dipendenze separate (Spring Boot, Jackson, Gradle/Maven), CI/CD separato, immagine Docker separata, deployment separato. La versione Next.js è un file nello stesso progetto in cui vivono i tuoi componenti React.

Argomento 1: la condivisione dello schema elimina un'intera classe di bug

Questo è l'argomento più forte per l'unificazione, e non è nemmeno vicino. Quando il tuo BFF è Java e il tuo frontend è TypeScript, hai due sistemi di tipi che devono restare sincronizzati. La soluzione tipica è la generazione di codice OpenAPI/Swagger: il lato Java genera una spec, il frontend genera tipi TypeScript da essa.

Funziona — finché non funziona più. La spec diventa obsoleta. Qualcuno modifica il DTO Java ma dimentica di rigenerare. Lo step CI che genera i tipi viene eseguito dopo il merge del PR. Un campo viene rinominato in Java ma il vecchio nome persiste nei tipi generati fino al prossimo rebuild completo. Ho visto bug di produzione per ognuno di questi scenari.

Con uno stack TypeScript unificato, questo intero problema scompare. Non «migliora» — scompare. Definisci il tipo una volta, e sia la route API che il componente React importano lo stesso file. Rinomina un campo e il compilatore trova ogni utilizzo istantaneamente. Condividi una funzione di validazione tra server e client. Nessuno step di generazione, nessuna sincronizzazione, nessuna deriva.

// 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;
}

Argomento 2: efficienza del team e bus factor

In un split tradizionale, servono specialisti su entrambi i lati. Gli sviluppatori Java non possono contribuire in modo significativo al codice React (e di solito non vogliono). Gli sviluppatori frontend non possono toccare il BFF (e spesso non hanno il permesso). Questo crea colli di bottiglia: un dev backend è malato e nessuno può modificare l'API. Un dev frontend è in ferie e un fix UI critico aspetta.

Con uno stack unificato, ogni sviluppatore può lavorare su ogni layer. Un task «frontend» che richiede una modifica API? Uno sviluppatore, un PR, una review. Nessun coordinamento tra team, nessuna negoziazione di contratto API, nessuna attesa che l'altra parte faccia il deploy prima.

Il bus factor — il numero di persone che possono essere investite da un autobus prima che il progetto si blocchi — migliora drasticamente. Invece di 1-2 persone che capiscono ogni lato, hai 3-4 persone che capiscono tutto.

E poi c'è l'argomento dell'headcount. Se il tuo BFF è un sottile layer I/O (come la maggior parte), non hai bisogno di sviluppatori backend dedicati. Tre forti sviluppatori TypeScript possono sostituire due backend + due frontend, con un throughput migliore perché non sono bloccati dai passaggi di consegna.

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

Argomento 3: un ecosistema invece di due

Due stack tecnologici significano due sistemi di gestione dipendenze (npm + Maven/Gradle), due pipeline CI, due immagini Docker di base, due set di patch di sicurezza da monitorare, due linter, due formatter, due framework di test, due configurazioni debugger. Non è solo il runtime — è tutto l'overhead operativo.

Un ecosistema significa un package.json, una configurazione CI, un Dockerfile, un set di dipendenze da auditare, una configurazione linter, un modo di scrivere test. I nuovi sviluppatori si integrano più velocemente perché c'è una cosa da imparare, non due.

Sembra poco, ma si accumula. Ogni CVE in una dipendenza Spring Boot è un ticket. Ogni aggiornamento major di Java è un progetto. Ogni conflitto di plugin Gradle è un pomeriggio. Con un ecosistema, dimezzi questa tassa operativa.

Argomento 4: performance cloud e costi

Per i workload BFF legati all'I/O — che è ciò che la maggior parte dei BFF sono — Node.js usa significativamente meno memoria e si avvia drammaticamente più veloce della JVM. Questo conta sia per il serverless (cold start) che per i deployment containerizzati (densità e velocità di scale-up).

L'overhead di memoria della JVM viene dal suo runtime: caricamento classi, warmup della compilazione JIT, metadati del garbage collector. Un'app Spring Boot che non fa nulla usa 200-400 MB. Un server Node.js che non fa nulla usa 30-50 MB. Per un BFF che fa fetch, trasforma e serve JSON, questo overhead è puro spreco.

L'event loop di Node.js è anche architettonicamente adatto ai workload BFF. Un BFF passa la maggior parte del tempo ad aspettare risposte dai servizi upstream — esattamente il tipo di lavoro pesante in I/O e leggero in CPU dove il modello async single-threaded di Node.js supera i modelli thread-per-request.

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.

I contro-argomenti onesti: dove Java vince

Se fosse una decisione semplice, tutti l'avrebbero già presa. Java ha vantaggi reali e significativi che dovresti valutare onestamente:

Sicurezza dei tipi e correttezza: il sistema di tipi di Java è più rigoroso e maturo di quello di TypeScript. TypeScript ha le vie di fuga «any», il typing strutturale che può nascondere discrepanze, e nessun controllo dei tipi a runtime. I tipi nominali di Java, le checked exception e le garanzie a compile-time catturano intere categorie di bug che TypeScript lascia passare. Per sistemi safety-critical (finanza, sanità), questo conta.

Lavoro CPU-intensive: se il tuo BFF fa calcoli significativi — aggregazione dati, crittografia, trasformazioni complesse — il compilatore JIT e il modello di threading ottimizzato della JVM supereranno significativamente Node.js. Node.js è single-threaded per design. Puoi aggirarlo con i worker thread, ma stai lottando contro l'architettura.

Ecosistema maturo per l'enterprise: l'ecosistema di Spring Boot per la sicurezza (Spring Security), l'osservabilità (Micrometer/Actuator) e l'integrazione enterprise (Spring Cloud) è profondo e collaudato in battaglia. Gli equivalenti Node.js esistono ma sono meno maturi e meno standardizzati.

Talento e assunzioni: in molte aziende, gli sviluppatori Java sono più facili da trovare rispetto a forti sviluppatori fullstack TypeScript. Se il bacino di talenti della tua azienda è orientato a Java, forzare una migrazione crea attrito nelle assunzioni.

Investimento esistente: se hai anni di codice BFF Java ben testato e ben strutturato con test di integrazione completi, monitoraggio solido e conoscenza istituzionale — riscrivere ha un costo reale e un rischio reale. La nuova versione avrà nuovi bug. «Mai riscrivere» è troppo forte, ma «non riscrivere senza un motivo convincente» è ingegneria solida.

Quando migrare, quando restare

Migra quando: il tuo BFF è un sottile layer I/O con logica di business minima. Il tuo team è piccolo (sotto 10) e i passaggi tra frontend/backend sono un collo di bottiglia. Stai già usando TypeScript sul frontend. La deriva dello schema causa bug regolarmente. Spendi tempo significativo a mantenere due pipeline CI/CD. I costi cloud sono una preoccupazione e hai workload I/O-bound.

Resta con Java quando: il tuo BFF ha logica di business significativa o calcoli. Sei in un'industria regolamentata dove la sicurezza dei tipi e il tooling di Java (analisi statica, verifica formale) forniscono valore di conformità. Il tuo team ha esperienza Java profonda e esperienza TypeScript limitata. Il BFF è stabile, cambia raramente e non causa attrito sviluppatore. Fai parte di un ecosistema Java più ampio dove Spring Cloud service mesh, librerie condivise e tooling enterprise forniscono valore significativo.

L'opzione peggiore è una semi-migrazione: spostare alcuni endpoint su Next.js mantenendo gli altri in Java. Questo ti dà tre ecosistemi da mantenere invece di due. Se migri, impegnati completamente. Se Java è la scelta giusta, assumi quella decisione e investi nel rendere il BFF Java eccellente.

Come migrare in modo incrementale

Se decidi di consolidare, non fare un rewrite big bang. Usa il pattern strangler fig: metti Next.js davanti come reverse proxy, migra un endpoint alla volta e spegni il servizio Java quando l'ultimo endpoint si è spostato.

Inizia con gli endpoint più semplici e più modificati — quelli che causano più attrito sviluppatore. Migra prima i tipi (ora sono condivisi), poi le route, poi i test. Ogni migrazione è un piccolo PR reviewabile.

Fai girare entrambi i sistemi in parallelo con confronto del traffico (shadow mode) per catturare differenze comportamentali. Il BFF Java serve il traffico di produzione mentre la versione Next.js processa le stesse richieste in background. Confronta le risposte. Quando corrispondono, switchare.

Questo approccio ti permette di provare il concetto a basso rischio, costruire la fiducia del team e fermarti in qualsiasi momento se la migrazione non sta fornendo valore. Le decisioni architetturali dovrebbero essere reversibili quando possibile — questa lo è.

La conclusione

Per un piccolo team Scrum che mantiene un sottile BFF Java accanto a un frontend React, l'approccio unificato Next.js offre: 25% meno headcount per lo stesso throughput, zero deriva dello schema tra API e UI, bus factor più alto (qualsiasi dev può lavorare su qualsiasi layer), 50-70% meno costi cloud per workload I/O, e metà dell'overhead operativo.

Ma non è una risposta universale. I punti di forza di Java nella sicurezza dei tipi, performance CPU e tooling enterprise sono reali. La decisione dipende dal tuo team, dal tuo workload e dai tuoi vincoli.

Ciò contro cui argomento è il default — l'assunzione che «backend = Java, frontend = React» sia l'unico modo di strutturare un team. Per molti piccoli team, non è il modo ottimale. La migliore architettura è quella che permette al tuo team di consegnare con la minima frizione. A volte sono due ecosistemi con confini chiari. Sempre più spesso, per i workload BFF, ne basta uno.