You need to rename a serialized field across versions without breaking already-stored or in-flight payloads. Using kotlinx.serialization mechanics in this leaf, how do you stage that evolution safely?
answer
- Rename = remove + add -> never atomic
- Kotlin-only rename: @SerialName (no migration)
- Wire rename: overlap window with both keys
- Defaults (backward) + ignoreUnknownKeys (forward)
- Drop old key only after all old payloads gone
basics
~10 sDon't hard-rename. Add the new field with a default, keep accepting the old one, ignore unknown keys during transition, then drop the old field only after all old payloads are gone.
solid answer
~50 sA rename is a remove + add, which breaks compatibility if done at once. Stage it: (1) Add the new property with a default so readers without it still decode (backward compat). (2) Keep the old property present, or use `@SerialName("oldKey")` to map the wire name while the Kotlin name changes — but you can't have two properties bind the same key, so during overlap you keep both keys and reconcile in code. (3) Enable `ignoreUnknownKeys = true` (or `@JsonIgnoreUnknownKeys`) on consumers so a producer that has already moved to the new key doesn't blow up old readers (forward compat). (4) On encode, you may force `@EncodeDefault(Mode.ALWAYS)` on the new field so every payload carries it during migration. (5) After all stored/in-flight data uses the new key, drop the old property. The leaf's four tools — defaults, nullable, `ignoreUnknownKeys`, `@EncodeDefault.Mode` — together give the both-directions tolerance a rolling rename needs; `@SerialName` handles the pure wire-vs-Kotlin name decoupling without a data migration.
code
kotlin · 6 lines// Pure Kotlin-side rename, wire key unchanged, zero data migration:
@Serializable
data class Profile(
@SerialName("avatar_url") val avatarUrl: String? = null
)
// JSON key stays "avatar_url"; Kotlin property is the camelCase name.go deeper
Recognizes that renaming a field can break old payloads and that @SerialName maps a wire name.
Uses @SerialName for Kotlin-only renames and adds defaults so old data decodes.
Designs an overlap window combining defaults, nullability, and ignoreUnknownKeys, deferring removal.
Sets the migration policy: cheapest-correct path, both-direction tolerance, @EncodeDefault to force emission, and a removal release gated on zero old-key dependency.
## Why a rename is dangerous A rename is logically **delete old key + add new key**. If both happen in one release: - Old payloads (with the old key) hit the new class → old key is now *unknown* (skipped or, if strict, throws) and new key is *missing* → wrong/broken decode. - New payloads hit old readers → new key unknown, old key missing → broken. So you never flip atomically; you run an **overlap window**. ## Decouple wire name from Kotlin name first If you only want to change the *Kotlin* property name but keep the JSON key, use `@SerialName`: ```kotlin @Serializable data class User( @SerialName("login") val username: String // wire stays "login" ) ``` This needs **no data migration** — it's a pure naming alias and the cleanest 'rename'. ## Staging a true wire-key rename (data must migrate) Use the leaf's compatibility tools across the overlap: ```kotlin @Serializable data class Account( val id: Long, // old key, kept readable during transition; nullable+null so absence is OK val email: String? = null, // new key, default so old payloads still decode (backward compat) @EncodeDefault(EncodeDefault.Mode.ALWAYS) val contactEmail: String? = null, ) { fun effectiveEmail() = contactEmail ?: email } ``` Consumer config: ```kotlin val json = Json { ignoreUnknownKeys = true } // tolerate keys this version dropped/added ``` Migration steps: 1. **Release N:** add `contactEmail` (default), keep `email`. Readers tolerate either via `effectiveEmail()`. Enable `ignoreUnknownKeys` so a peer already writing only `contactEmail` doesn't break a node still on the old field set. 2. **Backfill:** migrate stored records / let traffic populate `contactEmail`; `@EncodeDefault(Mode.ALWAYS)` ensures new writes always include it. 3. **Release N+1:** once no payload relies on `email`, remove it. Removing a field is safe for readers that ignore unknown keys. ## Why each tool is needed - **Default values** → new field absent in old data won't throw (backward). - **Nullable + null** → distinguish 'not yet migrated' from a real value during reconciliation. - **ignoreUnknownKeys** → old readers tolerate the soon-to-be-removed/just-added keys (forward). - **@EncodeDefault(Mode.ALWAYS)** → guarantee the new key is emitted during the window so consumers can switch over deterministically. - **@SerialName** → if it's only a Kotlin-side rename, skip the whole dance. ## Governance angle The principal-level point: pick the **cheapest correct** path. Pure Kotlin rename → `@SerialName`, zero migration. Real wire rename → overlap window with the four tolerance tools, plus a removal release gated on 'no remaining old-key traffic'. Never remove a key while any writer still emits it or any stored record still depends on it.
- If you only want to change the Kotlin property name but keep the JSON key, what's the minimal change?Annotate the property with @SerialName("originalKey"); no data migration is needed.
- Why must removal of the old key be a separate, later release?Until all stored and in-flight payloads have migrated to the new key, some readers/writers still depend on the old one; removing early breaks them.
Like changing a road's name: post both old and new signs during a transition, redirect traffic, then take the old sign down once nobody uses it.
saying these in an interview costs you the question
- Renaming the wire key atomically in one release
- Assuming @SerialName migrates stored data (it doesn't change bytes already written)
- Removing the old field before traffic/stored data has migrated
- Forgetting forward compat (ignoreUnknownKeys) for nodes mid-rollout
- Two properties claiming the same SerialName