Skip to content

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 @SerialName and any namingStrategy, 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?) is kotlin.String?, so narrowing it to String is caught;
  • for optional fields, its @EncodeDefault mode.

@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 @Contextual field. 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 SerializersModule you 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> and Page<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.

Next

  • Rules: the full classifier rule matrix.
  • Recipes: task-oriented usage.