Fechas y nulos con Jackson: java.time, timestamps y snake_case
Domina java.time, la trampa de nulo-versus-ausente y snake_case para que tu JSON sea portable y predecible.
Por defecto, Jackson no sabe qué hacer con los tipos de `java.time` como `Instant`, `LocalDate` u `OffsetDateTime`. Sin ayuda, lanza `InvalidDefinitionException` ("Java 8 date/time type not supported by default") o, peor aún, los serializa como objetos reflexivos enormes. La solución es el módulo `jackson-datatype-jsr310`. En un proyecto Spring Boot 3 basta con añadir `com.fasterxml.jackson.module:jackson-datatype-jsr310` a las dependencias de Gradle; la autoconfiguración de Boot lo detecta en el classpath mediante `Jackson2ObjectMapperBuilder` y registra el `JavaTimeModule` por ti. Si construyes un `ObjectMapper` a mano, debes llamar tú mismo a `registerModule(JavaTimeModule())` (o `findAndRegisterModules()`), o cada campo de fecha fallará.
El ajuste más importante una vez registrado el módulo es `WRITE_DATES_AS_TIMESTAMPS`. Cuando esta función está activada (el valor por defecto de Jackson puro), un `Instant` se escribe como un valor numérico de época como `1719331200.000000000` y un `LocalDateTime` como un arreglo `[2026,6,26,10,0]` — compacto pero hostil para humanos y para la mayoría de los front ends. Cuando la desactivas, `JavaTimeModule` usa cadenas ISO-8601: `Instant` se vuelve `"2026-06-26T10:00:00Z"` y `LocalDate` se vuelve `"2026-06-26"`. Spring Boot es opinado aquí y desactiva la función por ti por defecto, por eso las APIs de Boot emiten cadenas ISO legibles desde el principio. Si construyes tu propio mapper, desactívala explícitamente con `disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)`.
Elegir el tipo correcto importa tanto como el formato. Usa `Instant` (u `OffsetDateTime`) para un momento exacto en la línea de tiempo global — campos de fecha de creación, tiempos de eventos, cualquier cosa que guardes en UTC. Usa `LocalDate` para un día de calendario sin zona horaria, como un cumpleaños o la fecha de una factura, y `LocalDateTime` solo cuando una hora de reloj de pared genuinamente no tiene zona asociada. Un error común es usar `LocalDateTime` para timestamps que cruzan zonas horarias: descarta silenciosamente el offset y tu "10:00" significa instantes reales distintos para usuarios distintos. Prefiere `Instant` en el borde y convierte a una zona solo para mostrar.
El manejo de nulos es donde los equipos introducen bugs sin darse cuenta. Hay una diferencia crucial entre un campo que es explícitamente `null` en el JSON y un campo que simplemente está ausente. Por defecto, Jackson serializa una propiedad nullable de Kotlin con valor `null` como `"field": null`, y al deserializar tanto una clave ausente como un `null` explícito se mapean al mismo `null` de Kotlin. Esa ambigüedad está bien para muchas APIs, pero es desastrosa para endpoints PATCH, donde `null` significa "borra este valor" y ausente significa "déjalo intacto". Si no puedes distinguirlos, no puedes implementar una actualización parcial correcta.
Controlas los nulos de salida con `@JsonInclude`. Anota una clase o propiedad con `@JsonInclude(JsonInclude.Include.NON_NULL)` para omitir por completo los campos nulos del JSON, o configúralo globalmente con `objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL)` (en Boot, `spring.jackson.default-property-inclusion=non_null`). `NON_ABSENT` además omite los `Optional` vacíos, y `NON_EMPTY` descarta también cadenas y colecciones vacías. Para distinguir null de ausente en la entrada, modela el campo como `JsonNullable<T>` (del módulo `org.openapitools:jackson-databind-nullable`): una clave ausente lo deja `undefined`, mientras que un `null` explícito se captura como un valor presente-pero-nulo, dando a PATCH la información de tres estados que necesita.
La estrategia de nombres es el último punto de fricción común. Kotlin y las convenciones de la JVM usan `camelCase`, pero muchas APIs, clientes móviles y consumidores en Python/Ruby esperan `snake_case`. En lugar de salpicar cada propiedad con `@JsonProperty("created_at")`, configura una `PropertyNamingStrategies.SNAKE_CASE` global para que `createdAt` se serialice como `created_at` y se deserialice de vuelta automáticamente. En Spring Boot esto es una sola línea de configuración: `spring.jackson.property-naming-strategy=SNAKE_CASE`. La clase `PropertyNamingStrategies` (nota el plural — las constantes antiguas en singular `PropertyNamingStrategy` están obsoletas) también ofrece `KEBAB_CASE`, `LOWER_CASE` y `UPPER_CAMEL_CASE` para otras convenciones.
Kotlin añade una pieza más: el módulo `jackson-module-kotlin`. Le enseña a Jackson sobre los parámetros del constructor primario, los valores por defecto y la no-nulabilidad real, para que las data classes se deserialicen limpiamente sin un constructor sin argumentos. Spring Boot 3 lo incluye y auto-registra cuando detecta Kotlin, así que una `data class` con `val createdAt: Instant` y `val deletedAt: Instant?` simplemente funciona — el campo no nulo se exige (un JSON faltante lanza una `MismatchedInputException`; las versiones antiguas lanzaban la ya obsoleta `MissingKotlinParameterException`) mientras que el campo nullable tolera la ausencia. Combinado con el módulo de Kotlin, tus ajustes de nombres, fechas y nulos se aplican uniformemente en cada data class.
Júntalo todo y un único `ObjectMapper` configurado te da un contrato limpio: fechas ISO-8601 vía `jsr310` con timestamps desactivados, claves snake_case vía `PropertyNamingStrategies`, campos nulos omitidos vía `@JsonInclude` y `JsonNullable` reservado para los endpoints PATCH que genuinamente necesitan semántica de tres estados. Configura esto una vez a nivel de aplicación en lugar de anotar cada DTO — tu JSON se mantiene consistente, tu front end deja de tratar campos como casos especiales, y tus actualizaciones parciales por fin se comportan como los clientes esperan.
import com.fasterxml.jackson.annotation.JsonIncludeimport com.fasterxml.jackson.databind.PropertyNamingStrategiesimport com.fasterxml.jackson.databind.SerializationFeatureimport com.fasterxml.jackson.databind.json.JsonMapperimport com.fasterxml.jackson.datatype.jsr310.JavaTimeModuleimport com.fasterxml.jackson.module.kotlin.kotlinModuleval mapper = JsonMapper.builder().addModule(JavaTimeModule()) // teaches Jackson about java.time.addModule(kotlinModule()) // data classes, default values, nullability.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // -> ISO-8601 strings.serializationInclusion(JsonInclude.Include.NON_NULL) // drop null fields.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE).build()
Building an ObjectMapper by hand: register jsr310 + Kotlin modules, emit ISO dates instead of timestamps, omit nulls, and use snake_case globally. (Spring Boot does most of this for you via application.yml.)
import com.fasterxml.jackson.annotation.JsonIncludeimport java.time.Instantimport java.time.LocalDate@JsonInclude(JsonInclude.Include.NON_NULL)data class UserDto(val id: Long,val displayName: String,val createdAt: Instant, // "created_at": "2026-06-26T10:00:00Z"val birthDate: LocalDate, // "birth_date": "1995-04-12"val deletedAt: Instant? = null // omitted entirely when null)// Serialized with the mapper above:// { "id": 7, "display_name": "Ada",// "created_at": "2026-06-26T10:00:00Z", "birth_date": "1995-04-12" }
A typical DTO. createdAt is required (an exact UTC moment), birthDate is a calendar day, deletedAt is nullable. With NON_NULL, a null deletedAt disappears from the output JSON.
import org.openapitools.jackson.nullable.JsonNullableimport java.time.LocalDate// Only the keys the client actually sent are 'present'.data class UserPatch(val displayName: JsonNullable<String> = JsonNullable.undefined(),val birthDate: JsonNullable<LocalDate> = JsonNullable.undefined(),)data class UserState(val displayName: String, val birthDate: LocalDate?)fun applyPatch(current: UserState, patch: UserPatch): UserState = current.copy(// absent -> keep current; present -> overwrite (an explicit null clears it)displayName = if (patch.displayName.isPresent) patch.displayName.get() else current.displayName,birthDate = if (patch.birthDate.isPresent) patch.birthDate.get() else current.birthDate,)
The null-vs-absent problem for PATCH. JsonNullable distinguishes 'key was sent as null' (clear it) from 'key was absent' (leave untouched) — something a plain LocalDate? cannot express. birthDate is nullable here, so an explicit null genuinely clears it.
sealed interface Field<out T> {object Absent : Field<Nothing> // key not sent -> leave untoucheddata class Present<T>(val value: T?) : Field<T> // key sent (value may be null)}fun <T> resolve(current: T?, incoming: Field<T>): T? = when (incoming) {is Field.Absent -> current // keep existingis Field.Present -> incoming.value // overwrite (possibly with null)}fun main() {val current = "Ada"println(resolve(current, Field.Absent)) // Ada (untouched)println(resolve(current, Field.Present("Grace"))) // Grace (replaced)println(resolve(current, Field.Present(null))) // null (cleared)}
Pure-Kotlin demo of the conceptual difference between an explicit null and an absent value, using a sealed three-state type. No Jackson needed — this runs on the stdlib.
Arena IDE🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. In a Spring Boot 3 + Kotlin API, your endpoint returns an Instant as the numeric value 1719331200.000000000 instead of an ISO-8601 string. What is the most likely cause?