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) — throwsAwakerExceptionif your awaker returns nullMoney?(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:
- MpDateTimeModule — Ultra's multiplatform datetime types
- KotlinxTimeModule — kotlinx.datetime types
- JavaTimeModule — java.time types
- 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.