Da JSON a classe Java: il generatore ti dà uno scheletro, per la produzione servono altre cinque cose
In sintesi: Uno sguardo pratico a ciò che un generatore JSON-to-Java emette davvero — i numeri diventano Double, gli oggetti annidati diventano classi interne statiche e non viene prodotta nemmeno un'annotazione Jackson. Precisione monetaria, rinomina dei campi, type erasure, campi sconosciuti e date sono le cinque cose che devi aggiungere tu stesso, con codice pronto da copiare per ognuna.

In breve: l'output di un generatore compila subito ma non è pronto per la produzione. Le impostazioni predefinite misurate del nostro generatore mappano ogni numero JSON su Double, trasformano gli oggetti annidati in classi interne statiche e non emettono alcuna annotazione Jackson. Precisione monetaria, rinomina dei campi, type erasure, campi sconosciuti e date sono cinque cose che non farà mai per te.
Trasformare la risposta di un'API in classi Java è lavoro meccanico: leggere il JSON, copiare i campi, avvolgerli in getter e setter. Il costo del lavoro meccanico è il coltello invisibile — un nome di proprietà copiato male non produce errori di compilazione. Il campo arriva silenziosamente null, e te ne accorgi in un log di produzione. Generare la struttura delle classi esiste per allineare nomi e tipi dal primo giorno. Ma un generatore può allineare solo ciò che è visibile nel campione.
1. Esegui prima il generatore: cosa ti dà davvero
Incolla una risposta rappresentativa nel nostro strumento JSON to Java:
{
"order_id": 202611040001,
"buyer": {
"name": "王五",
"vip": true
},
"items": [
{ "sku": "A-1", "price": 19.99, "qty": 2 }
],
"created_at": "2026-11-04T09:30:00+08:00"
}
Lo scheletro generato (opzioni predefinite, useLombok disattivato):
public class Root {
private Double orderId;
private Buyer buyer;
private List<Item> items;
private String createdAt;
public Double getOrderId() { return orderId; }
public void setOrderId(Double orderId) { this.orderId = orderId; }
// ...i restanti getter/setter seguono lo stesso schema
}
Buyer e Item sono classi interne statiche separate. Le regole di mappatura, una per una:
| Forma nel JSON | Tipo Java generato | Note |
|---|---|---|
"name": "王五" |
String |
Le stringhe diventano sempre String |
"qty": 2 |
Double |
Ogni numero diventa Double, vedi sezione 2 |
"vip": true |
Boolean |
Tipo wrapper, null consentito |
"buyer": {...} |
Buyer (classe interna statica) |
Gli oggetti annidati diventano una classe propria |
"items": [...] |
List<Item> |
Tipo elemento preso dal primo elemento |
order_id |
orderId |
snake_case convertito in camelCase |
I nomi delle classi prendono il PascalCase (Root), i campi il camelCase (orderId), e le collisioni con le parole chiave Java (class, new, int) ricevono automaticamente un suffisso. Esistono tre opzioni: packageName, className e useLombok, che alterna @Data e getter/setter scritti a mano. Con Lombok attivo, l'intero blocco di codice ripetitivo si riduce a poche annotazioni.
Due confini di questo scheletro vale la pena conoscere in anticipo. Primo, non genera alcuna annotazione Jackson/Gson — niente @JsonProperty, niente @JsonIgnoreProperties, niente @JsonFormat, perché le annotazioni decidono come viene eseguita la deserializzazione, e un payload di esempio non può rivelare la tua politica di esecuzione. Secondo, non esiste conversione inversa: derivare JSON da una classe Java è fuori dal perimetro, la stessa struttura unidirezionale del percorso JSON to TypeScript.
2. Correzione numero uno: Double non è un tipo per il denaro
Il generatore mappa "price": 19.99 su Double. Compila, assegna, ed è comunque il tipo sbagliato. La virgola mobile binaria non può rappresentare 0.1, il che significa che non può rappresentare la maggior parte delle frazioni decimali:
System.out.println(19.99); // 19.99 — sembra a posto
double a = 0.1 + 0.2;
System.out.println(a); // 0.30000000000000004
System.out.println(a == 0.3); // false
Stampare 19.99 da solo appare pulito perché Java applica l'abbellimento alla rappresentazione più corta; l'errore emerge nel momento in cui il valore entra in un'operazione aritmetica. Tutte e tre le guide di ingegneria su questo tema concordano: prezzi, importi e tasse dovrebbero essere BigDecimal, costruito da una stringa — new BigDecimal("19.99"), non new BigDecimal(19.99), che porta dentro direttamente l'errore del double.
La correzione meccanica è un passaggio post-generazione: sostituisci Double con BigDecimal solo per i campi monetari. Una trappola correlata sulla stessa radice: gli interi oltre 2^53 (numeri d'ordine, ID snowflake) instradati through Double perdono silenziosamente le cifre finali — un numero a 20 cifre che perde le ultime tre è un esito misurato e riproducibile, e il meccanismo è lo stesso alla base di perché JSON perde precisione sui grandi interi. Mappa quei campi su String o Long e lascia che le cifre viaggino come testo.
| Significato reale del campo | Il generatore dà | Sostituire con |
|---|---|---|
| Denaro, tasse | Double |
BigDecimal (costruttore da stringa) |
| Numeri d'ordine, ID (possono superare 2^53) | Double |
String o Long |
| Conteggi, numeri di pagina | Double |
Integer / Long |
| Float scientifici autentici | Double |
mantieni |
3. Correzione numero due: nomi non corrispondenti, fissati con @JsonProperty
Convertire order_id in orderId è un'ipotesi ben intenzionata: il generatore presume che tu voglia camelCase lato Java. Jackson, però, confronta per default le chiavi JSON con i nomi dei campi letteralmente. Quando non corrispondono, nulla fallisce — orderId resta semplicemente null. Due rimedi:
Per campo — aggiungi @JsonProperty, un'annotazione fissa una mappatura:
public class Root {
@JsonProperty("order_id")
private Long orderId;
}
Globale — cambia la naming strategy dell'ObjectMapper e non serve alcuna annotazione per campo in tutto il progetto:
ObjectMapper mapper = new ObjectMapper();
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
Scegli in base alla superficie: una sola API fissa, usa il livello campo; un intero team che dialoga con servizi in snake_case, usa la strategia globale. Un fatto negativo merita qui un paragrafo a sé — un nome sbagliato fallisce come null silenzioso, non come eccezione. La pagina dello strumento jsonformater considera questo proprio il motivo per cui esistono i generatori: la copiatura meccanica è esattamente il punto in cui questo tasso di errore culmina. Quindi, dopo aver aggiunto le annotazioni, scrivi almeno un test di asserzione che i campi chiave siano non-null.
4. Correzione numero tre: type erasure — non deserializzare mai in una List nuda
Il generatore ti consegna la dichiarazione di campo List<Item>, ma la cosa che riempie davvero quella lista è Jackson. Deserializzare al top level così cade dritto nella trappola del type erasure:
// Sbagliato: gli elementi sono LinkedHashMap a runtime
List<Item> items = mapper.readValue(json, List.class);
// Giusto: TypeReference preserva il parametro generico
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});
readValue(json, List.class) compila e gira, ma i generics di Java esistono solo a compile time. A runtime Jackson vede solo List, quindi gli elementi atterrano come LinkedHashMap. L'errore non scatta subito — scatta alla prima item.getSku() chissà dove, lanciando una ClassCastException lontanissima da dove è stato commesso l'errore. Tre forme corrette, per scenario:
// 1. Il top level è l'array
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});
// 2. Un campo di un oggetto top-level
JsonNode root = mapper.readTree(json);
List<Item> items = mapper.convertValue(root.get("items"), new TypeReference<List<Item>>() {});
// 3. Costruzione esplicita di JavaType
JavaType type = mapper.getTypeFactory().constructCollectionType(List.class, Item.class);
List<Item> items = mapper.readValue(json, type);
Lato Gson l'equivalente è TypeToken. Tutte e tre le guide elencano questo come voce a sé, perché fallisce tardi e riporta lontano.
5. Correzione numero quattro: campi sconosciuti — non lasciare che l'evoluzione dell'API rompa la deserializzazione
Un'API che aggiunge un campo è l'evoluzione più ordinaria. Il default di Jackson, però, è lanciare UnrecognizedPropertyException sui campi sconosciuti — il fornitore aggiunge un campo e il tuo servizio inizia ad andare in errore. Disattivala a due livelli:
// Livello classe: tollera gli sconosciuti solo su questa classe
@JsonIgnoreProperties(ignoreUnknown = true)
public class Root { ... }
// Globale: l'intero mapper li tollera
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
Una volta spento, appare un problema speculare: la tolleranza silenzia anche i nomi di campo sbagliati, aprendo in lungo e in largo la modalità di fallimento "null silenzioso" della sezione precedente. La via di mezzo è girare in produzione con la tolleranza spenta e nella suite di test con essa accesa, così gli errori di ortografia emergono durante i test. Gson ignora i campi sconosciuti per default — ecco perché sembra senza fatica nelle integrazioni rapide, e il prezzo di quella comodità è non sapere mai cosa il fornitore ha aggiunto.
6. Correzione numero cinque: date e costruttore senza argomenti
Il generatore mappa created_at su String, che è in realtà il default più sicuro: in JSON una data è solo una stringa, e il formato in rete potrebbe essere ISO-8601, un timestamp Unix o qualcosa di personalizzato — il campione non può dirti quale vuoi. Quando serve un vero tipo data, aggiungi due righe:
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
// Emette ISO-8601 in serializzazione invece di array di timestamp
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
public class Root {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
}
Il costruttore senza argomenti è l'altro controllo obbligatorio: Jackson deserializza creando l'oggetto attraverso di esso, poi impostando i campi uno per uno. Scrivi un costruttore parametrico e quello predefinito sparisce — la deserializzazione fallisce subito. Con Lombok, aggiungi @NoArgsConstructor; con un record Java 17, affida l'intero problema al supporto record di Jackson 2.12+, che corrisponde per parametri del costruttore ed elimina del tutto il requisito.
7. Due questioni di produzione: riusa l'ObjectMapper e valida gli input
Il generatore ti dà le classi; due voci architetturali restano a runtime. Primo, un ObjectMapper è costoso da creare e thread-safe — condividi una sola istanza in tutta l'applicazione. Chiamare new ObjectMapper() dentro un metodo è l'anti-pattern di performance più comune, e l'istanza che Spring Boot inietta per default esiste proprio per questo. Naming strategy, JavaTimeModule e politica sui campi sconosciuti appartengono tutte a quell'unica istanza, non sparse tra i punti di chiamata.
Secondo, una deserializzazione riuscita dice che i tipi corrispondono, non che i valori sono legali. Un prezzo negativo, una quantità zero, un'email malformata — a Jackson non importa. La validazione degli input appartiene a Bean Validation (hibernate-validator), aggiunta come annotazioni sullo scheletro generato:
public class CreateOrderRequest {
@NotNull
@DecimalMin(value = "0.01")
private BigDecimal price;
@Min(1)
private Integer qty;
@Email
private String contact;
}
In Spring, @Valid intercetta la richiesta prima che raggiunga la logica di business. Questo strato è qualcosa che un generatore non potrà mai fornire — può leggere da un campione come appare un campo, ma non quale vincolo di business il campo porta.
8. Dove finisce la generazione e inizia la scrittura a mano
Quando generare e quando scrivere a mano è una linea chiara:
- Usa il generatore: risposte con molti campi, integrazioni una-tantum con terze parti, prototipi rapidi. Elimina esattamente una classe di errori — gli errori di copiatura meccanica — e ti consegna in secondi uno scheletro con nomi e tipi allineati.
- Scrivi a mano o ritoccia: denaro e interi lunghi richiedono cambi di tipo, la naming strategy richiede una decisione globale, gli oggetti di dominio centrali richiedono annotazioni di validazione. Sono decisioni di business che un payload di esempio non può prendere.
- Precondizione condivisa per entrambi: verifica prima che il JSON sorgente sia valido. Un generatore produce volentieri classi da un payload rotto e lascia la trappola al momento della deserializzazione; passalo prima dalla nostra pagina di validazione. Dieci secondi, in ogni caso.
Il generatore copre "da zero a scheletro". Le cinque correzioni di questo articolo coprono "da scheletro a produzione". L'ordine non può essere invertito, e nessuno dei due passi può essere saltato.
FAQ
Le classi generate si possono usare direttamente in Spring Boot?
A compile time, sì. Prima del runtime valgono tre aggiunte: campi monetari su BigDecimal, una naming strategy o @JsonProperty per le API in snake_case, e @JsonIgnoreProperties(ignoreUnknown = true) per sopravvivere all'evoluzione dell'API. Le annotazioni Bean Validation seguono le tue regole di business.
Perché il generatore mappa ogni numero su Double invece di long o BigDecimal?
Un campione distingue "intero vs. decimale" ma non il significato di business. Un 2 può essere una quantità o un ID; un numero d'ordine a 20 cifre e ¥19.99 sono entrambi semplicemente numeri in JSON. Lasciare le decisioni semantiche all'utente è un confine ragionevole per un generatore forward — il costo è che la tabella di sostituzione della sezione 2 devi passartela da solo.
@Data di Lombok o getter/setter scritti a mano — quale scegliere?
Se il team già usa Lombok, prendi @Data per il minor codice ripetitivo. Se preferisci evitare un plugin a compile time o conflitti con altri annotation processor, scrivili a mano. Per Jackson non cambia nulla; guarda i getter/setter o la visibilità dei campi.
Gli oggetti annidati diventano classi interne statiche — è un problema?
Per Jackson no; una classe interna statica può essere istanziata indipendentemente. Per la manutenzione a lungo termine, la maggior parte dei team li estrae come classi top-level in un package, per riuso e test isolati. La struttura a classi interne del generatore è pensata per l'ispezione rapida, non per una codebase viva.
Un campo List lancia ClassCastException durante la deserializzazione — come lo individuo?
Prima verifica se la deserializzazione ha usato una List.class nuda: gli elementi sono in realtà LinkedHashMap e l'errore scatta alla lettura di un campo. Passa a TypeReference. Se continua a fallire, incolla l'eccezione nella nostra pagina di ricerca dei messaggi d'errore e segui la voce per il tuo linguaggio.