Skip to content

Jackson

etcd-recipes-jackson provides one class: JacksonCodec<T>, an implementation of core's EtcdCodec<T> backed by a Jackson ObjectMapper.

implementation("com.pambrose:etcd-recipes-jackson:0.12.0")

Why it is a separate artifact

Core already has a JSON codec — KotlinxJsonCodec, reached through jsonCodec<T>(). But jsonCodec is an inline reified function backed by kotlinx-serialization, which wants a compiler plugin and an @Serializable annotation on your type. From Java that is a non-starter, and plenty of Kotlin services already have an ObjectMapper configured to their taste.

So JacksonCodec is the Java-facing counterpart to KotlinxJsonCodec, split into its own module so that projects which do not want Jackson never see it on their classpath. Nothing in core knows the difference: a JacksonCodec is an EtcdCodec, and it drops into every typed recipe unchanged.

Constructing a codec

Two @JvmOverloads constructors, each taking an optional ObjectMapper:

class JacksonCodec<T> : EtcdCodec<T> {
  constructor(type: Class<T>, mapper: ObjectMapper = ObjectMapper())
  constructor(type: TypeReference<T>, mapper: ObjectMapper = ObjectMapper())
}
// A Class token, the Java-facing form.
val fromClass: EtcdCodec<Order> = JacksonCodec(Order::class.java)

// Or the reified Kotlin helper, which builds the TypeReference for you.
val reified: EtcdCodec<Order> = jacksonCodec<Order>()
// The Class token form. The second constructor argument is the ObjectMapper; omit it
// and you get a vanilla one.
EtcdCodec<Order> codec = new JacksonCodec<>(Order.class);

// Supply your own mapper to control the wire format.
ObjectMapper mapper = new ObjectMapper();
EtcdCodec<Order> configured = new JacksonCodec<>(Order.class, mapper);

jacksonCodec<T>(mapper) is a reified Kotlin helper that builds the TypeReference for you. It is the closest analogue to core's jsonCodec<T>(), and the form to reach for from Kotlin.

Generic payloads need a TypeReference

A Class token cannot express List<Order> — the type argument is erased, and Jackson would have nothing to reconstruct the elements from. That is what the second constructor is for:

// A Class token cannot express List<Order> — its type argument is erased. Use a
// TypeReference (or the reified helper, which makes one internally) instead.
val explicit: EtcdCodec<List<Order>> = JacksonCodec(object : TypeReference<List<Order>>() {})
val reified: EtcdCodec<List<Order>> = jacksonCodec<List<Order>>()
// Order.class cannot express List<Order> — the type argument is erased. A
// TypeReference captures it, so generic payloads round-trip correctly.
EtcdCodec<List<Order>> codec = new JacksonCodec<>(new TypeReference<List<Order>>() {
});

The Class constructor is the one to use for a plain type; reach for TypeReference the moment a generic appears in the payload.

Kotlin data classes need a configured mapper

The default ObjectMapper is vanilla

JacksonCodec(Order::class.java) gives you ObjectMapper() — a bare instance with no modules registered. That is exactly right for Java beans, records, and anything Jackson can construct out of the box, and it is why the default exists.

It is wrong for Kotlin data classes. A data class has no no-arg constructor, so a vanilla mapper cannot instantiate it and decode fails at runtime with InvalidDefinitionException — at decode time, not construction time, so a codec that looks fine can fail on the first read. Kotlin nullability is not enforced either: a vanilla mapper will happily leave null in a non-null field.

Register jackson-module-kotlin and pass the mapper in:

implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
// The default ObjectMapper is vanilla. findAndRegisterModules() ServiceLoader-discovers
// jackson-module-kotlin when it is on the classpath, which is what makes plain Kotlin
// data classes (no no-arg constructor, no @JsonCreator) decodable.
val mapper = ObjectMapper().findAndRegisterModules()
val codec: EtcdCodec<Order> = jacksonCodec<Order>(mapper)

findAndRegisterModules() discovers the Kotlin module via ServiceLoader, so it also picks up jackson-module-parameter-names, JSR-310, and anything else on the classpath — usually what you want. ObjectMapper().registerKotlinModule() is the explicit alternative if you would rather name it.

