Skip to content

Supported hook for substituting serializers during a (de)serialization operation #3243

Description

@kamalnayan04

What is your use-case and why do you need this feature?

I work on an Android app whose entire UI is driven by a JSON payload from the backend. A single screen response is a few hundred KB, deeply nested, and touches dozens of distinct @Serializable types. Parsing it is one of the more expensive things the app does on a cold start.

Two things I keep wanting and cannot get without a lot of ceremony:

The first is per-type parse cost. I want to know which types actually dominate a decodeFromString call — not the total, which I already have, but the breakdown. If a Component holds a GradientData which holds a List<Color>, I want a timing around each of those, attributed to the type, so I can go and fix the expensive one. Today the only honest way to get that is to hand-write a wrapping serializer for every type I am curious about, which is exactly the set of types I don't know yet.

The second is failure attribution. When one of our own serializers throws, I want the type and the path to land in our metrics pipeline. 1.11.0 making JsonDecodingException public with path and offset on it helps a lot here, and I'm grateful for it, but it only covers exceptions Json itself raises — a NumberFormatException out of a custom serializer still arrives with no context (which I realise is #2165).

Both of these are the same shape of problem: I want to do something uniform to every serializer used in one decode, including generated ones and ones from libraries I don't own, without changing what any of them produce.

The constraint that rules out the obvious answer is that this has to stay on the streaming path. Going through JsonElement first is precisely the cost I am trying to measure, and at our payload sizes it isn't affordable anyway.

What I've tried, and where each one stops

I don't want to claim this is impossible today, because it isn't. It's just expensive in a way I think the library could make cheap.

@KeepGeneratedSerializer with @Serializable(with = ...) — this is the resolution of #793 / #1169 and it does the job, on the streaming path, with no JsonElement. But it's per type. It needs source access to every class and an annotation on each one, and a decent fraction of the types in our payload live in modules we don't own.

SerializersModule.contextual — doesn't override plugin-generated serializers, which is by design and documented. Making it apply means marking every use site @Contextual, or listing types in @file:UseContextualSerialization, so it lands back in the same place: I have to enumerate the types up front.

JsonTransformingSerializer — materialises a JsonElement, so it's out for the reason above.

A format that delegates to Json and substitutes serializers on the way through — this is the approach @pdvrieze sketched in #2699, and it's the one that actually does what I want. Because generated serializers route every nested value through decodeSerializableElement / decodeSerializableValue on whatever decoder they're handed, wrapping the decoder gets you recursion for free and never leaves the streaming path.

What puts me off shipping it is the cost of the wrapper rather than the idea. It means hand-delegating the whole Decoder and CompositeDecoder surface and remembering to re-wrap on beginStructure and decodeInline; it means also implementing JsonDecoder, or every serializer that does decoder as JsonDecoder breaks underneath me; and Decoder isn't guaranteed stable for implementation by user code, so I'd be signing up to re-check that wrapper on every library upgrade for the lifetime of the app.

So what I'm asking for isn't a new capability so much as a supported way to do something the library evidently already permits in principle.

Describe the solution you'd like

A small opt-in hook at the point where a serializer is about to be used — the decodeSerializableValue / decodeSerializableElement boundary — so that interception is recursive by construction and the streaming path is left alone. Something along the lines of:

interface SerializerInterceptor {
    fun <T> intercept(serializer: KSerializer<T>): KSerializer<T>
}

registered on the format:

val json = Json {
    interceptors += TimingInterceptor()
}

The properties that matter to me: it applies to generated serializers without any change to generated code or to the classes themselves; it applies recursively to properties, collection elements and map keys/values; it composes if more than one is registered; it costs nothing when none is; the wrapped result is cacheable per serializer rather than allocated per value; and it doesn't touch serializer resolution, only what the resolved serializer gets wrapped in.

I've deliberately written that as a sketch rather than a proposal, because where the hook belongs is the part I'd most like your opinion on.

One thing I am explicitly not asking for

An interceptor is an obvious place to catch an exception and substitute a fallback, and I want to be clear that I'm not asking for that. Decoder's own documentation says catching SerializationException from a decode* method is not allowed and leaves the decoder in an arbitrary state, and #2238 was closed on exactly that ground. Recovery needs a separate primitive — some way to skip or restore the current value — and that's a different discussion with different risks. Observation-only interceptors are useful on their own and don't need any exception-safety guarantee, so I'd rather keep the two apart.

Related issues

Questions before I write anything

  1. Should substitution sit at serializer resolution, on Decoder/CompositeDecoder, or purely as format-level configuration?
  2. Is there appetite for this in core at all? The alternative I'd be just as happy with is a stable, extendable DelegatingDecoder / DelegatingEncoder in the library, so the Wrapping a type's serializer with another without changing the actual type #2699 approach stops depending on API that isn't meant to be implemented downstream. That's a much smaller change and it solves my problem too.
  3. If it does go in core, where should caching of wrapped serializers live?

Happy to implement whichever direction you prefer — I'd just rather agree the shape first than send a PR you'd have to redesign.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions