Modeling Domain State with Kotlin Sealed Classes and Interfaces
Kotlin sealed classes and interfaces model finite domain states with compiler-checked exhaustive when expressions. Covers syntax, serialization, sealed interfaces for multiple inheritance, and production trade-offs: binary compatibility, DEX count, R8 stripping.
17 Aug 2026, 22:20 UTC

The problem with enums and abstract classes
You're building a feature that tracks a network request: it can be Loading, Success with a payload, or Error with a message and a retry flag. An enum forces every variant to share the same fields, so you end up with nullable payloads and messy when branches. An abstract class lets each subclass hold its own data, but the compiler can't guarantee you've handled every case — a new subclass added next month silently slips through your when without an else.
Kotlin sealed classes and sealed interfaces solve both problems: they restrict subclasses to a known set (same module for sealed classes, any module for sealed interfaces since 1.5) so the compiler verifies exhaustive when expressions, while still letting each variant carry distinct state.
Sealed class hierarchy with data subclasses
Define the hierarchy in one file (or one module) so the compiler sees all direct subclasses:
sealed class Result {
data class Success(val value: T) : Result()
data class Error(val message: String, val retryable: Boolean) : Result()
object Loading : Result()
}
Each variant is a data class (or object) — you get equals, hashCode, toString, and destructuring for free. Now a consumer can write:
fun render(result: Result): String = when (result) {
is Result.Success -> "Welcome, ${result.value.name}"
is Result.Error -> "Failed: ${result.message}"
Result.Loading -> "Loading…"
// no else needed — compiler errors if a new subclass appears
}
The when subject is statically typed as Result, so exhaustiveness checking works. Cast it to Any? or a supertype and you lose the guarantee.
Sealed interfaces for multiple inheritance
Sometimes a state implements several concerns. Imagine a UI state machine where screens can be AuthRequired, Cacheable, or both. Sealed interfaces (Kotlin 1.5+) let you compose markers while preserving exhaustiveness:
sealed interface Screen
sealed interface AuthRequired
sealed interface Cacheable
data class ProfileScreen(val userId: String) : Screen, AuthRequired, Cacheable
data class LoginScreen(val redirect: String) : Screen, AuthRequired
data class SettingsScreen() : Screen, Cacheable
object SplashScreen : Screen
A when over Screen remains exhaustive. Adding a new Screen implementation forces a recompile of all when expressions — a breaking binary change, but one the compiler catches for you.
Serialization: explicit discriminant required
Sealed hierarchies don't carry a built-in discriminant field. With kotlinx.serialization 1.6+ you annotate the sealed class and each subclass:
@Serializable
sealed class Result {
@Serializable data class Success(val value: T) : Result()
@Serializable data class Error(val message: String, val retryable: Boolean) : Result()
@Serializable object Loading : Result()
}
The encoder writes a type field (customizable via @SerialName or a custom KSerializer). Jackson and Moshi need analogous @JsonSubTypes or JsonClassDiscriminator configuration. Forget the setup and you'll get a runtime serialization exception, not a compile-time error.
Trade-offs and limitations
- Binary compatibility: Adding a sealed subclass in a published library forces all consumers to recompile. For plugin architectures, consider
open sealed class(Kotlin 2.0) to allow controlled extensibility outside the module. - DEX method count on Android: Many small sealed subclasses increase method count. Leaf nodes without identity can use
@JvmInline value classinstead. - Reflection:
::class.sealedSubclassesreturns only direct children; transitive sealing requires recursive traversal. - R8/ProGuard: The synthetic
$EnumSwitchMapping$class used for O(1)whendispatch can be stripped. Add keep rules if you serialize sealed hierarchies.
Actionable checklist
- Model closed domain states as sealed classes with data subclasses; use sealed interfaces when a state implements multiple traits.
- Write
whenexpressions withoutelseand compile with-Xexhaustive-when(default in Kotlin 1.7+) to get compiler errors for missing cases. - Configure your serialization library's polymorphic support before shipping — inspect the JSON output for the discriminant field.
- If you publish a library, treat new sealed subclasses as breaking changes; bump the major version and document migration steps.
- Profile hot
whenpaths with JMH on your target JDK; the compiler's ordinal-based dispatch usually beats manualinstanceofchains.
Sealed hierarchies give you the safety of enums with the flexibility of class hierarchies. Use them for any finite set of domain states that carry data — and let the compiler do the bookkeeping.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.