The other way out is to make the type constructable by a vanilla mapper, which is what the sample type on this page does — the module's own tests use exactly this trick to stay honest about the default:

// @JsonCreator keeps this data class constructable by a *vanilla* ObjectMapper, exactly
// as a Java bean would be. Without it, Kotlin data classes need jackson-module-kotlin.
data class Order
  @JsonCreator
  constructor(
    @param:JsonProperty("id") val id: String,
    @param:JsonProperty("qty") val qty: Int,
  )
// A record is decodable by a vanilla ObjectMapper out of the box, as is any bean with a
// no-arg constructor and setters.
public record Order(String id, int qty) {
}

A Java record works with a vanilla mapper with no annotations at all, as does any bean with a no-arg constructor and setters. If you are on Java, this is a non-issue.

Using it

Anywhere an EtcdCodec goes. With a typed recipe:

// A JacksonCodec goes anywhere an EtcdCodec goes — nothing in core knows it is Jackson.
TypedDistributedQueue(client, "/queues/orders", jacksonCodec<Order>()).use { queue ->
  queue.enqueue(Order("A-1", 3))
  val order: Order = queue.dequeue()
  logger.info { "Dequeued $order" }
}
EtcdCodec<Order> codec = new JacksonCodec<>(Order.class);

try (TypedDistributedQueue<Order> queue =
       new TypedDistributedQueue<>(client, "/queues/orders", codec)) {
  queue.enqueue(new Order("A-1", 3));

  // dequeue() hands back an Order, not a ByteSequence.
  Order order = queue.dequeue();
  System.out.println("Dequeued " + order);
}

With a typed cache:

TypedPathChildrenCache(client, "/config/orders", jacksonCodec<Order>()).use { cache ->
  cache.addListener { event -> logger.info { "${event.childName} -> ${event.data}" } }
  cache.start(buildInitial = true)

  cache.currentData.forEach { child -> logger.info { "${child.key} = ${child.value}" } }
}

Or with typed key/value reads and writes:

val codec = jacksonCodec<Order>()

client.putValue("/config/order", Order("A-1", 3), codec)
val order: Order? = client.getValue("/config/order", codec)
logger.info { "Read back $order" }
EtcdCodec<Order> codec = new JacksonCodec<>(Order.class);

// The typed KV helpers are Kotlin extension functions, so from Java they are statics on
// TypedKVUtils with the client as the first argument. They have no @JvmOverloads, so
// the trailing defaults must be passed explicitly.
TypedKVUtils.putValue(client, "/config/order", new Order("A-1", 3), codec,
  PutOption.DEFAULT, RpcResilience.DEFAULT);

Order order = TypedKVUtils.getValue(client, "/config/order", codec, RpcResilience.DEFAULT);
System.out.println("Read back " + order);

The typed KV helpers are clumsy from Java

putValue / getValue are Kotlin extension functions with default arguments and no @JvmOverloads, so Java sees statics on TypedKVUtils with the client as the first argument and no short overload — you must pass PutOption.DEFAULT and RpcResilience.DEFAULT explicitly. The typed recipes have no such wart; their constructors are @JvmOverloads. See the Java guide.

Service payloads

A ServiceInstance carries its payload in an opaque jsonPayload string, so a service payload codec must emit UTF-8 text. JacksonCodec emits JSON, which qualifies:

// Service payloads ride in the instance's `jsonPayload` String, so the codec must emit
// UTF-8 text. JacksonCodec emits JSON, which qualifies.
val codec = jacksonCodec<Order>()
val instance = serviceInstance("orders", Order("A-1", 3), codec) { apply { port = 8080 } }

ServiceDiscovery(client, "/discovery").use { discovery ->
  discovery.registerService(instance)

  discovery.queryForInstances("orders").forEach { found ->
    val order: Order = found.payload(codec)
    logger.info { "${found.name} carries $order" }
  }
}

See Service discovery for the registration side, and Typed values for why the text restriction exists.