Kotlin Sealed Interfaces: Modeling Complex Domain State Without Compromise

Go beyond sealed classes with sealed interfaces for exhaustive type hierarchies and multi-trait state models.

Introduction

Sealed classes provide exhaustive when expressions but limit inheritance. Sealed interfaces combine exhaustive matching with multiple inheritance. This guide covers advanced domain modeling with sealed interfaces.

Sealed Classes vs Sealed Interfaces

Sealed classes: single inheritance, known subclasses. Sealed interfaces: multiple inheritance, known implementors. Use sealed interfaces when types need multiple traits.

Modeling State Machines

Define states as sealed interface. Each state implements multiple trait interfaces. Use when for exhaustive state handling. Compiler ensures all states handled.

Domain Event Modeling

Model events as sealed interface hierarchy. Combine events with marker interfaces for categorization. Use when with smart casting. Events are self-documenting.

Cross-Cutting Concerns

Use sealed interfaces for permissions, feature flags, A/B tests. Combine traits orthogonally. Avoid inheritance explosions. Keep hierarchies flat.

Frequently Asked Questions

When should I use sealed interfaces over sealed classes?

Use sealed interfaces when types need multiple traits or when you need to combine hierarchies. Use sealed classes for simple, single-inheritance hierarchies.

Do sealed interfaces work with serialization?

Yes, with kotlinx.serialization. Use @Serializable on the interface and implementors. Use polymorphic serialization for runtime type preservation.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:37:45
UP00:01
MOD · ARTICLE · KOTLINS/N · AX-KOTLINPUBLISHED
KotlinMar 8, 202610 MIN

Kotlin Sealed Interfaces: Modeling Complex Domain State Without Compromise

Go beyond sealed classes: sealed interfaces for exhaustive type hierarchies, multi-trait state models, and compiler-enforced business rules in Android apps.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

Why Sealed Classes Are Not Enough

Sealed classes have been the backbone of state modeling in Kotlin since 1.0. They give you exhaustive `when` expressions, clear subtype hierarchies, and compile-time safety. But they have a fundamental constraint: single inheritance. A sealed class subtype can extend exactly one parent. In real-world Android applications, state often has multiple independent dimensions. A network response can be both `Cacheable` and `Paginated`. A UI event can be both `Trackable` and `Undoable`. With sealed classes alone, you end up with either combinatorial explosion (one subtype for every trait combination) or unsafe runtime casting. Kotlin 1.5 introduced sealed interfaces, which solve this elegantly. A type can implement multiple sealed interfaces simultaneously, giving you exhaustive pattern matching across multiple independent trait hierarchies. This is a game-changer for Android architecture.

Sealed Interface Fundamentals

A sealed interface restricts which types can implement it, just like a sealed class restricts subclasses. The permitted implementations must be defined in the same package (same module for Kotlin 1.5+). The compiler knows every possible implementation, enabling exhaustive `when` expressions. The critical difference from sealed classes is that a class can implement multiple sealed interfaces. This gives you the power of algebraic data types with the flexibility of interface composition.
kotlin
// Single-dimension sealed class (traditional)
sealed class NetworkResult<out T> {
    data class Success<T>(val data: T) : NetworkResult<T>()
    data class Error(val code: Int, val message: String) : NetworkResult<Nothing>()
    data object Loading : NetworkResult<Nothing>()
}

// Multi-dimension sealed interfaces (modern)
sealed interface Loadable {
    data object Idle : Loadable
    data object Loading : Loadable
    data class Ready<T>(val data: T) : Loadable
    data class Failed(val error: Throwable) : Loadable
}

sealed interface Refreshable {
    val isRefreshing: Boolean
}

sealed interface Paginated {
    val currentPage: Int
    val hasNextPage: Boolean
}

// A single type can implement ALL three traits
data class ProductListState(
    val products: List<Product>,
    override val isRefreshing: Boolean = false,
    override val currentPage: Int = 1,
    override val hasNextPage: Boolean = false,
) : Loadable.Ready<List<Product>>(products),
    Refreshable,
    Paginated

Modeling UI State with Multiple Dimensions

Consider a real e-commerce product listing screen. The state has at least four independent dimensions: loading status, filter state, sort order, and connectivity awareness. With sealed classes, you would need a flat hierarchy with names like `LoadingWithFiltersOffline` -- an unmaintainable mess. With sealed interfaces, each dimension is modeled independently, and the concrete states compose them freely. The compiler still enforces exhaustive matching on each dimension.
kotlin
sealed interface ContentState {
    data object Empty : ContentState
    data class Populated(val items: List<Product>) : ContentState
    data class Error(val message: String) : ContentState
}

sealed interface SyncState {
    data object Synced : SyncState
    data object Syncing : SyncState
    data class Conflict(val localVersion: Int, val remoteVersion: Int) : SyncState
}

sealed interface ConnectivityState {
    data object Online : ConnectivityState
    data object Offline : ConnectivityState
    data object Metered : ConnectivityState
}

// Compose the screen state from independent dimensions
data class ProductScreenState(
    val content: ContentState,
    val sync: SyncState,
    val connectivity: ConnectivityState,
    val selectedFilters: Set<Filter> = emptySet(),
    val sortOrder: SortOrder = SortOrder.RELEVANCE
)

