Skip to content

Commit 6138e7c

Browse files
committed
docs: add model-layer codegen design specifications
Add a docs/codegen/ directory of design specifications for the planned model-layer code generator. These are design documents only — no generator or generated code is added to the repository. - json-field-model.md: a dependency-free four-state JSON field wrapper (JsonField<T>: Known / Missing / Null / Raw) plus an embedded RawJson tree, with all Jackson conversion kept behind the Serde SPI in the adapter, mirroring how Tristate is split. Documents the read-path (forward-compat) vs PATCH-path (three-state) boundary so JsonField and Tristate are never wrongly merged. - model-classes.md: generated models as a field map plus one-line typed accessors over a hand-written runtime that owns the invariant machinery, with an explicit coverage (module-scoped Kover exclusion) and binary-compatibility (separate .api baseline) strategy for generated code. - model-validation.md: an opt-in, memoized, fail-soft validate()/isValid()/ validity() triad that is never run on the deserialize path and serves as the fallback union-disambiguation strategy behind discriminator matching. - discriminator-const-fields.md: const and discriminator fields generated as defaulted raw values with dual (typed + raw) accessors. Add a docs/codegen/README.md index and link it from the repository README documentation table.
1 parent 80dfbf1 commit 6138e7c

6 files changed

Lines changed: 777 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ Two further modules build but are never published: `sdk-example`, the runnable e
9898
| [I/O module](docs/io.md) | I/O contracts and the `IoProvider` seam |
9999
| [HTTP body logging and concurrency](docs/http-body-logging-and-concurrency.md) | Body logging system, concurrency model, thread safety |
100100
| [Pipeline mechanism](docs/pipelines.md) | Pipeline architecture, stages, step composition, async pipeline |
101+
| [Codegen design specs](docs/codegen/README.md) | Design specifications for the planned model-layer code generator |
101102
| [Style guides](styleguide/README.md) | Kotlin and Kotlin-on-JVM style guides this codebase follows |
102103

103104
## Usage

