Non-Ergonomic Jackson

February 23, 2021

Recently, I’ve written this code (or something like that) to serialize a JSON object via Jackson:

public String renderToJson(Object dto) {
    try {
        return objectMapper.writeValueAsString(dto);
    } catch (IOException e) {
        throw new AssertionError("Unexpected IOException converting object to json");
    }
}

It was almost offensive to my eye. I was creating a string object in memory. What the heck would that have to do with IO? I had no time to ponder about this, though.

So there I was reading The modern way to perform error handling. And voila, here’s the answer, emerging right from the depths of my mind! You know what? That’s still the same topic of mixing policies with effects, and all the problems rooted in this.

If you take a deep look at writeValueAsString 's inner workings, you’ll see that this pure (rus)—per se—method is just a wrap for _writeValueAndClose(JsonGenerator g, Object value).

g in turn is a wrap for java.io.Writer. Then you have Writer throwing the java.io.IOException. Boom, there goes your IOException when you’re generating a string in memory.

The cause of that problem is this: the policy and the effects are jumbled into one function (namely, _writeValueAndClose). The former defines the set of rules according to which the JSON gets formed, based on the Java object. The latter writes the result into the abstract receiver.

To make this code ergonomic, we have to keep these two apart:

fun transform(value: Any): Sequence<Char> =
  // the intricate yet clean algorithm that re-renders the json

fun writeValueAsString(value: Any) = transform(value).joinToString()

@Throws(IOException::class)
fun writeValue(value: Any, w: Writer) = transform(value).forEach { w.append(it) }

It’s still the same ergonomic (rus) (aka ROP, aka Functional Core/Imperative Shell) pattern. I haven’t discussed it yet. The key idea is to keep all these apart:

  1. the business logic (transform),
  2. the effective port (Writer), and
  3. the usecase/workflow bridging the two (writeValueAsString, writeValue).

Keeping the logic and the effects separated is like giving your users letter cubes for them to build their own use cases. The ones that seem most common can come out of the box.

It’s not like I’m bashing the team behind Jackson. I see at least three reasons everything is the way it is:

  1. I’m not sure the sequences-based option can be as fast as the one where you write everything straight into the output stream, which is critical for them.
  2. It’s highly likely that back when they were writing this code, the functional paradigm hadn’t yet got in fashion, so Java simply had no Stream API.
  3. It’s quite hard to come up with a good design straight away. If you’re the author behind a popular open source library and have any respect for your users, you’ll be stuck with the mistakes of your youth forever for one simple reason: backward compatibility.

Luckily, we have quite a lot under our belt here. We know of the ergonomic approach, have learned from others' mistakes, and are armed with a sequence library. All of this means we can design our app to be ergonomic from the very start. If we can afford that performance-wise, of course.

I’ve written this post primarily with libraries development (Jackson) in mind. Everything I said can be used in app development as well, though. The system’s core or domain should can be viewed as a standard library used by your app. The main advantage to this approach is that a good library can be used by lots of apps, and you will normally have at least two—the main app and the tests. I’ll cover the details of viewing the domain module as a library some other time, though. It’s worthy of its own dedicated post. :)


P.S. Perhaps you’ll say that a lazy Sequence isn’t the same as a eager Writer, and you’ll always have a harder time will be less used to writing this way. Thanks to coroutines, though, even that’s not true:

val obj = ""
val seq = sequence<Char> {
    yield('{')
    obj::class.memberProperties.forEach {
        yieldAll("\"${it.name}\": ${it.call()}".asSequence())
    }
    yield('}')
}