Deep dive
This page explains how serialkompat works inside: extraction, classification, and the git-ref baseline. For the settings you can change, see Configuration. For the full rule matrix, see Rules.
Architecture
flowchart LR
A["@Serializable model"] --> B[Extractor]
B --> C[Snapshot]
C --> D[Differ]
D --> E[Classifier]
E --> F[Report]
Extraction is the only stage that touches a runtime SerialDescriptor or the JVM.
Everything from Snapshot onward is plain data.
The compatibility engine (the Differ and the Classifier) lives in
serialkompat-core. That module 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.
This separation 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, without running serialization at all.
Extraction
The extractor runs in a separate JVM on your project's own runtime classpath, ahead
of its own dependencies. It loads your real compiled @Serializable classes,
against the kotlinx-serialization version your project actually uses.
It finds the types to check in one of two ways. You can list them explicitly, or it
can scan your compiled class directories for @Serializable classes. The discovery
mode (EXPLICIT, OPT_OUT, or OPT_IN) decides which scanned types count. See
Configuration for details.
From those root types, SnapshotExtractor walks the SerialDescriptor graph
breadth-first. A visited set handles cycles and shared types. For each element it
records:
- the wire key: the actual JSON key after
@SerialNameand anynamingStrategy, not the Kotlin property name; - whether it's optional and whether it's nullable;
- its type. A value class is recorded as its underlying type, and that value's
nullability is part of the type:
@JvmInline value class Id(val raw: String?)iskotlin.String?, so narrowing it toStringis caught; - for optional fields, its
@EncodeDefaultmode.
@EncodeDefault isn't visible through the descriptor, so the extractor reads it
from the compiled class's bytecode. If it can't read the annotation (for example,
behind a hand-written serializer), it records the mode as unknown. It never
assumes the annotation is absent.
Enum values, sealed subtypes, and open polymorphism resolved through the
SerializersModule all go into the same Snapshot.
A subtype's @SerialName only has to be unique within its base, so two sealed
bases can each have a created subtype. serialkompat therefore identifies a
subtype by its serial name and its base, and reports it as Base/sub (for
example OrderEvent/created). Each one is checked against its own base. 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).
When a type can't be analysed
Some types can't be analysed. The extractor doesn't skip these or guess. It
records each one as an OPAQUE contract: present in the snapshot, diffable, and
round-trippable, but flagged. Examples:
- a
@Contextualfield. Its real serializer is picked at runtime, so the descriptor doesn't show its shape. - an open polymorphic base with no visible subtypes, for example because it isn't
registered in the
SerializersModuleyou pointed serialkompat at. Its subtypes are only knowable from the module, so an empty list can't be trusted. - a type whose serializer can't load, such as a field type missing from the
runtime classpath (
NoClassDefFoundError) or a serializer whose static initializer throws. - a generic type used with different type arguments in the same graph, such as
Page<User>andPage<Order>. Type arguments aren't part of a contract's identity yet, so serialkompat can't check each instantiation separately. - a generic sealed hierarchy, an unrecognized
SerialKind, or a class file that can't be read.
This is a core guarantee: the extractor never throws on a model it can't fully
analyse, and unanalysable never means safe. The classifier reports every opaque
type as a COVERAGE_GAP WARN, never a silent pass.
A type that was opaque in the baseline but is analysed now (after a code change, or
a newer serialkompat) has no old shape to compare with. It is reported as one
COVERAGE_GAP WARN, not as a removal plus an addition. The reverse, analysed to
opaque, is still a CONTRACT_REMOVED BREAK plus the gap, so lost coverage stays
loud.
Note
A broken configuration is different from a hard-to-analyse model. If you set
jsonInstance and it can't be loaded, extraction fails instead of guessing
with the default Json config.
Classification and the oracle
The Differ lists the structural Changes between two snapshots. The
Classifier then scores each change per direction:
- backward: new code reads data written by old code;
- forward: old code reads data written by new code.
FULL checks both. Scoring also uses the real Json { } config in play
(ignoreUnknownKeys, encodeDefaults, explicitNulls, coerceInputValues, and
more), so the same change can be SAFE under one reader config and BREAK under
another. See Rules for the full matrix.
None of this comes from reading kotlinx-serialization's source or spec. Every rule is backed by a round-trip oracle test. The test serializes a payload with the old model and decodes it with the new one (and the reverse), using the real library under the declared config. It then checks that the classifier predicted what actually happened.
flowchart LR
subgraph Oracle test
S1[Old model] -->|encode payload| P((JSON))
P -->|decode| S2[New model]
S2 --> R{Matches classifier's<br/>predicted verdict?}
end
A rule without an oracle test doesn't ship. That's what turns "the classifier
predicts SAFE" into a verified claim about real decode behavior, rather than an
assumption about the spec.
The git-ref baseline
The baseline is the schema you compare against. With serialkompat it isn't a
file you commit and then forget to update. You name a git ref with baselineRef
(or let the plugin detect your default branch), and GitRefBaseline rebuilds the
baseline from that ref's source on demand.
It resolves the ref to a commit SHA and checks that exact commit out into a
temporary, detached git worktree. Inside it, it runs a nested Gradle build of that
module's serialkompatExtract task. So every run
compares against what's actually on that ref right now.
sequenceDiagram
participant Task as serialkompatCheck
participant Git as GitRefBaseline
participant WT as temp worktree
participant Cache as SnapshotCache
Task->>Git: resolve baselineRef
Git->>Cache: lookup by commit SHA + version
alt cache hit
Cache-->>Git: cached Snapshot
else cache miss
Git->>WT: git worktree add (detached, at SHA)
WT->>Git: extract Snapshot
Git->>Cache: store, keyed by SHA + version
Git->>WT: remove worktree
end
Git-->>Task: baseline Snapshot
Task->>Task: diff baseline vs. current, classify, report
Caching
The baseline is cached per module under build/serialkompat/baseline/, keyed by
the resolved commit SHA and the serialkompat version. A commit's source never
changes, so each SHA is extracted only once per serialkompat version. After an
upgrade, the baseline is re-extracted rather than reusing an older version's.
The cache can't hand you a bad baseline. Writes are atomic. An entry that doesn't parse as a snapshot counts as a miss: it is deleted and re-extracted.
serialkompatExtract is also build-cacheable and relocatable, so its output can be
restored from a checkout at another path. That includes the baseline worktree:
with a shared or remote build cache, an already-built commit's baseline is
restored from the cache instead of re-running the extractor.
The check tasks are never cached, on purpose. Their verdict depends on what the baseline ref points at right now, and that isn't a task input.
Robustness
- Stale worktrees self-heal. If an interrupted run leaves a worktree behind, the next run prunes and removes it instead of getting stuck.
- Ref resolution is fail-closed. A ref that doesn't resolve fails the run. It never silently compares against nothing.
- Multi-module builds take turns. A shared build service lets only one module
extract a baseline at a time. Modules never race on the same repository, and
you never get many nested builds started at once. The plugin works under
--parallel, the configuration cache, and Gradle Isolated Projects.
Further reading
This page is a summary. 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.