Coding

Esplorando Java e Spring Boot in Buildo

Di recente, in Buildo abbiamo usato per la prima volta Java e Spring Boot. Volevamo fare esperienza con queste tecnologie, capire lo stato dello sviluppo Java nel 2024 e vedere come riusare la nostra esperienza maturata nel mondo Scala.

Tommaso Petrucciani Tommaso Petrucciani
28 giugno 2024 — 10 min

Scala e TypeScript sono i linguaggi di programmazione che usiamo principalmente da anni in Buildo. Scala è stato il nostro linguaggio principale per lo sviluppo backend sin dall’inizio, ma negli anni abbiamo usato sempre di più TypeScript e Node, man mano che il loro ecosistema maturava e cresceva l’interesse commerciale. Essendo una società di consulenza che lavora su progetti e clienti molto diversi, abbiamo usato occasionalmente anche altri linguaggi quando richiesto da esigenze progettuali.

Nel nostro progetto più recente abbiamo usato Java: una prima volta per Buildo. Il nostro cliente, nel settore sanitario, voleva evitare un’ulteriore frammentazione di uno stack tecnologico già complesso. Oltre a rispondere a questa esigenza, volevamo sfruttare l’occasione per esplorare il mondo Java. Molti di noi avevano già esperienza o conoscenza di Java, ma non lo avevamo mai usato in Buildo, e volevamo vedere come fosse evoluto negli ultimi anni e come la nostra esperienza con Scala e TypeScript potesse essere riusata qui. In questo articolo condividiamo le nostre scelte e la nostra esperienza in questo percorso.

Scelta dello stack tecnologico e obiettivi

A parte il linguaggio di programmazione, nel progetto non avevamo vincoli tecnologici rigidi. All’inizio ci siamo quindi trovati davanti al compito interessante, ma un po’ impegnativo, di selezionare uno stack completo per il backend di un’applicazione web.

Volevamo bilanciare diversi fattori nella valutazione di framework e librerie. Usare strumenti affermati e diffusi avrebbe reso la nostra esperienza più riusabile con clienti e progetti futuri. Allo stesso tempo volevamo uno stack aggiornato, che ci permettesse di mettere a frutto l’esperienza con Scala e TypeScript adottando in buona misura i pattern di programmazione funzionale. Il cliente era anche interessato a capire se le nostre scelte tecnologiche e i pattern adottati potessero diventare un seme di innovazione per altri suoi progetti in futuro.

Queste sono state le tre scelte principali:

  • usare Java 21 , appena rilasciato, sfruttando funzionalità appena stabilizzate come il pattern matching e i virtual thread;
  • usare Spring Boot perché è così diffuso che lavorarci sarebbe stato molto utile (anche se framework più leggeri, in particolare Helidon, sembrano più vicini ai nostri pattern preferiti);
  • usare jOOQ per l’accesso al database.

Il buono e il meno buono

Nel complesso siamo soddisfatti dell’equilibrio raggiunto, anche se c’è ancora molto da esplorare e migliorare nei progetti futuri. Qui condividiamo alcune riflessioni sulla nostra esperienza con Spring Boot, con le nuove funzionalità di Java e non solo. Naturalmente non tutto era nuovo per noi: già lavorando con Scala usiamo in parte librerie Java (Log4j 2 o le API Date/Time di Java, per citarne un paio).

__wf_reserved_inherit

Spring Boot

Scegliere un framework completo ci ha permesso di velocizzare il bootstrap e di guidare molte delle singole decisioni su struttura del codice, routing, gestione degli errori e altro. Nel mondo Scala tendiamo a diffidare dei framework e a comporre lo stack con diverse librerie più ortogonali (per esempio, in progetti recenti: ZIO, http4s, tapir, Slick o Doobie, circe). Ma per partire il vantaggio di un framework completo e prescrittivo è stato innegabile.

Ci preoccupava un po’ tutto il lato automagico: annotazioni per la dependency injection e simili. In pratica però ha funzionato piuttosto bene e non abbiamo incontrato molti problemi. Resta però la nostra preoccupazione che in un framework ampio ci siano molti comportamenti non del tutto chiari, soprattutto per chi lo usa per la prima volta.

Test automatici estesi si sono rivelati molto preziosi per garantire che l’applicazione si comportasse come previsto, anche nei casi limite (per esempio su validazione e gestione degli errori), che è più facile trascurare.

__wf_reserved_inherit

Java moderno

Passando a Java, ci sono piaciute le funzionalità relativamente nuove di record , sealed interface e pattern matching. Ci hanno permesso di scrivere modelli di dominio e logica di business vicini a quelli a cui siamo abituati in Scala con case class e sealed trait (o con le enumerazioni di Scala 3). Nel complesso, questo riduce il boilerplate.

Per esempio, modellare il risultato di una richiesta come

sealed interface UpdateComponentRequestResult {
    record Started()
        implements UpdateComponentRequestResult {}
    record AgentError(HttpStatusCode responseCode, String responseBody)
        implements UpdateComponentRequestResult {}
    record FailedToContactAgent(Exception exception)
        implements UpdateComponentRequestResult {}
}

e farci pattern matching sopra come

switch (result) {
    case UpdateComponentRequestResult.Started _ -> { ... }
    case UpdateComponentRequestResult.AgentError(var responseCode, var responseBody) -> { ... }
    case UpdateComponentRequestResult.FailedToContactAgent(var exception) -> { ... }
}

risulta sicuro e familiare.

Abbiamo usato ampiamente Optional per limitare l’uso di null e avere così maggiore type safety. È un punto controverso in Java, dove l’uso standard di Optional è più limitato ai valori di ritorno a supporto delle fluent API, mentre l’uso per tipi di campi o parametri di metodo è spesso scoraggiato (vedi per esempio https://nipafx.dev/design-java-optional/) e non è considerato un sostituto completo di null. Ci ha però permesso di mantenere un pattern familiare da Scala, senza sostituirlo con annotazioni di nullability di cui potremmo comprendere meno bene comportamento e limiti, o che potrebbero dipendere più da strumenti aggiuntivi. Ai confini dell’applicazione abbiamo comunque usato annotazioni, per la validazione.

Nel complesso abbiamo avuto la sensazione che, soprattutto grazie a record, sealed interface ed espressioni switch, scrivere modelli di dominio e logica di business pensando a immutabilità e type safety sia più comodo che in passato e più vicino a Scala, pur con una maggiore pesantezza sintattica (quegli infiniti .stream()....toList()!).

Anche il supporto degli editor e degli strumenti è ovviamente molto buono, almeno lavorando con IntelliJ, e su questo fronte è un miglioramento rispetto a Scala; il supporto di VS Code, invece, ci è sembrato inizialmente meno solido (per esempio abbiamo trovato questo issue sui template string introdotti di recente, poi risolto).

Va detto che si trattava di un’applicazione piuttosto piccola, con un’orchestrazione abbastanza semplice e, soprattutto, senza bisogno di gestire concorrenza complessa. Questo ci ha permesso di lavorare con un semplice modello un-thread-per-richiesta, appoggiato ai virtual thread. Sembra più semplice dell’uso di astrazioni asincrone esplicite come Promise in TypeScript o Future/ZIO in Scala, ma dobbiamo ancora provare le nuove API di structured concurrency.

__wf_reserved_inherit

jOOQ

L’applicazione aveva requisiti di persistenza piuttosto semplici (in termini di complessità dei dati e di carico). Per motivi legati al progetto abbiamo deciso di usare il database H2 in modalità embedded. Per lavorarci abbiamo usato jOOQ per l’accesso al database e Flyway per gestire l’evoluzione dello schema con le migrazioni.

Questo ci ha permesso di restare vicini al modo in cui preferiamo lavorare anche in altri stack tecnologici e ha limitato il rischio di fraintendere il comportamento dei nostri strumenti. In particolare usiamo già Flyway nei progetti Scala e, per l’accesso al database, tipicamente Slick: jOOQ ci è sembrato familiare perché è essenzialmente un query builder (in gran parte) type-safe per esprimere query SQL in un DSL Java, il che rende facile capire quale SQL ne risulterà.

Al contrario, astrarre da SQL — per esempio con Hibernate e Spring Data JPA — potrebbe portare a comportamenti più sorprendenti e a insidie (come ci è capitato in passato con ORM come TypeORM e, in misura minore, Prisma). È la stessa preoccupazione che avevamo con Spring, ma per il layer di database ci è sembrato che i compromessi migliori fossero nel restare più vicini a SQL, anche a costo di un po’ più di boilerplate. Pensiamo che la scelta abbia funzionato bene: non abbiamo avuto problemi né sorprese con jOOQ, e il codice di accesso al database, pur un po’ verboso, è facile da capire e da modificare.

Validazione e generazione dell’OpenAPI

La validazione è l’area che ci ha soddisfatto meno. Prendiamo la validazione dei campi nei body JSON delle richieste HTTP. Abbiamo usato Bean Validation, ormai uno standard consolidato (questo è un buon articolo introduttivo), e ha funzionato bene. Il controllo dei null resta però subottimale, perché dobbiamo assicurarci manualmente che i campi siano Optional<SomeType> oppure annotati con @NotNull.

Questo dipende dal nostro uso di Optional all’interno dei confini dell’applicazione per evitare null, e dall’uso di @NotNull solo ai confini: una scelta che potremmo rivedere in futuro. Più in generale, però, l’approccio alla validazione basato su annotazioni non lega direttamente la validazione da eseguire al tipo Java ottenuto dal parsing della richiesta, e i due possono quindi divergere: per esempio non controllo @NotNull e al tempo stesso non dichiaro il tipo come Optional.

Di solito preferiamo un approccio “parse, don’t validate”, in cui il parsing di un input sconosciuto in un modello e la sua validazione procedono insieme in un unico oggetto schema. Zod ne è un buon esempio nel mondo TypeScript. La deserializzazione dei JSON con Jackson è però di per sé type safe e limita i rischi dell’approccio basato su annotazioni (a differenza, per esempio, dello stesso approccio usato da NestJS nel mondo TypeScript, che ci è sembrato più soggetto a errori).

Infine, usiamo spesso una specifica OpenAPI per collegare backend e frontend. Preferiamo descrivere gli endpoint nel codice del backend (in Scala usiamo tapir) e generare da lì la specifica. Dalla specifica generiamo poi il codice client e i modelli dati per l’applicazione frontend.

In questo caso abbiamo usato SpringDoc per generare l’OpenAPI dai nostri controller. Abbiamo però incontrato alcuni problemi, soprattutto con array o mappe nei body delle richieste, dove le annotazioni di schema venivano applicate erroneamente sia alla collezione sia ai suoi elementi. Per esempio, in questo parametro di controller

@RequestBody
@ArraySchema(schema = @Schema(minLength = 0, maxLength = 1000))
@Size(min = 1, max = 100) List<@Size(min = 0, max = 1000) String> logs

abbiamo aggiunto un @ArraySchema ridondante per definire il vincolo di lunghezza delle stringhe nell’array, altrimenti quel vincolo (già espresso dalla seconda annotazione @Size) non veniva interpretato correttamente. Anche così, la specifica generata è parzialmente errata (minLength dovrebbe essere 0, non 1):

requestBody:
  content:
    application/json:
      schema:
        maxItems: 100
        minItems: 1
        type: array
        items:
          maxLength: 1000
          minLength: 1
          type: string
  required: true

Alla luce di questi problemi, nei prossimi progetti Java cercheremo di capire meglio comportamento e limiti di SpringDoc, oppure valuteremo alternative.

Per il futuro

Nel complesso siamo soddisfatti di come questo stack ha funzionato durante il progetto. Non pretendiamo di essere diventati esperti di Java né di avere la stessa ampiezza di conoscenza dei nostri stack principali, ma siamo riusciti a partire rapidamente e a consegnare il progetto con buona qualità e velocità. Abbiamo potuto capitalizzare l’esperienza con Scala, sia per i pattern di programmazione funzionale applicabili in parte anche nel Java moderno, sia per la conoscenza della JVM e di alcune librerie Java riutilizzabili. Non vediamo l’ora di ampliare le nostre conoscenze e affinare questo stack in un prossimo progetto!

Tommaso Petrucciani
Tommaso Petrucciani AI Productivity Engineer and Tech Lead

Tommaso joined Buildo as a full-stack engineer in 2019. He loves functional programming languages (and did a PhD on them) but at Buildo he has broadened his interest to all backend development and software architecture and is now the backend Tech Lead.

NEWSLETTER

Ehi, c'è anche una Newslettero!
Sì, proprio con la o.

Ogni mese scegliamo articoli utili ma anche idee fresche e stimolanti. Proprio quello che vorremmo trovare noi in una newsletter!