docs/codegen/README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Codegen design specifications
2+
3+
This directory holds design specifications for the planned model-layer code generator. They are
4+
**design documents only** — there is no generator code, no KotlinPoet templates, and no generated
5+
sources in this repository yet. Every Kotlin/Java snippet in these specs is illustrative *target
6+
output*: it shows the shape a future generator would emit, and is not compiled as part of the build.
7+
8+
The guiding principle across all of these specs is the same one that already governs `sdk-core`:
9+
**logic lives in a hand-written runtime, generated code is thin.** A generated model is a field list
10+
plus accessors; everything that is invariant across models — the four-state field representation,
11+
serde wiring, validation scoring, dual typed/raw access — is written once in `sdk-core` (or an
12+
adapter) and shared. This keeps generated files small, keeps the binary-compatibility baseline for
13+
generated code stable, and keeps the coverage floor meaningful.
14+
15+
These specs build on the existing `sdk-core` serde surface — primarily
16+
[`Tristate`](../../sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Tristate.kt) and the
17+
[`Serde`](../../sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Serde.kt) SPI — and on the
18+
Jackson adapter pattern established by
19+
[`TristateModule`](../../sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/TristateModule.kt).
20+
21+
## Specifications
22+
23+
| Spec | Covers |
24+
|---|---|
25+
| [The four-state JSON field model](json-field-model.md) | `JsonField<T>` + `RawJson`: a dependency-free four-state field wrapper and embedded JSON tree, with all Jackson conversion behind the `Serde` SPI. The foundation the rest build on. |
26+
| [Thin model classes over a hand-written runtime](model-classes.md) | Generated models as a field map + typed accessors; runtime carries the invariant machinery. Coverage and binary-compatibility strategy for generated code. |
27+
| [The validate()/isValid()/validity() triad](model-validation.md) | An opt-in, memoized, fail-soft validation triad on generated models — never run on the deserialize path; the fallback union-disambiguation strategy. |
28+
| [Discriminator and const fields](discriminator-const-fields.md) | Const/discriminator fields generated as defaulted raw values with dual (typed + raw) accessors. |
29+
30+
## Dependency order
31+
32+
```
33+
json-field-model.md (the foundation: JsonField<T> + RawJson)
34+
|
35+
+-- model-classes.md (thin models over the runtime)
36+
+-- model-validation.md (validate/isValid/validity triad)
37+
+-- discriminator-const-fields.md (defaulted raw + dual accessors)
38+
```
39+
40+
Read `json-field-model.md` first; the other three assume its vocabulary (`Known` / `Missing` /
41+
`Null` / `Raw`, `RawJson`, the read-path vs PATCH-path boundary).
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Discriminator and const fields
2+
3+
> **Status:** design specification. The snippets show the *target shape* a future generator would
4+
> emit. Nothing here is compiled in this repository.
5+
6+
Builds on [the four-state JSON field model](json-field-model.md) and
7+
[thin model classes](model-classes.md).
8+
9+
## Problem
10+
11+
Two closely related field kinds fall out of the four-state field model and need a dedicated codegen
12+
template:
13+
14+
- **Const fields.** A schema pins a field to a fixed value (`"object": "user"`, `"version": 2`). The
15+
generated model should default it to that value so a caller never has to set it, while still
16+
surviving a server that sends something *other* than the expected constant (forward compatibility —
17+
a const today may gain new allowed values tomorrow).
18+
- **Discriminator fields.** A union keys member selection off a field's value (`"type": "circle"` vs
19+
`"type": "square"`). The discriminator must be readable *both* as the typed enum the model expects
20+
*and* as the raw wire value, because union resolution reads the raw value before any member has been
21+
chosen, and forward compat requires tolerating a discriminator value we do not yet have a member
22+
for.
23+
24+
Both want the same thing: a field that has a **sensible default** and exposes **both a typed and a
25+
raw view**. That is the dual-accessor pattern.
26+
27+
## Proposed shape: defaulted raw value + dual accessors
28+
29+
A const/discriminator field is generated as a `JsonField<T>` (from
30+
[json-field-model.md](json-field-model.md)) that **defaults to the const's raw value** and exposes
31+
two getters and two setters.
32+
33+
```kotlin
34+
// TARGET OUTPUT — generated const field on a model. Illustrative; not compiled here.
35+
public class User /* private constructor(...) : JsonModel() */ {
36+
37+
// ---- const field "object" pinned to "user" ----
38+
39+
/** Typed accessor: the const projected to its declared type. Returns the default const when the
40+
* field was absent, and the typed value when the server sent the expected shape. A server value
41+
* that does not match T comes back via the raw accessor instead (forward-compat). */
42+
public fun objectType(): JsonField<String> = field("object").orDefault(DEFAULT_OBJECT)
43+
44+
/** Raw accessor: the underlying wire value, whatever it was — including an unexpected constant a
45+
* newer server sent that this model has no typed mapping for. */
46+
public fun _objectType(): RawJson = field<String>("object").asRaw(DEFAULT_OBJECT_RAW)
47+
48+
public companion object {
49+
private const val DEFAULT_OBJECT: String = "user"
50+
private val DEFAULT_OBJECT_RAW: RawJson = RawJson.Str("user")
51+
}
52+
53+
public class Builder /* : Builder<User> */ {
54+
private val acc = LinkedHashMap<String, JsonField<*>>()
55+
56+
/** Typed setter: set the const/discriminator to a typed value. */
57+
public fun objectType(value: String): Builder = apply { acc["object"] = JsonField.known(value) }
58+
59+
/** Raw setter: forward an arbitrary wire value verbatim — used to round-trip an unknown
60+
* discriminator value the SDK does not model yet. */
61+
public fun objectType(raw: RawJson): Builder = apply { acc["object"] = JsonField.raw(raw) }
62+
}
63+
}
64+
```
65+
66+
### The default is applied at the accessor, not baked into the stored field
67+
68+
The stored field map ([model-classes.md](model-classes.md)) keeps the *actual* state — `Missing` when
69+
the server omitted the key, `Known`/`Raw` when it sent something. The const default is applied by the
70+
accessor (`orDefault(...)` / `asRaw(default)`), not written into the map on construction. This keeps
71+
two properties:
72+
73+
- **Round-trip fidelity.** A model that was deserialized from a payload that omitted the const
74+
re-serializes without inventing a key (`additionalProperties()` and the serializer see `Missing`),
75+
unless a caller explicitly set it. The default is a *read-time* convenience, not a *write-time*
76+
fabrication.
77+
- **Forward compatibility.** A server that sends an unexpected const value stores it as `Raw`; the
78+
typed accessor still has a sane default to fall back on, and the raw accessor surfaces the real
79+
value so nothing is lost.
80+
81+
### Discriminator fields are the same template, plus a const value the union keys on
82+
83+
A discriminator is a const field whose value is what union resolution matches against. The dual
84+
accessor is what makes resolution work *before* a member is chosen:
85+
86+
```kotlin
87+
// TARGET OUTPUT — union member matching by discriminator. Not compiled here.
88+
public fun matchByDiscriminator(raw: RawJson): Shape? {
89+
// Read the RAW discriminator off the undecoded tree — no member committed yet.
90+
val tag = (raw as? RawJson.Object)?.entries?.get("type") as? RawJson.Str ?: return null
91+
return when (tag.value) {
92+
"circle" -> Circle.fromRaw(raw)
93+
"square" -> Square.fromRaw(raw)
94+
else -> null // unknown tag: let the caller fall back to validity scoring
95+
}
96+
}
97+
```
98+
99+
The raw accessor is load-bearing here: resolution must read the discriminator from `RawJson` before
100+
any typed model exists, and an unknown tag returns `null` so the union strategy can fall back to
101+
[validity scoring](model-validation.md) rather than throwing. This is exactly why the discriminator
102+
is the **first**, cheap path in `resolveUnion` (one raw read, O(1) member lookup) and scoring is the
103+
fallback.
104+
105+
## Why two getters and two setters per field
106+
107+
The dual-accessor pattern means every const/discriminator field contributes **two getters
108+
(`objectType()` typed, `_objectType()` raw) and two setters (typed `objectType(String)`, raw
109+
`objectType(RawJson)`)** to the public surface. This is deliberate, and it reinforces the binary-
110+
compatibility decision from [model-classes.md](model-classes.md):
111+
112+
- The generated surface is **wide and regular** — exactly the kind of large, mechanically-generated
113+
API that should live behind its **own `.api` baseline**, separate from the curated `sdk-core`
114+
surface, and be regenerated (`apiDump`) as part of a schema-update change rather than mixed into a
115+
hand-written API change.
116+
- Because every field follows the identical two-getter/two-setter template, the baseline churns
117+
predictably with the schema and never with the runtime.
118+
119+
## How it ties into the existing runtime
120+
121+
- **`JsonField` / `RawJson`.** The whole template is expressed in the four-state field types from
122+
[json-field-model.md](json-field-model.md): the typed accessor reads `Known`/falls back to the
123+
const default, the raw accessor reads `asRaw(...)`, and an unexpected server constant lands in
124+
`Raw`. No new field machinery is introduced.
125+
- **`Serde` SPI.** Re-serializing a const field round-trips through the
126+
[`Serde`](../../sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Serde.kt) serializer; an
127+
explicitly-set typed value serializes as the value, a `Raw` serializes verbatim, and a `Missing`
128+
const is omitted (the Jackson `JsonField` module's property writer skips it, the same way
129+
[`TristatePropertyWriter`](../../sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/TristateModule.kt)
130+
skips `Tristate.Absent`).
131+
- **Union resolution and validation.** The discriminator template is the cheap first path of the
132+
union strategy described in [model-validation.md](model-validation.md); validity scoring is only
133+
reached when the discriminator is absent or carries an unknown value.
134+
135+
## Design decisions and trade-offs
136+
137+
- **Default at read time, not construction time.** Applying the const default in the accessor keeps
138+
re-serialization faithful (no fabricated keys) and keeps the stored field map a truthful record of
139+
the wire. The trade-off is that the default lives in a generated constant per field rather than in
140+
the map; that is cheap and keeps models immutable and round-trip-safe.
141+
- **Dual accessors instead of a single typed-or-throw accessor.** A typed-only accessor would have to
142+
throw or lie when a server sends an unmodelled const/discriminator value. The raw sibling makes
143+
forward compatibility explicit and gives union resolution the pre-decode read it needs.
144+
- **Accepting the wide surface.** Two getters and two setters per field is more public API than a
145+
single typed accessor, but it is the cost of honest forward compatibility, and the separate `.api`
146+
baseline absorbs the churn so it never destabilizes the curated `sdk-core` surface.
147+
148+
## Acceptance mapping
149+
150+
- Const/discriminator template — const and discriminator fields generated as `JsonField<T>` defaulted
151+
to the const's raw value, with the default applied at the accessor and the discriminator readable
152+
pre-decode for union matching.
153+
- Dual accessors generated — a typed getter + raw getter and a typed setter + raw setter per field,
154+
forming the wide, regular surface that lives behind the separate generated-code `.api` baseline.

0 commit comments

Comments
 (0)