// Exhaustive handling in the UI layer
@Composable
fun ProductScreen(state: ProductScreenState) {
    // Connectivity banner -- compiler ensures all states handled
    when (state.connectivity) {
        ConnectivityState.Online -> { /* no banner */ }
        ConnectivityState.Offline -> OfflineBanner()
        ConnectivityState.Metered -> MeteredWarningBanner()
    }

    // Content area -- compiler ensures all states handled
    when (state.content) {
        ContentState.Empty -> EmptyState()
        is ContentState.Populated -> ProductGrid(state.content.items)
        is ContentState.Error -> ErrorState(state.content.message)
    }
}

Compiler-Enforced Business Rules

Sealed interfaces shine when you need to encode business rules directly in the type system. Instead of runtime checks with `require()` or `check()`, you make illegal states unrepresentable. Consider a payment processing system where different payment methods require different validation data. With sealed interfaces, the compiler itself prevents you from trying to charge a bank transfer with a credit card's CVV.
kotlin
sealed interface PaymentMethod {
    val displayName: String
}

sealed interface CardPayment : PaymentMethod {
    val last4: String
    val expiryMonth: Int
    val expiryYear: Int
}

sealed interface BankPayment : PaymentMethod {
    val routingNumber: String
    val accountLast4: String
}

data class Visa(
    override val last4: String,
    override val expiryMonth: Int,
    override val expiryYear: Int,
    val cvv: String
) : CardPayment {
    override val displayName = "Visa ****$last4"
}

data class ACHTransfer(
    override val routingNumber: String,
    override val accountLast4: String,
    val accountType: AccountType
) : BankPayment {
    override val displayName = "Bank ****$accountLast4"
}

// Process function is type-safe:
// you CANNOT accidentally pass bank details to card processing
fun processCardPayment(card: CardPayment): PaymentResult {
    // card.last4, card.expiryMonth etc. are guaranteed to exist
    return gateway.chargeCard(card)
}

fun processBankPayment(bank: BankPayment): PaymentResult {
    // bank.routingNumber, bank.accountLast4 guaranteed to exist
    return gateway.debitAccount(bank)
}

Event Systems with Sealed Interface Hierarchies

Another powerful pattern is using sealed interfaces to build event systems with cross-cutting traits. In analytics-heavy apps, some events need tracking, some are undoable, some trigger side effects. Sealed interfaces let you tag events with multiple traits and handle them through independent processing pipelines. This pattern replaces brittle annotation-based approaches and eliminates the need for `is` checks scattered throughout your codebase.
kotlin
sealed interface UiEvent

sealed interface Trackable : UiEvent {
    val eventName: String
    val properties: Map<String, Any>
}

sealed interface Undoable : UiEvent {
    fun undo(): UiEvent
}

sealed interface RequiresAuth : UiEvent

// An event can be trackable AND undoable
data class AddToCart(
    val productId: String,
    val quantity: Int
) : Trackable, Undoable, RequiresAuth {
    override val eventName = "add_to_cart"
    override val properties = mapOf(
        "product_id" to productId,
        "quantity" to quantity
    )
    override fun undo() = RemoveFromCart(productId, quantity)
}

data class RemoveFromCart(
    val productId: String,
    val quantity: Int
) : Trackable, Undoable {
    override val eventName = "remove_from_cart"
    override val properties = mapOf(
        "product_id" to productId,
        "quantity" to quantity
    )
    override fun undo() = AddToCart(productId, quantity)
}

// Processing pipelines handle each trait independently
class EventProcessor(
    private val analytics: Analytics,
    private val undoManager: UndoManager,
    private val authGuard: AuthGuard
) {
    suspend fun process(event: UiEvent) {
        if (event is RequiresAuth) authGuard.ensureAuthenticated()
        if (event is Trackable) analytics.track(event.eventName, event.properties)
        if (event is Undoable) undoManager.push(event)
    }
}

Performance Considerations and Best Practices

Sealed interfaces have zero runtime overhead compared to regular interfaces. The exhaustiveness checking happens entirely at compile time. The JVM bytecode is identical to standard interface implementations. Best practices for production use: - **Keep hierarchies shallow**: Two levels of sealed interface nesting is the practical maximum. Deeper hierarchies become hard to reason about. - **Prefer data classes for leaf types**: They give you `copy()`, `equals()`, `hashCode()`, and `toString()` for free, which is essential for state management. - **Use `when` with explicit branches, not `else`**: The whole point of sealed types is exhaustive matching. An `else` branch defeats compiler safety when you add new subtypes. - **Define sealed interfaces in the domain layer**: They are domain concepts, not UI constructs. Your ViewModel should expose sealed interface states that the UI layer matches on. - **Document the intent, not the structure**: A comment explaining *why* a type hierarchy is structured a certain way is worth more than a comment explaining *what* each subtype is.
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Sealed interfaces remove the single-inheritance constraint of sealed classes while preserving exhaustive when matching.
  2. 2Model independent state dimensions (loading, connectivity, sync) as separate sealed interfaces and compose them in a data class.
  3. 3Encode business rules in the type system to make illegal states unrepresentable at compile time.
  4. 4Build event systems with cross-cutting traits like Trackable, Undoable, and RequiresAuth on the same event type.
  5. 5Sealed interfaces have zero runtime overhead -- exhaustiveness checking is purely compile-time.
  6. 6Always use explicit when branches instead of else to maintain compiler safety when adding new subtypes.
MOD · FAQ2 ENTRIESANSWERED

Frequently Asked

When should I use sealed interfaces over sealed classes?

Use sealed interfaces when types need multiple traits or when you need to combine hierarchies. Use sealed classes for simple, single-inheritance hierarchies.

Do sealed interfaces work with serialization?

Yes, with kotlinx.serialization. Use @Serializable on the interface and implementors. Use polymorphic serialization for runtime type preservation.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA