JSON Toolkit

De JSON a clase Java: el generador te da un esqueleto, para producción faltan cinco cosas más

En resumen: Una mirada práctica a lo que un generador de JSON a Java emite de verdad — los números se convierten en Double, los objetos anidados en clases internas estáticas, y no produce ni una sola anotación de Jackson. La precisión monetaria, el renombrado de campos, el borrado de tipos, los campos desconocidos y las fechas son las cinco cosas que tendrás que añadir tú mismo, con código para copiar y pegar en cada caso.

Intermedio~10 min de lecturaPublicado el 2026-11-04Actualizado el 2026-11-04
Herramienta de conversión que transforma JSON en código de clase POJO de Java

En resumen: la salida de un generador compila de inmediato, pero no está lista para producción. Los valores medidos de nuestro generador por defecto mapean cada número JSON a Double, convierten los objetos anidados en clases internas estáticas y no emiten ninguna anotación de Jackson. La precisión monetaria, el renombrado de campos, el borrado de tipos, los campos desconocidos y las fechas son cinco cosas que nunca hará por ti.

Convertir una respuesta de API en clases Java es trabajo mecánico: leer el JSON, copiar los campos, envolverlos en getters y setters. El coste del trabajo mecánico es ese cuchillo invisible — un nombre de propiedad copiado mal no provoca un error de compilación. El campo llega silenciosamente como null, y lo descubres en un log de producción. Generar la estructura de clases existe para alinear nombres y tipos desde el primer día. Pero un generador solo puede alinear lo que es visible en la muestra.

1. Ejecuta primero el generador: qué te da realmente

Pega una respuesta representativa en nuestra herramienta JSON a 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"
}

El esqueleto generado (opciones por defecto, useLombok desactivado):

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; }
    // ...los demás getters/setters siguen el mismo patrón
}

Buyer e Item son clases internas estáticas independientes. Las reglas de mapeo, una por una:

Forma en el JSON Tipo Java generado Notas
"name": "王五" String Las cadenas siempre se convierten en String
"qty": 2 Double Todo número se convierte en Double, ver sección 2
"vip": true Boolean Tipo envoltorio, se permite null
"buyer": {...} Buyer (clase interna estática) Los objetos anidados se convierten en su propia clase
"items": [...] List<Item> El tipo de elemento se toma del primer elemento
order_id orderId snake_case convertido a camelCase

Los nombres de clase adoptan PascalCase (Root), los campos camelCase (orderId), y las colisiones con palabras clave de Java (class, new, int) reciben automáticamente un sufijo. Hay tres opciones: packageName, className y useLombok, que alterna entre @Data y getters/setters escritos a mano. Con Lombok activado, todo el bloque de código repetitivo se reduce a unas pocas anotaciones.

Dos límites de este esqueleto conviene conocer de antemano. Primero, no genera ninguna anotación de Jackson/Gson — ni @JsonProperty, ni @JsonIgnoreProperties, ni @JsonFormat, porque las anotaciones deciden cómo se ejecuta la deserialización, y una carga de muestra no puede revelar tu política de ejecución. Segundo, no hay conversión inversa: derivar JSON a partir de una clase Java queda fuera del alcance, el mismo diseño unidireccional que la ruta de JSON a TypeScript.

2. Corrección número uno: Double no es un tipo para el dinero

El generador mapea "price": 19.99 a Double. Compila, asigna, y aun así es el tipo equivocado. La coma flotante binaria no puede representar 0.1, lo que significa que no puede representar la mayoría de las fracciones decimales:

System.out.println(19.99);          // 19.99 — parece correcto
double a = 0.1 + 0.2;
System.out.println(a);              // 0.30000000000000004
System.out.println(a == 0.3);       // false

Imprimir 19.99 por separado se ve limpio porque Java aplica el embellecimiento de la representación más corta; el error aparece en cuanto el valor entra en una operación aritmética. Los tres artículos de ingeniería sobre este tema coinciden: precios, importes y tasas deben ser BigDecimal, construido desde una cadena — new BigDecimal("19.99"), no new BigDecimal(19.99), que arrastra directamente el error del double.

La corrección mecánica es una pasada posterior a la generación: sustituir Double por BigDecimal solo en los campos monetarios. Una trampa relacionada con la misma raíz: los enteros por encima de 2^53 (números de pedido, IDs snowflake) enrutados por Double pierden sus últimos dígitos en silencio — que un número de 20 dígitos pierda sus últimas tres cifras es un resultado medido y reproducible, y el mecanismo es el mismo que está detrás de por qué JSON pierde precisión con enteros grandes. Mapea esos campos a String o Long y deja que los dígitos viajen como texto.

Significado real del campo El generador da Cambiar a
Dinero, tasas Double BigDecimal (constructor de cadena)
Números de pedido, IDs (pueden superar 2^53) Double String o Long
Cantidades, números de página Double Integer / Long
Flotantes realmente científicos Double conservar

3. Corrección número dos: nombres que no coinciden, fijados con @JsonProperty

Convertir order_id en orderId es una suposición bienintencionada: el generador asume que quieres camelCase en el lado Java. Jackson, sin embargo, por defecto compara las claves JSON con los nombres de los campos de forma literal. Cuando no coinciden, nada falla — orderId simplemente se queda en null. Dos soluciones:

Por campo — añade @JsonProperty, una anotación fija un mapeo:

public class Root {
    @JsonProperty("order_id")
    private Long orderId;
}

Global — cambia la estrategia de nombres del ObjectMapper y no harán falta anotaciones por campo en todo el proyecto:

ObjectMapper mapper = new ObjectMapper();
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);

Elige según la superficie de contacto: una sola API fija, usa el nivel de campo; un equipo entero dialogando con servicios en snake_case, usa la estrategia global. Un hecho negativo merece aquí su propio párrafo — un nombre mal escrito falla como null silencioso, no como excepción. La página de la herramienta jsonformater considera que este es precisamente el motivo de existencia de los generadores: la copia mecánica es justo donde esta tasa de error alcanza su máximo. Así que después de añadir las anotaciones, escribe al menos un test de aserción de que los campos clave no son null.

4. Corrección número tres: borrado de tipos — nunca deserialices en una List desnuda

El generador te entrega la declaración de campo List<Item>, pero lo que realmente llena esa lista es Jackson. Deserializar en el nivel superior así cae de lleno en la trampa del borrado de tipos:

// Incorrecto: los elementos son LinkedHashMap en tiempo de ejecución
List<Item> items = mapper.readValue(json, List.class);

// Correcto: TypeReference preserva el parámetro genérico
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});

readValue(json, List.class) compila y se ejecuta, pero los genéricos de Java solo existen en tiempo de compilación. En tiempo de ejecución Jackson ve solo List, así que los elementos aterrizan como LinkedHashMap. El error no salta de inmediato — salta en el primer item.getSku() quien sabe dónde, lanzando una ClassCastException muy lejos de donde se cometió el error. Tres formas correctas, según el escenario:

// 1. El nivel superior es el array
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});

// 2. Un campo de un objeto de nivel superior
JsonNode root = mapper.readTree(json);
List<Item> items = mapper.convertValue(root.get("items"), new TypeReference<List<Item>>() {});

// 3. Construcción explícita de JavaType
JavaType type = mapper.getTypeFactory().constructCollectionType(List.class, Item.class);
List<Item> items = mapper.readValue(json, type);

En el lado de Gson el equivalente es TypeToken. Las tres guías lo enumeran como punto propio, porque falla tarde y reporta lejos.

5. Corrección número cuatro: campos desconocidos — no dejes que la evolución del API rompa la deserialización

Que un API añada un campo es la evolución más ordinaria. El comportamiento por defecto de Jackson, sin embargo, es lanzar UnrecognizedPropertyException ante campos desconocidos — el proveedor añade un campo y tu servicio empieza a dar errores. Desactívalo a dos niveles:

// Nivel de clase: tolera los desconocidos solo en esta clase
@JsonIgnoreProperties(ignoreUnknown = true)
public class Root { ... }

// Global: todo el mapper los tolera
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

Una vez desactivado, aparece un problema especular: la tolerancia también silencia los nombres de campo mal escritos, dejando paso libre al modo de fallo "null silencioso" de la sección anterior. El punto medio es ejecutar producción con la tolerancia desactivada y la suite de pruebas con ella activada, para que los errores de escritura salgan a la luz durante las pruebas. Gson ignora los campos desconocidos por defecto — por eso resulta tan cómodo en integraciones rápidas, y el precio de esa comodidad es no saber nunca qué añadió el proveedor.

6. Corrección número cinco: fechas y el constructor sin argumentos

El generador mapea created_at a String, que en realidad es el valor por defecto más seguro: en JSON una fecha es solo una cadena, y el formato en el cable puede ser ISO-8601, un timestamp Unix o algo personalizado — la muestra no puede decirte cuál quieres. Cuando necesites un tipo de fecha real, añade dos líneas:

ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
// Emite ISO-8601 al serializar en lugar de arrays de timestamps
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
public class Root {
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createdAt;
}

El constructor sin argumentos es la otra comprobación obligatoria: Jackson deserializa creando el objeto a través de él y luego estableciendo los campos uno a uno. Escribe un constructor con parámetros y el por defecto desaparece — la deserialización falla de inmediato. Con Lombok, añade @NoArgsConstructor; con un record de Java 17, entrega el problema entero al soporte de records de Jackson 2.12+, que empareja por parámetros del constructor y elimina por completo el requisito.

7. Dos asuntos de producción: reutiliza el ObjectMapper y valida la entrada

El generador te da las clases; dos puntos de arquitectura quedan en tiempo de ejecución. Primero, crear un ObjectMapper es caro y es thread-safe — comparte una sola instancia en toda la aplicación. Llamar a new ObjectMapper() dentro de un método es el antipatrón de rendimiento más común, y la instancia que Spring Boot inyecta por defecto existe precisamente por eso. La estrategia de nombres, el JavaTimeModule y la política de campos desconocidos pertenecen todos a esa única instancia, no repartidos entre los puntos de llamada.

Segundo, una deserialización exitosa dice que los tipos coinciden, no que los valores sean legales. Un precio negativo, una cantidad cero, un email mal formado — a Jackson le da igual. La validación de entrada pertenece a Bean Validation (hibernate-validator), añadida como anotaciones sobre el esqueleto generado:

public class CreateOrderRequest {
    @NotNull
    @DecimalMin(value = "0.01")
    private BigDecimal price;

    @Min(1)
    private Integer qty;

    @Email
    private String contact;
}

En Spring, @Valid intercepta la petición antes de que llegue a la lógica de negocio. Esta capa es algo que un generador nunca podrá proporcionar — puede leer de una muestra cómo es un campo, pero no qué restricción de negocio lleva consigo.

8. Dónde termina la generación y empieza la escritura a mano

Cuándo generar y cuándo escribir a mano es una línea clara:

  • Usa el generador: respuestas con muchos campos, integraciones puntuales con terceros, prototipos rápidos. Elimina exactamente una clase de errores — los errores de copia mecánica — y te entrega en segundos un esqueleto con nombres y tipos alineados.
  • Escribe a mano o reforma a fondo: el dinero y los enteros largos necesitan cambio de tipo, la estrategia de nombres exige una decisión global, los objetos de dominio centrales necesitan anotaciones de validación. Son decisiones de negocio que una carga de muestra no puede tomar.
  • Precondición común para ambos: confirma primero que el JSON de origen es válido. Un generador produce clases encantado a partir de una carga rota y deja la trampa para el momento de la deserialización; pásalo antes por nuestra página de validación. Diez segundos, en cualquier caso.

El generador cubre "de cero a esqueleto". Las cinco correcciones de este artículo cubren "de esqueleto a producción". El orden no se puede invertir, y ningún paso se puede saltar.

FAQ

¿Las clases generadas se pueden usar directamente en Spring Boot?

En tiempo de compilación, sí. Antes de la ejecución valen tres añadidos: campos monetarios a BigDecimal, una estrategia de nombres o @JsonProperty para APIs en snake_case, y @JsonIgnoreProperties(ignoreUnknown = true) para sobrevivir a la evolución del API. Las anotaciones de Bean Validation siguen tus reglas de negocio.

¿Por qué el generador mapea todos los números a Double en lugar de long o BigDecimal?

Una muestra distingue "entero vs. decimal" pero no el significado de negocio. Un 2 puede ser una cantidad o un ID; un número de pedido de 20 dígitos y ¥19.99 son ambos simplemente números en JSON. Dejar las decisiones semánticas al usuario es un límite razonable para un generador forward — el coste es que la tabla de sustitución de la sección 2 te la tienes que recorrer tú.

¿@Data de Lombok o getters/setters escritos a mano — cuál?

Si el equipo ya usa Lombok, toma @Data para el mínimo código repetitivo. Si prefieres evitar un plugin en tiempo de compilación o conflictos con otros procesadores de anotaciones, escríbelos a mano. A Jackson le da igual; mira los getters/setters o la visibilidad de los campos.

Los objetos anidados se convierten en clases internas estáticas — ¿importa?

Para Jackson no; una clase interna estática se puede instanciar de forma independiente. Para el mantenimiento a largo plazo, la mayoría de los equipos las extrae como clases de nivel superior en un paquete, para reutilizarlas y probarlas de forma aislada. La estructura de clases internas del generador está pensada para inspección rápida, no para una base de código viva.

Un campo List lanza ClassCastException durante la deserialización — ¿cómo lo localizo?

Primero comprueba si la deserialización usó una List.class desnuda: los elementos son en realidad LinkedHashMap y el error salta al leer un campo. Cambia a TypeReference. Si sigue fallando, pega la excepción en nuestra página de búsqueda de mensajes de error y sigue la entrada de tu lenguaje.