Differ
hana.differ
0.1.12
github.com/ultish/differ

KSP writes the comparator. You keep the bits and the events.

Annotate the fields you care about. The processor emits a *Differ that compares two values of the same type. hasChanged is a bit test. changes holds only what moved, keyed by id, never by list position. The walk is ordinary generated Kotlin. No reflection, no JSON tree, no interpreter.

Why it is fast

You mark the fields that matter. At compile time KSP writes a *Differ of ordinary Kotlin ifs. There is no reflection at runtime, no JSON tree, no interpreter. hasChanged is a bit test. Old and new values are copied only for fields that moved. Nested lists and maps are matched by id, so a shuffle is not a change.

You can write that walk yourself. On our bench it is a bit faster. You then own every if, every bit, and every add/remove branch. A new field is another edit. A rename can silently drop a check. Differ regenerates the walk from the annotations. A tagged rename fails the build. The result still has the bits, the sealed events, and the keyed maps.

Both sides of a diff are the same Kotlin type. Convert Avro, Mongo, or a Kafka payload when it arrives. If stored ids are String and the event uses UUID, parse once at that boundary. The generated walk then does plain ==.

256 child objects, nothing changed time vs this library
Hand-written walk of the same model13 µs0.6×
This library21 µs1×
JSON Patch (Jackson + zjsonpatch)0.82 ms39×
JaVers3.7 ms176×

Java 17, Apple silicon. One nested document: site, two id-keyed link lists, an alarm set, a recipe map. All four rows match children by id and record old/new. JSON is Jackson plus zjsonpatch (RFC 6902). That library compares arrays by position, so the timed walk matches by id like Differ does. Re-measure on your documents.

What you write

// same type on both sides
@Differ
data class Pet(
  val id: String,
  @Tracked val name: String,
  @Tracked(captureValues = false)
  val notes: String,
  @Tracked val nickname: String?,
  @TrackedNested val owner: Person,
  @TrackedNested val vet: Person?,
  @TrackedList val toys: Set<Toy>,
  @TrackedList val walks: List<Walk>,
  @TrackedMap val tricks: Map<String, Trick>,
  @TrackedMap val tags: Map<String, String>,
)
val diff = PetDiffer.diff(old, new)
if (!diff.hasChanged) return
if (diff.hasName) { /* bit test */ }

for (change in diff.changes) {
  when (change) {
    is PetChange.ToysName -> {
      change.id
      change.old
      change.new
    }
    is PetChange.Vet -> change.new
    else -> {}
  }
}

diff.toys["t1"]?.name?.new
diff.tags["color"]?.value?.new

Child types do not take @Differ. Their tagged fields fold into the generated file. PetDiffer is the entry. PetDiffer.diff returns a PetDiff. The bits and events live on that result. A typo does not compile. A rename of a tagged property fails the build. A new untagged field is invisible.

// generated. Do not edit. One hasX / event per tagged path.
object PetDiffer {
  fun diff(old: Pet, new: Pet): PetDiff
}

class PetDiff {
  val hasChanged: Boolean
  val hasName: Boolean
  val hasNotes: Boolean
  val hasNickname: Boolean
  val hasOwnerAge: Boolean
  val hasToysName: Boolean
  val hasToysAdded: Boolean
    // ...
  val changes: List<PetChange>
  val toys: Map<String, PetToysChange>
    // ...
}

sealed interface PetChange {
  data class Name(val old: String, val new: String)
  data object Notes
  data class ToysName(val id: String, val old: String, val new: String)
    // ...
}

One run of that API

This is the model KSP read. Each tagged path becomes a property on PetDiff and a type on PetChange. The table under it is one diff of that shape. There is no has("toys[].name") string.

@Differ Pet
  id
  @Tracked name                              hasName
  @Tracked(captureValues = false) notes      hasNotes
  @Tracked nickname: String?                 hasNickname
  @TrackedNested owner: Person
    @Tracked age                             hasOwnerAge
  @TrackedNested vet: Person?
    presence                                hasVet
    @Tracked age                             hasVetAge
  @TrackedList toys: Set<Toy>
    [+]  [-]                                hasToysAdded  hasToysRemoved
    @Tracked name                             hasToysName
  @TrackedList walks: List<Walk>
    [+]  [-]                                hasWalksAdded  hasWalksRemoved
    @Tracked park                             hasWalksPark
  @TrackedMap tricks: Map<String, Trick>
    [+]  [-]                                hasTricksAdded  hasTricksRemoved
    @Tracked level                            hasTricksLevel
  @TrackedMap tags: Map<String, String>
    [+]  [-]                                hasTagsAdded  hasTagsRemoved
    scalar value                            hasTags
slot generated bit from this run
00hasName@Tracked nameHana → Hana-chan
01hasNotes@Tracked(captureValues = false) notesbit on, no old/new
02hasNickname@Tracked nickname: String?null → pup
03hasOwnerAge@TrackedNested owner + @Tracked age3 → 4
04hasVet@TrackedNested vet: Person?null → Kim. Presence only. No hasVetAge.
05hasToysName@TrackedList toys + @Tracked nameid t1, ball → squeaky
06hasToysAddedKSP [+] on toysid t2, rope
07hasToysRemovedKSP [−] on toysoff
08hasWalksPark@TrackedList walks + @Tracked parkid w1, park → beach
09hasWalksAddedKSP [+] on walksid w2, lake
10hasTricksLevel@TrackedMap tricks + @Tracked levelkey sit, 1 → 2
11hasTricksAddedKSP [+] on trickskey paw
12hasTags@TrackedMap tags (scalar V)key color, red → cream
13hasTagsAddedKSP [+] on tagskey size, shiba

Two hundred toys still share hasToysName. Slots are kinds, not rows. The mask is a Long, then a LongArray past 64 slots.

Install

plugins {
  kotlin("jvm") version "2.1.21" // or newer
  id("com.google.devtools.ksp") version "2.1.21-2.0.2" // match your Kotlin
}

implementation("io.github.ultish:differ-annotations:0.1.12")
ksp("io.github.ultish:differ-processor:0.1.12")

Maven Central. Imports stay hana.differ. Kotlin 2.1 is the floor. Apply a KSP plugin that matches your Kotlin (2.1.21-2.0.2, 2.2.x-2.0.x, 2.3.x). The processor is the KSP 2.1 API. Newer KSP can run it.

Rules that matter

Lists, sets, and maps match by id (or map key), never by order. TreeSet and TreeMap count. null list or map is empty. Optional nested objects record presence. Both null is unchanged. One null is a single event with the whole object. Primitive arrays are not keyed collections. Mark them @Tracked and compare the whole array.