skip to content

How and why do you enforce domain invariants on a @Serializable class so that an invalid instance can never be deserialized?

level: juniorimportance: must knowfreq 55%

answer

  1. Decoder calls the constructor → init {} runs
  2. require() throws IllegalArgumentException
  3. Make illegal states unrepresentable
  4. Defaults must also pass init {} checks
  5. Catch SerializationException + IllegalArgumentException at boundary

basics

~10 s

Put checks in the class's init {} block using require(). Deserialization calls the real constructor, so the checks run too. Bad input throws instead of producing a broken object.

solid answer

~40 s

kotlinx.serialization's generated deserializer calls the class's primary constructor with decoded values, which means the `init {}` block runs for deserialized instances exactly as for hand-constructed ones. Use `require(condition) { message }` inside `init {}` to assert invariants (non-empty, ranges, enum-like strings, cross-field consistency). A failed `require` throws `IllegalArgumentException`, so an object violating the invariant is never created — there is no path where a partially-valid instance escapes. This centralizes validation in the type itself ('make illegal states unrepresentable') instead of scattering checks across call sites. Note `init {}` runs in declaration order with property initializers, and that decoding wraps it: a thrown `IllegalArgumentException` surfaces from `decodeFromString`. Catch both it and `SerializationException` at the trust boundary.

go deeper

for a junior

Knows to put require() in init {} and that it runs on deserialization; can write a basic example.

for a middle

Explains require vs check, where the exception surfaces, and that defaults must satisfy invariants.

for a senior

Frames it as 'illegal states unrepresentable', distinguishes validate-only vs normalize (custom serializer), wires boundary catching.

for a principal

Sets a team convention for invariant location, considers performance of validation on hot decode paths and consistency across modules/contracts.

## Why init {} is the right place When the kotlinx.serialization compiler plugin generates `deserialize`, it reads each property value and then **invokes the primary constructor** of your class. Kotlin runs **property initializers and `init {}` blocks in source order** as part of construction. Therefore validation written in `init {}` executes for *every* instance — whether created in code or rebuilt from JSON. ```kotlin import kotlinx.serialization.* import kotlinx.serialization.json.Json @Serializable data class Account(val id: String, val balanceCents: Long, val email: String) { init { require(id.isNotBlank()) { "id required" } require(balanceCents >= 0) { "balance cannot be negative" } require('@' in email) { "invalid email" } } } ``` Decoding hostile JSON like `{"id":"","balanceCents":-1,"email":"x"}` throws **`IllegalArgumentException`** from `require`, so no `Account` with a negative balance ever exists. ## require vs check vs IllegalStateException - `require(...)` → throws `IllegalArgumentException`; use for *argument/precondition* validation (the deserialized values are inputs). This is the idiomatic choice here. - `check(...)` → throws `IllegalStateException`; for internal state, not input. - Throwing inside `init {}` is fine; it aborts construction. ## 'Make illegal states unrepresentable' By validating in the type, every consumer of `Account` can trust it. You avoid the anti-pattern of constructing the object first and validating later, where a window exists in which an invalid object is live (and might be logged, persisted, or passed on). ## Boundary handling A deserialized invariant violation surfaces as `IllegalArgumentException` out of `Json.decodeFromString`. Malformed JSON surfaces as `SerializationException`. At an untrusted boundary catch both: ```kotlin fun parseAccount(json: String): Account? = try { Json.decodeFromString<Account>(json) } catch (e: SerializationException) { null // malformed JSON / missing fields } catch (e: IllegalArgumentException) { null // failed init {} invariant } ``` ## Gotchas - Default values still flow through `init {}`, so defaults must also satisfy invariants. - `init {}` cannot fix data (it only accepts/rejects); for normalization use a private constructor + factory or a custom serializer. - Validation in `init {}` does not run for the *very rare* `@OptIn`-gated constructor-bypass paths; in standard kotlinx.serialization usage the constructor is always called.

  • What exception type does a failed require() in init {} produce, and where does it surface?
    IllegalArgumentException, which propagates out of Json.decodeFromString — so callers must catch it alongside SerializationException.
  • Can init {} normalize/clean a value, e.g. trim whitespace?
    No — init {} can only validate. To transform, store the normalized value via a property initializer expression or use a custom serializer/factory.

init {} is a bouncer at the only door: every guest (including the one arriving as JSON) gets checked before entering.

saying these in an interview costs you the question

  • Validating in a separate method called after construction (leaves a window for invalid objects)
  • Using check()/IllegalStateException for input validation
  • Assuming default property values bypass init {} checks
  • Forgetting that init {} throws on bad deserialized data and not catching it

context