Custom Modules

Teach Slumber to handle your own types by implementing SlumberModule. No annotation processors — just an interface.

How modules work

A SlumberConfig holds an ordered list of SlumberModules. When Slumber needs to serialize or deserialize a type, it asks each module in order. The first module to return a non-null handler wins.

interface SlumberModule {
    fun getAwaker(type: KType, attributes: TypedAttributes): Awaker?
    fun getSlumberer(type: KType, attributes: TypedAttributes): Slumberer?
}

Return null to pass — "I don't handle this type." Return an Awaker or Slumberer to claim it.

Example: Custom Money type

data class Money(val amount: Long, val currency: String)

object MoneyModule : SlumberModule {

    override fun getAwaker(type: KType, attributes: TypedAttributes): Awaker? {
        if (type.classifier != Money::class) return null

        return type.wrapIfNonNull(object : Awaker {
            override fun awake(data: Any?, context: Awaker.Context): Money? {
                val map = data as? Map<*, *> ?: return null
                val amount = (map["amount"] as? Number)?.toLong() ?: return null
                val currency = map["currency"] as? String ?: return null
                return Money(amount, currency)
            }
        })
    }

    override fun getSlumberer(type: KType, attributes: TypedAttributes): Slumberer? {
        if (type.classifier != Money::class) return null

        return type.wrapIfNonNull(object : Slumberer {
            override fun slumber(data: Any?, context: Slumberer.Context): Any? {
                val money = data as? Money ?: return null
                return mapOf("amount" to money.amount, "currency" to money.currency)
            }
        })
    }
}

Registering a custom module

// Prepend to give your module highest priority
val config = SlumberConfig.default.prependModules(MoneyModule)
val codec = config.codec()

// Now Money is handled by your module
codec.slumber(Money(1099, "EUR"))
// → { "amount": 1099, "currency": "EUR" }

codec.awake<Money>(mapOf("amount" to 1099, "currency" to "EUR"))
// → Money(amount=1099, currency="EUR")

wrapIfNonNull

Notice the type.wrapIfNonNull(awaker) call. This is a helper on SlumberModule that wraps your awaker in a NonNullAwaker when the type is non-nullable. This ensures that:

  • Money (non-nullable) — throws AwakerException if your awaker returns null
  • Money? (nullable) — returns null gracefully

Always use wrapIfNonNull unless you have a specific reason not to.

Module ordering

Modules are queried in order. Use prependModules when your module should override built-in behavior, and appendModules when it's a fallback:

// Override: your module takes priority over built-in handlers
val config = SlumberConfig.default.prependModules(MoneyModule)

// Fallback: your module only handles types the built-in modules don't
val config = SlumberConfig.default.appendModules(FallbackModule)

Passing context via attributes

Modules receive TypedAttributes — a typed key-value store. Use this to pass runtime context:

// Define a typed key
val DatabaseKey = TypedKey<Database>("database")

// Add it to the config
val config = SlumberConfig.default
    .prependModules(MyDbModule)
    .plusAttributes(TypedAttributes.of { add(DatabaseKey, myDatabase) })

// Access it in your module
object MyDbModule : SlumberModule {
    override fun getAwaker(type: KType, attributes: TypedAttributes): Awaker? {
        val db = attributes[DatabaseKey] // Access the database
        // ... use db to resolve references
    }
}

This is how the Vault and Karango integrations work — they pass Database and EntityCache references through attributes so their custom modules can resolve entity references during serialization.

The default module stack

SlumberConfig.default includes these modules in order:

  1. MpDateTimeModule — Ultra's multiplatform datetime types
  2. KotlinxTimeModule — kotlinx.datetime types
  3. JavaTimeModule — java.time types
  4. BuiltInModule — primitives, strings, collections, maps, data classes, enums, sealed classes, singletons

DateTime modules come first because they handle specific types that the BuiltInModule would otherwise catch as generic data classes.