Architecture¶
This page follows one real run of serialkompatCheck from start to finish, so you can see
what serialkompat actually does and why you can trust its verdict. It's for anyone who
has seen a BREAK and wants to know where it came from. Contributors will find the
component map in Under the hood at the end.
The example is the one on the home page: a PR drops the optional note field
from Order.
@Serializable
data class Order(
val id: String,
val amountCents: Long,
- val note: String? = null,
)
One run, step by step¶
flowchart TD
PR["Your PR runs<br/>serialkompatCheck"] --> WT["Temporary git worktree<br/>at baselineRef"]
WT --> OLD["Old snapshot<br/>(baseline)"]
PR --> NEW["New snapshot<br/>(your compiled classes)"]
OLD --> DIFF["Diff:<br/>field 'note' removed"]
NEW --> DIFF
DIFF --> CLS["Classify both directions<br/>under your Json { } config"]
CLS --> REP["Report:<br/>BREAK (backward)"]
Build both schemas¶
1
Your PR runs serialkompatCheck. Locally, or in CI through check or the GitHub Action.
2
It builds the old schema. It checks out baselineRef (your target branch) in a temporary git worktree and runs the extractor there.
3 It builds the new schema by running the extractor on your PR's compiled classes, in place.
There's no baseline file to commit or forget to update: the old schema always comes from the code that's on the target branch right now. How the worktree is created, cached and cleaned up is in Baseline & caching.
Extract reads the serializer's own descriptor¶
serialkompat doesn't parse your source. The extractor runs in a separate JVM on your
project's own runtime classpath, ahead of its own dependencies, loads your compiled @Serializable classes, and reads the
SerialDescriptor that kotlinx-serialization itself uses to encode and decode them,
against the kotlinx-serialization version your project actually uses. Which classes count
as roots (an explicit types list, or a scan of your class directories) is covered in
Adopting gradually.
From the roots it walks the descriptor graph breadth-first; a visited set handles cycles and shared types. For each field it records:
- the wire key: the key on the wire after
@SerialNameand anynamingStrategy, not the Kotlin property name; - whether it's optional (has a default) and whether it's nullable;
- its type. A value class is recorded as its underlying type, including that value's
nullability:
@JvmInline value class Id(val raw: String?)iskotlin.String?, so narrowing it toStringis caught; - for optional fields, the
@EncodeDefaultmode. It isn't visible on the descriptor, so the extractor reads it from the class's bytecode. If it can't (for example, behind a hand-written serializer), it records the mode as unknown and never assumes it's absent.
Enum values, sealed subtypes and open polymorphism resolved through your
SerializersModule go into the same snapshot. A subtype's @SerialName only has to be
unique within its base, so subtypes are identified by base and name and reported as
Base/sub (for example OrderEvent/created). A subtype of two bases is recorded once
per base. In renames, acceptedBreaks, include
and exclude you can write Base/sub for one base or the bare sub for all of them (see
Configuration).
It also records the wire config of the Json { } (or Cbor { }) instance you point it at
with jsonInstance (or cborInstance): ignoreUnknownKeys, explicitNulls,
encodeDefaults and the rest. With nothing set, that's the default Json.
The two snapshots¶
The result is a plain-text snapshot, the same thing serialkompatExtract writes to
build/serialkompat/current.snapshot. This is real extractor output for Order before
and after the PR, with the @config block cut down to three of its lines:
@contract com.example.Order kind=CLASS
amountCents: kotlin.Long
id: kotlin.String
note: kotlin.String optional nullable encodeDefault=ABSENT
@config
encodeDefaults=false
explicitNulls=true
ignoreUnknownKeys=false
@contract com.example.Order kind=CLASS
amountCents: kotlin.Long
id: kotlin.String
@config
encodeDefaults=false
explicitNulls=true
ignoreUnknownKeys=false
note: kotlin.String optional nullable says everything the classifier needs: the wire key
is note, it has a default, and it can be null. encodeDefault=ABSENT means there's no
@EncodeDefault on it.
The diff¶
The differ compares the two snapshots contract by contract and lists structural changes.
Here there is exactly one: field note removed from com.example.Order. The differ
doesn't judge anything; it only says what changed.
The verdict, both ways¶
The classifier scores that one change in each direction, using the config recorded in the snapshots. Compatibility runs two ways: backward (new code reads old data) and forward (old code reads new data).
Backward: new code reads old data. BREAK
Payloads written before the PR are still out there, in queues, caches and databases,
and they still carry note:
{"id":"A1","amountCents":1299,"note":"leave at door"}
The new Order has no note, and the new reader uses the default
ignoreUnknownKeys = false, so kotlinx-serialization throws
JsonDecodingException: Encountered an unknown key 'note'. New code can't read old
data, which makes removing note a backward BREAK.
Forward: old code reads new data. SAFE
New code writes payloads without note:
{"id":"A1","amountCents":1299}
In the old Order, note was optional (= null), so old code fills in the default
and decodes the payload cleanly. Forward is SAFE.
A SAFE direction isn't reported, so the report has one line: the backward BREAK,
with a fix.
$ ./gradlew serialkompatCheck
serialkompat: 1 active finding(s) (1 breaking, 0 warning), 0 acknowledged
BREAK PROPERTY_REMOVED com.example.Order (backward)
field 'note' was removed from com.example.Order
fix: Removing a field drops its data for tolerant readers; keep it (or bridge a rename with @JsonNames) until nothing uses it; else bump major.
BUILD FAILED
The verdict depends on your config. The backward reader is the new code, so what
counts is the new side's ignoreUnknownKeys. Run the same PR with
Json { ignoreUnknownKeys = true } and the old payload decodes without an error, but
"leave at door" is silently thrown away. Nothing fails, yet data is lost, so
serialkompat reports it as a WARN instead of passing it:
serialkompat: 1 active finding(s) (0 breaking, 1 warning), 0 acknowledged
WARN PROPERTY_REMOVED com.example.Order (backward)
field 'note' was removed from com.example.Order
fix: Removing a field drops its data for tolerant readers; keep it (or bridge a rename with @JsonNames) until nothing uses it; else bump major.
Every setting that flips a verdict like this is listed in
Rules → Config awareness, and this rule's full entry is
PROPERTY_REMOVED.
Why you can trust the verdict¶
None of these verdicts come from reading kotlinx-serialization's source or spec. Each
rule is backed by a round-trip oracle test: it encodes a payload with the old model,
decodes it with the new one (and the reverse) using the real library under the declared
config, and checks that every real failure was predicted as a BREAK. For
PROPERTY_REMOVED that's the removing an optional field test in
RoundTripOracleTest.kt.
A second test there,
a field rename silently loses data under a lenient reader — flagged at least WARN,
proves the ignoreUnknownKeys = true case really drops data and is flagged as a WARN.
See The oracle for how this works and what it covers.
Under the hood¶
For contributors, the run above maps onto five components:
@Serializable
Extractor
Snapshot
Differ
Classifier
Report
- Extractor (
serialkompat-extractor): walks the runtimeSerialDescriptorgraph on the JVM (extract) and produces the snapshots. It's the only stage that touches a descriptor or the JVM. - Snapshot: a canonical, comparable model of the wire schema plus its config. From here on everything is plain data.
- Differ: lists the structural changes between two snapshots (the diff).
- Classifier: applies the rules and the recorded config to turn each change into a finding with a severity per direction (the verdict).
- Report: the findings plus an exit code, printed to the console and also available as
JSON, SARIF and GitHub annotations. The gate runs once and produces a
single
Report; each format is a pure function of it inserialkompat-core(no I/O, no kotlinx-serialization runtime), a read-only view of the same findings, so the format never changes the gate's result.
The differ and classifier live in serialkompat-core, which is plain Kotlin with no I/O
and no kotlinx-serialization runtime dependency. It never touches a file, a git repo or
a live descriptor. That has two payoffs: the Gradle plugin and the
CLI share one engine, so rule logic is never duplicated, and the
rules can be unit-tested against plain data.
Some types can't be fully analysed. The extractor never throws on them and never treats them as safe; see Limitations.
Further reading¶
The full engineering spec lives in the repo at
docs/design. It
covers the design rationale, the complete data model, and the reasoning behind each
guarantee.
Next¶
- The oracle: how each rule is proven against the real library.
- Baseline & caching: the git-ref worktree and the caches.
- Rules: the full rule matrix.