
Build typed property views over raw object maps, decode on-demand, edit known fields, and re-emit preserving unknown fields. Supports JSON/YAML delegates, slices and null-write modes.
Propigator is a Kotlin Multiplatform library for building typed views over raw object-shaped data while keeping the original payload forward-compatible. Use it when you want Kotlin properties for the fields your code understands, but you must not destroy fields you do not understand yet. This is useful for protocol objects, configuration files, extension points, signed or externally-owned payloads, and versioned data formats where newer producers may send fields older consumers should preserve.
Propigator currently provides object-backed wrappers for:
common: format-agnostic delegates and validation hooks.json: JsonObject backed objects.yaml: yamlkt YamlMap backed objects.Normal @Serializable data classes are great when your schema is the whole truth. They parse known fields into constructor parameters and usually ignore or reject the rest depending on format configuration.
Propigator uses a different model:
kotlinx.serialization.That means a downstream integrator can parse, inspect, edit, and re-emit objects without becoming the schema authority for every field in the document.
Propigator preserves unknown fields by design.
If an incoming JSON object contains:
{
"id": "42",
"name": "Grace",
"futureField": {
"addedBy": "newer-service"
}
}and your wrapper only knows id and name, futureField stays in the backing object and is emitted again when you serialize.
This makes Propigator a good fit for downstream tools that should be conservative:
Use the module matching the format you need.
dependencies {
implementation("at.asitplus.propigator:common:<version>")
implementation("at.asitplus.propigator:json:<version>")
implementation("at.asitplus.propigator:yaml:<version>")
}json depends on common and kotlinx-serialization-json.
yaml depends on common and yamlkt.
Define a nominal wrapper class around JsonObjectBacked. Add required properties with jsonProperty() and nullable properties with nullableJsonProperty().
@Serializable(with = PersonJsonObject.Serializer::class)
class PersonJsonObject(
raw: JsonObject,
json: Json = Json.Default,
) : JsonObjectBacked(raw, JsonBackingCodec(json)), ObjectBackedValidated {
var id: String by jsonProperty()
var name: String by jsonProperty()
var active: Boolean by jsonProperty("is_active")
var nickname: String? by nullableJsonProperty("nick", NullWriteMode.REMOVE_KEY)
override fun validate() {
id
name
}
object Serializer : KSerializer<PersonJsonObject> by JsonObjectBackedSerializer(::PersonJsonObject)
}Use it through kotlinx.serialization:
val json = Json { prettyPrint = true }
val person = json.decodeFromString(
PersonJsonObject.serializer(),
"""
{
"id": "42",
"name": "Grace",
"is_active": true,
"futureField": "preserved"
}
""".trimIndent()
)
person.name = "Grace Hopper"
person.nickname = "Amazing Grace"
val encoded = json.encodeToString(PersonJsonObject.serializer(), person)The encoded JSON contains the changed known fields and still contains futureField.
YAML works the same way, using YamlObjectBacked and YAML-specific delegates.
@Serializable(with = ServiceYamlObject.Serializer::class)
class ServiceYamlObject(
raw: YamlMap,
yaml: Yaml = Yaml.Default,
) : YamlObjectBacked(raw, YamlBackingCodec(yaml)), ObjectBackedValidated {
var id: String by yamlProperty()
var endpoint: String by yamlProperty()
var description: String? by nullableYamlProperty()
override fun validate() {
id
endpoint
}
object Serializer : KSerializer<ServiceYamlObject> by YamlObjectBackedSerializer(create = ::ServiceYamlObject)
}val yaml = Yaml.Default
val service = yaml.decodeFromString(
ServiceYamlObject.serializer(),
"""
id: payments
endpoint: https://example.test/payments
x-vendor-option: keep-me
""".trimIndent()
)
service.endpoint = "https://api.example.test/payments"
val encoded = yaml.encodeToString(ServiceYamlObject.serializer(), service)x-vendor-option is preserved.
Required properties use jsonProperty() or yamlProperty().
var id: String by jsonProperty()
var displayName: String by jsonProperty("display_name")If no key is supplied, the Kotlin property name is used as the object key. If a key is supplied, that key is used instead.
Reading a missing required property throws SerializationException:
val id = person.id // throws if "id" is absentNullable properties use nullableJsonProperty() or nullableYamlProperty().
var nickname: String? by nullableJsonProperty("nick")Missing keys and explicit format-native null values both read as null.
Read-only views are also useful:
val PersonJsonObject.publicName: String by jsonProperty("name")
val PersonJsonObject.optionalNick: String? by nullableJsonProperty("nick")Use jsonSlice() or yamlSlice() when you want to decode the entire backing object as an existing
@Serializable type instead of defining one delegated property per field.
A slice is a read-only view over rawObject. It uses the wrapper's configured JsonBackingCodec or
YamlBackingCodec, so the same format settings and serializers apply.
@Serializable
data class PublicClaims(
val iss: String,
val sub: String,
val aud: String,
)
@Serializable(with = ClaimsJsonObject.Serializer::class)
class ClaimsJsonObject(
raw: JsonObject,
json: Json = Json.Default,
) : JsonObjectBacked(raw, JsonBackingCodec(json)), ObjectBackedValidated {
val claims: PublicClaims by jsonSlice()
var nonce: String? by nullableJsonProperty()
override fun validate() {
claims
}
object Serializer : KSerializer<ClaimsJsonObject> by JsonObjectBackedSerializer(::ClaimsJsonObject)
}claims is decoded from the whole JSON object, while nonce remains an editable property backed by
the same raw object. Unknown fields are still preserved when the wrapper is serialized again.
YAML-backed objects provide the same pattern with yamlSlice():
val foo: Foo by yamlSlice()Propigator supports per-property null write behavior.
The default is NullWriteMode.STORE_NULL: assigning null stores a format-native null value.
var middleName: String? by nullableJsonProperty("middle_name")
person.middleName = null
// JSON: "middle_name": nullUse NullWriteMode.REMOVE_KEY when null should mean absence:
var nickname: String? by nullableJsonProperty("nick", NullWriteMode.REMOVE_KEY)
person.nickname = null
// JSON: "nick" is removedThis is intentionally per property. Some formats or schemas distinguish explicit null from an absent key; others do not. Propigator lets the wrapper encode that decision where the semantic meaning is known.
Propigator is designed for parse-not-validate workflows.
Parsing creates a typed view over the raw object. It does not require you to model every field in the payload. Unknown fields are kept as raw data.
By default, delegated required fields are checked when read:
val person = json.decodeFromString(PersonJsonObject.serializer(), payload)
// Missing "name" fails here, when the property is needed.
println(person.name)This is useful for downstream integrators that should accept future payloads, inspect a small subset, and forward the rest unchanged.
JOSE-style objects are a prime Propigator use case. They have a few core fields that many libraries need to understand, but they are also intentionally open-ended: deployments add domain-specific parameters, claims, headers, and policy fields.
For example, a JwsSigned wrapper may need typed access to signature-critical fields while preserving every application-specific field:
@Serializable(with = JwsSigned.Serializer::class)
class JwsSigned(
raw: JsonObject,
json: Json = Json.Default,
) : JsonObjectBacked(raw, JsonBackingCodec(json)) {
val protectedHeader: String by jsonProperty("protected")
val payload: String by jsonProperty()
val signature: String by jsonProperty()
object Serializer : KSerializer<JwsSigned> by JsonObjectBackedSerializer(::JwsSigned)
}
var JwsSigned.kid: String? by nullableJsonProperty("kid")
var JwsSigned.trustDomain: String? by nullableJsonProperty("trust_domain")
var JwsSigned.policyVersion: Int? by nullableJsonProperty("policy_version")An integrator can read the fields it needs:
val jws = json.decodeFromString(JwsSigned.serializer(), incoming)
val signature = jws.signature
jws.trustDomain = "example.eu"
val forwarded = json.encodeToString(JwsSigned.serializer(), jws)Fields not modelled by JwsSigned, including future JOSE extensions and domain-specific fields, stay in rawObject and are emitted again.
This is parse-not-validate by design. A component that routes or annotates a JOSE object may need payload and signature, but it should not reject an object because it does not understand a domain-specific property. Full JOSE validation belongs to the layer that has the keys, algorithms, policy, critical-header handling, and domain rules. Propigator keeps the object editable and forward-compatible until that layer needs to make a decision.
When you do need parse-time checks for your own mandatory fields, implement ObjectBackedValidated and touch those properties in validate():
override fun validate() {
id
name
}The format serializer calls validate() after decoding if the object implements ObjectBackedValidated.
Use this sparingly:
You can add semantic fields outside the nominal wrapper class.
var PersonJsonObject.locale: String? by nullableJsonProperty("locale")
val PersonJsonObject.displayLabel: String
get() = locale?.let { "$name ($it)" } ?: nameThis is useful when several downstream integrations share the same raw object but each integration owns different extension fields.
Use rawObject when you need to inspect, pass through, or debug the complete backing object.
val raw: JsonObject = person.rawObjectFor JSON, rawObject is a JsonObject.
For YAML, rawObject is a YamlMap.
The wrapper keeps an internal mutable backing map and exposes snapshots through rawObject.
Propigator serializers are attached to each nominal wrapper type:
@Serializable(with = PersonJsonObject.Serializer::class)
class PersonJsonObject(...) : JsonObjectBacked(...) {
object Serializer : KSerializer<PersonJsonObject> by JsonObjectBackedSerializer(::PersonJsonObject)
}The serializer reads and writes the raw object. Delegated properties are not discovered by the Kotlin serialization compiler plugin as constructor properties. They are semantic accessors over the backing object.
Use regular @Serializable data classes when:
Use Propigator when:
yamlkt.ObjectBackedValidated validates only what your validate() function reads.External contributions are greatly appreciated. Please observe the contribution guidelines (see CONTRIBUTING.md).
The Apache License does not apply to the logos (including the A-SIT logo) and the project/module name(s), as these are the sole property of A-SIT/A-SIT Plus GmbH and may not be used in derivative works without explicit permission!
Propigator is a Kotlin Multiplatform library for building typed views over raw object-shaped data while keeping the original payload forward-compatible. Use it when you want Kotlin properties for the fields your code understands, but you must not destroy fields you do not understand yet. This is useful for protocol objects, configuration files, extension points, signed or externally-owned payloads, and versioned data formats where newer producers may send fields older consumers should preserve.
Propigator currently provides object-backed wrappers for:
common: format-agnostic delegates and validation hooks.json: JsonObject backed objects.yaml: yamlkt YamlMap backed objects.Normal @Serializable data classes are great when your schema is the whole truth. They parse known fields into constructor parameters and usually ignore or reject the rest depending on format configuration.
Propigator uses a different model:
kotlinx.serialization.That means a downstream integrator can parse, inspect, edit, and re-emit objects without becoming the schema authority for every field in the document.
Propigator preserves unknown fields by design.
If an incoming JSON object contains:
{
"id": "42",
"name": "Grace",
"futureField": {
"addedBy": "newer-service"
}
}and your wrapper only knows id and name, futureField stays in the backing object and is emitted again when you serialize.
This makes Propigator a good fit for downstream tools that should be conservative:
Use the module matching the format you need.
dependencies {
implementation("at.asitplus.propigator:common:<version>")
implementation("at.asitplus.propigator:json:<version>")
implementation("at.asitplus.propigator:yaml:<version>")
}json depends on common and kotlinx-serialization-json.
yaml depends on common and yamlkt.
Define a nominal wrapper class around JsonObjectBacked. Add required properties with jsonProperty() and nullable properties with nullableJsonProperty().
@Serializable(with = PersonJsonObject.Serializer::class)
class PersonJsonObject(
raw: JsonObject,
json: Json = Json.Default,
) : JsonObjectBacked(raw, JsonBackingCodec(json)), ObjectBackedValidated {
var id: String by jsonProperty()
var name: String by jsonProperty()
var active: Boolean by jsonProperty("is_active")
var nickname: String? by nullableJsonProperty("nick", NullWriteMode.REMOVE_KEY)
override fun validate() {
id
name
}
object Serializer : KSerializer<PersonJsonObject> by JsonObjectBackedSerializer(::PersonJsonObject)
}Use it through kotlinx.serialization:
val json = Json { prettyPrint = true }
val person = json.decodeFromString(
PersonJsonObject.serializer(),
"""
{
"id": "42",
"name": "Grace",
"is_active": true,
"futureField": "preserved"
}
""".trimIndent()
)
person.name = "Grace Hopper"
person.nickname = "Amazing Grace"
val encoded = json.encodeToString(PersonJsonObject.serializer(), person)The encoded JSON contains the changed known fields and still contains futureField.
YAML works the same way, using YamlObjectBacked and YAML-specific delegates.
@Serializable(with = ServiceYamlObject.Serializer::class)
class ServiceYamlObject(
raw: YamlMap,
yaml: Yaml = Yaml.Default,
) : YamlObjectBacked(raw, YamlBackingCodec(yaml)), ObjectBackedValidated {
var id: String by yamlProperty()
var endpoint: String by yamlProperty()
var description: String? by nullableYamlProperty()
override fun validate() {
id
endpoint
}
object Serializer : KSerializer<ServiceYamlObject> by YamlObjectBackedSerializer(create = ::ServiceYamlObject)
}val yaml = Yaml.Default
val service = yaml.decodeFromString(
ServiceYamlObject.serializer(),
"""
id: payments
endpoint: https://example.test/payments
x-vendor-option: keep-me
""".trimIndent()
)
service.endpoint = "https://api.example.test/payments"
val encoded = yaml.encodeToString(ServiceYamlObject.serializer(), service)x-vendor-option is preserved.
Required properties use jsonProperty() or yamlProperty().
var id: String by jsonProperty()
var displayName: String by jsonProperty("display_name")If no key is supplied, the Kotlin property name is used as the object key. If a key is supplied, that key is used instead.
Reading a missing required property throws SerializationException:
val id = person.id // throws if "id" is absentNullable properties use nullableJsonProperty() or nullableYamlProperty().
var nickname: String? by nullableJsonProperty("nick")Missing keys and explicit format-native null values both read as null.
Read-only views are also useful:
val PersonJsonObject.publicName: String by jsonProperty("name")
val PersonJsonObject.optionalNick: String? by nullableJsonProperty("nick")Use jsonSlice() or yamlSlice() when you want to decode the entire backing object as an existing
@Serializable type instead of defining one delegated property per field.
A slice is a read-only view over rawObject. It uses the wrapper's configured JsonBackingCodec or
YamlBackingCodec, so the same format settings and serializers apply.
@Serializable
data class PublicClaims(
val iss: String,
val sub: String,
val aud: String,
)
@Serializable(with = ClaimsJsonObject.Serializer::class)
class ClaimsJsonObject(
raw: JsonObject,
json: Json = Json.Default,
) : JsonObjectBacked(raw, JsonBackingCodec(json)), ObjectBackedValidated {
val claims: PublicClaims by jsonSlice()
var nonce: String? by nullableJsonProperty()
override fun validate() {
claims
}
object Serializer : KSerializer<ClaimsJsonObject> by JsonObjectBackedSerializer(::ClaimsJsonObject)
}claims is decoded from the whole JSON object, while nonce remains an editable property backed by
the same raw object. Unknown fields are still preserved when the wrapper is serialized again.
YAML-backed objects provide the same pattern with yamlSlice():
val foo: Foo by yamlSlice()Propigator supports per-property null write behavior.
The default is NullWriteMode.STORE_NULL: assigning null stores a format-native null value.
var middleName: String? by nullableJsonProperty("middle_name")
person.middleName = null
// JSON: "middle_name": nullUse NullWriteMode.REMOVE_KEY when null should mean absence:
var nickname: String? by nullableJsonProperty("nick", NullWriteMode.REMOVE_KEY)
person.nickname = null
// JSON: "nick" is removedThis is intentionally per property. Some formats or schemas distinguish explicit null from an absent key; others do not. Propigator lets the wrapper encode that decision where the semantic meaning is known.
Propigator is designed for parse-not-validate workflows.
Parsing creates a typed view over the raw object. It does not require you to model every field in the payload. Unknown fields are kept as raw data.
By default, delegated required fields are checked when read:
val person = json.decodeFromString(PersonJsonObject.serializer(), payload)
// Missing "name" fails here, when the property is needed.
println(person.name)This is useful for downstream integrators that should accept future payloads, inspect a small subset, and forward the rest unchanged.
JOSE-style objects are a prime Propigator use case. They have a few core fields that many libraries need to understand, but they are also intentionally open-ended: deployments add domain-specific parameters, claims, headers, and policy fields.
For example, a JwsSigned wrapper may need typed access to signature-critical fields while preserving every application-specific field:
@Serializable(with = JwsSigned.Serializer::class)
class JwsSigned(
raw: JsonObject,
json: Json = Json.Default,
) : JsonObjectBacked(raw, JsonBackingCodec(json)) {
val protectedHeader: String by jsonProperty("protected")
val payload: String by jsonProperty()
val signature: String by jsonProperty()
object Serializer : KSerializer<JwsSigned> by JsonObjectBackedSerializer(::JwsSigned)
}
var JwsSigned.kid: String? by nullableJsonProperty("kid")
var JwsSigned.trustDomain: String? by nullableJsonProperty("trust_domain")
var JwsSigned.policyVersion: Int? by nullableJsonProperty("policy_version")An integrator can read the fields it needs:
val jws = json.decodeFromString(JwsSigned.serializer(), incoming)
val signature = jws.signature
jws.trustDomain = "example.eu"
val forwarded = json.encodeToString(JwsSigned.serializer(), jws)Fields not modelled by JwsSigned, including future JOSE extensions and domain-specific fields, stay in rawObject and are emitted again.
This is parse-not-validate by design. A component that routes or annotates a JOSE object may need payload and signature, but it should not reject an object because it does not understand a domain-specific property. Full JOSE validation belongs to the layer that has the keys, algorithms, policy, critical-header handling, and domain rules. Propigator keeps the object editable and forward-compatible until that layer needs to make a decision.
When you do need parse-time checks for your own mandatory fields, implement ObjectBackedValidated and touch those properties in validate():
override fun validate() {
id
name
}The format serializer calls validate() after decoding if the object implements ObjectBackedValidated.
Use this sparingly:
You can add semantic fields outside the nominal wrapper class.
var PersonJsonObject.locale: String? by nullableJsonProperty("locale")
val PersonJsonObject.displayLabel: String
get() = locale?.let { "$name ($it)" } ?: nameThis is useful when several downstream integrations share the same raw object but each integration owns different extension fields.
Use rawObject when you need to inspect, pass through, or debug the complete backing object.
val raw: JsonObject = person.rawObjectFor JSON, rawObject is a JsonObject.
For YAML, rawObject is a YamlMap.
The wrapper keeps an internal mutable backing map and exposes snapshots through rawObject.
Propigator serializers are attached to each nominal wrapper type:
@Serializable(with = PersonJsonObject.Serializer::class)
class PersonJsonObject(...) : JsonObjectBacked(...) {
object Serializer : KSerializer<PersonJsonObject> by JsonObjectBackedSerializer(::PersonJsonObject)
}The serializer reads and writes the raw object. Delegated properties are not discovered by the Kotlin serialization compiler plugin as constructor properties. They are semantic accessors over the backing object.
Use regular @Serializable data classes when:
Use Propigator when:
yamlkt.ObjectBackedValidated validates only what your validate() function reads.External contributions are greatly appreciated. Please observe the contribution guidelines (see CONTRIBUTING.md).
The Apache License does not apply to the logos (including the A-SIT logo) and the project/module name(s), as these are the sole property of A-SIT/A-SIT Plus GmbH and may not be used in derivative works without explicit permission!