Designing a type-safe, sane API that prevents consumers from misusing it could be crucial for further implementation of that API. Frustrated consumers, necessity for extra validations, delayed feedback and convoluted, hard to maintain code are just a few things you might have to pay with for poor design decisions during early development stages. Thankfully, Kotlin provides a plenty of tools to design a great API. Let’s have a look at how to build a good API with Kotlin to make your life and life of your API consumers easier.
Let’s imagine we have to build a service to store some monitoring data. The service should provide an API to consume an event coming from the monitored system. The event itself should have information like: host name, service name, owning team name, status (Up, Down, Warning), uptime, number of processes and maybe some other stats. It should also provide an API to find consumed events by host name, service name or owning team name.
While moving forward, we’ll also consider an option of building a client library, so the article will be covering both a REST API case and a library API case.
Designing the model
Now, let’s try to design an initial model. According to the requirements mentioned above we might end up with something like this:
data class ShmonitoringEvent(
val timestamp: LocalDateTime,
val hostName: String,
val serviceName: String,
val owningTeamName: String,
val status: ServiceStatus,
val upTime: Long,
val numberOfProcesses: Int,
val warning: String,
)
enum class ServiceStatus {
UP, DOWN, WARNING
}
Code language: Kotlin (kotlin)
Simple and straightforward. To be able to find events, let’s also introduce a filter model:
data class ShmonitoringEventFilter(
val hostName: String? = null,
val serviceName: String? = null,
val owningTeamName: String? = null,
)
Code language: Kotlin (kotlin)
Here we make each field nullable, so that we can use any subset of those fields to find an event.
While both models are quite simple, there are a few things that could be improved here.
To save and find events we will have a simple service like this:
class ShmonitoringService {
private val eventsRepository = mutableListOf<ShmonitoringEvent>()
fun save(event: ShmonitoringEvent) = eventsRepository.add(event)
fun find(filter: ShmonitoringEventFilter) = eventsRepository.filter { event ->
(filter.hostName?.let(event.hostName::equals) ?: true) &&
(filter.serviceName?.let(event.serviceName::equals) ?: true) &&
(filter.owningTeamName?.let(event.serviceName::equals) ?: true)
}
}
Code language: Kotlin (kotlin)
Improving type safety
The first thing that can definitely be improved here is the same-typed fields. Imagine instantiating such a model:
ShmonitoringEvent(LocalDateTime.now(), "Death Star", "Laser beam", "Imperial troops", ServiceStatus.UP, 1000, 2, "")
Code language: Kotlin (kotlin)
Where “Death Star” is the host name, “Laser beam” is the service name and “Imperial troops” is the team name. A bunch of strings like this could be easy to confuse. What can we do about it?
One approach would be to add names to call arguments, like this:
ShmonitoringEvent(
timestamp = LocalDateTime.now(),
hostName = "DeathStar",
serviceName = "Laser-beam",
owningTeamName = "Imperial troops",
status = ServiceStatus.UP,
upTime = 1000,
numberOfProcesses = 2,
warning = ""
)
Code language: Kotlin (kotlin)
It definitely makes the call a bit more readable and somewhat safer (if the one writing it is careful enough), but what about other places?
Let’s take a close look at the ShmonitoringService
, it has a bug in it. Did you spot it?
class ShmonitoringService {
private val eventsRepository = mutableListOf<ShmonitoringEvent>()
fun save(event: ShmonitoringEvent) = eventsRepository.add(event)
fun find(filter: ShmonitoringEventFilter) = eventsRepository.filter { event ->
(filter.hostName?.let(event.hostName::equals) ?: true) &&
(filter.serviceName?.let(event.serviceName::equals) ?: true) &&
(filter.owningTeamName?.let(event.serviceName::equals) ?: true)
}
}
Code language: Kotlin (kotlin)
On the line number 8 I did a typo and accidentally compared owning team name to service name. So now whenever someone will try to fetch a service by owning team name, they’ fail to do so. What a shame!
Luckily, there are things we can do about it!
Value classes
Value classes (or inline classes) are a Kotlin feature that’s been there for a while now, initially available as an experimental feature of Kotlin 1.2.x, when using Kotlin with JVM, value classes rely on project valhalla. You can think of value classes as of wrapper classes for primitives, somewhat similar to what Long
is to long
in JVM. As a result value classes typically have smaller memory footprint than regular classes, so you can use them without worrying of performance impact. There are a few caveats though that I’d mention below.
For now, let’s see how we can improve our models:
import java.time.Duration
@JvmInline
value class HostName(val value: String)
@JvmInline
value class ServiceName(val value: String)
@JvmInline
value class TeamName(val value: String)
@JvmInline
value class WarningMessage(val value: String)
@JvmInline
value class NumberOfProcesses(val value: Int)
data class ShmonitoringEvent(
val timestamp: LocalDateTime,
val hostName: HostName,
val serviceName: ServiceName,
val owningTeamName: TeamName,
val status: ServiceStatus,
val upTime: Duration,
val numberOfProcesses: NumberOfProcesses,
val warning: WarningMessage,
)
data class ShmonitoringEventFilter(
val hostName: HostName? = null,
val serviceName: ServiceName? = null,
val owningTeamName: TeamName? = null,
)
Code language: Kotlin (kotlin)
Now we have a separate type for every property and you can not easily assign a host name to service name or team name. Notice we used a java.time.Duration
here for upTime
property. This class is a perfect fit for our use case, since uptime represents a duration of how long the service has been up. We also have only 1 duration property here, so it doesn’t make sense to introduce our own wrapper for it.
There's also kotlin.time.Duration available in Kotlin, but using it has a few caveats mentioned below.
Instantiation of that model can look like this:
ShmonitoringEvent(
LocalDateTime.now(),
HostName("DeathStar"),
ServiceName("Laser-beam"),
TeamName("Imperial troops"),
ServiceStatus.UP,
1000.milliseconds,
NumberOfProcesses(2),
WarningMessage("")
)
Code language: Kotlin (kotlin)
Notice how even without named arguments it’s still very clear what kind of values we have there. But this is just an example, in my opinion even with value classes it is always good to have argument names visible.
Now, let’s take a look at the ShmonitoringService
again.
Wait, what? No errors? Damn! Default implementation of equals
method takes Any?
as an argument, so unfortunately, the service would still compile and the error might go unnoticed. What can we do about it? Can we make it type safe?
Well, there are a few thing we can do here.
Add an interface
One thing we can do is add an interface with type safe equals method, like this:
interface TypeSafeEqualsAware<T> {
fun typeSafeEquals(other: T) = this == other
}
@JvmInline
value class HostName(val value: String) : TypeSafeEqualsAware<HostName>
@JvmInline
value class ServiceName(val value: String) : TypeSafeEqualsAware<ServiceName>
@JvmInline
value class TeamName(val value: String) : TypeSafeEqualsAware<TeamName>
Code language: Kotlin (kotlin)
And then use this method in the service implementation:
Now there’s an error there because of type mismatch and the service wouldn’t compile.
But that seems like quite a hassle to make the classes we might have in the filter to implement that interface. Also, what if we’re gonna have a property that doesn’t have a wrapper class, like Duration
? We can’t change it to extend the interface, but we can create a wrapper for it. But it does seem like an overkill to do so.
Enforce type safety with a simple DSL
Another thing we can do is introduce a very primitive DSL to enforce a strict type check during compile time. In this case we wouldn’t need to change the model, but only the service instead. Here’s what it will look like:
fun find(filter: ShmonitoringEventFilter) = eventsRepository.filter { event ->
filter.hostName.typeSafeCondition(event.hostName).equals() &&
filter.serviceName.typeSafeCondition(event.serviceName).equals() &&
filter.owningTeamName.typeSafeCondition(event.serviceName).equals()
}
private class TypeSafeCondition<A, B>(val left: A, val right: B)
private fun <A, B> A.typeSafeCondition(other: B) = TypeSafeCondition(this, other)
private fun <T> TypeSafeCondition<out T?, T>.equals() = left?.let { it == right } ?: true
Code language: Kotlin (kotlin)
Here’s what happens here:
- Creating an instance of
TypeSafeCondition
(class on line 7) makes sure it is invariant inA
andB
, so thatTypeSafeCondition<HostName, ServiceName>
will not be a subtype ofTypeSafeCondition<Any, Any>
. equals
extension function (line 9) is only declared forTypeSafeCondition
instances having the same type inA
andB
with an exception thatA
could be nullable.
You can read more on generics and variance in Kotlin here.
The result of those changes is that equals
extension function can not be called on the line 4 and will cause a compile time error.
This gives as a very quick feedback loop, so we learn about an error even before we can run the code.
And of course using value classes can help in other cases as well through making your method signatures more strict. For example, we can have a service to notify a team of a warning in their service like this:
interface NotificationService {
fun notifyTeam(team: TeamName, warning: Warning)
}
Code language: Kotlin (kotlin)
Validation
Other thing that using value classes can give you is validation. Let’s say we have a very specific host name format that we want to enforce, let’s say all host names should start with “DeathStar” and then be followed by a number. To enforce this rule we can change HostName
class like this:
@JvmInline
value class HostName(val value: String) {
init {
require(HOSTNAME_REGEX.matches(value)) {
"The host name is invalid. Should have the following format: \"DeathStar<number>\""
}
}
private companion object {
val HOSTNAME_REGEX = "^DeathStar\\d+$".toRegex()
}
}
Code language: Kotlin (kotlin)
Now if I try to instantiate this class win invalid host name, I’ll get an exception:
HostName("asd")
Code language: Kotlin (kotlin)
Will result in:
Exception in thread "main" java.lang.IllegalArgumentException: Host name [asd] is invalid. Should have the following format: "DeathStar<number>"
Code language: Java (java)
Sometimes you might not want to get an exception when a class is instantiated, but rather get a validation result. In this case you can do something like this:
@JvmInline
value class HostName(val value: String) {
init {
validate(value)?.throwIfInvalid()
}
companion object {
private val HOSTNAME_REGEX = "^DeathStar\\d+$".toRegex()
private fun validate(value: String) = if (!HOSTNAME_REGEX.matches(value)) {
Validated.Invalid<HostName>(
"Host name [$value] is invalid. Should have the following format: \"DeathStar<number>\""
)
} else null
fun validated(value: String): Validated<HostName> = validate(value) ?: Validated.Valid(HostName(value))
}
}
sealed class Validated<T> {
abstract fun throwIfInvalid()
class Valid<T>(val value: T) : Validated<T>() {
override fun throwIfInvalid() = Unit
}
class Invalid<T>(val errors: List<String>) : Validated<T>() {
constructor(vararg errors: String) : this(errors.toList())
override fun throwIfInvalid() {
throw IllegalArgumentException(errors.joinToString("\n"))
}
}
}
Code language: Kotlin (kotlin)
This way whenever you want to get a validation result you can call HostName#validated
method, while it would still be impossible to create an invalid instance of that class. Instantiation will look somewhat like this:
when(val hostName = HostName.validated("asd")) {
is Validated.Invalid -> {
// handle invalid
}
is Validated.Valid -> {
// handle valid
}
}
Code language: Kotlin (kotlin)
You might also want to check validation with arrow-kt.
Data protection
Another advantage value classes bring is making sure you don’t accidentally leak PII data anywhere. Let’s say you process things like IBANs or maybe VAT IDs in your service, or even customer names. All of that is a PII data that should be processed very carefully (unless you want to get fined by the authorities).
Here’s how you can design your VatID
value class in this case:
@JvmInline
value class VatID(val value: Int) {
override fun toString() = "VatID(value=<hidden>)"
}
Code language: Kotlin (kotlin)
This way whenever you log VatID
itself, or any other model having an instance of this class as a property, you can rest assured it won’t get leaked accidentally.
(De)serialization with value classes
Previously I mentioned there might be caveats when using value classes. One of such caveats is (de)serialization of value classes. If you use Jackson you might notice that deserializing a payload like this into the model we declared earlier:
{
"timestamp" : "2023-05-28T15:35:09.419912265",
"hostName" : "DeathStar1",
"serviceName" : "Laser-beam",
"owningTeamName" : "Imperial troops",
"status" : "UP",
"upTime" : "PT1S",
"numberOfProcesses" : 2,
"warning" : ""
}
Code language: JSON / JSON with Comments (json)
will cause an exception:
Exception in thread "main" com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of `HostName` (although at least one Creator exists): no String-argument constructor/factory method to deserialize from String value ('DeathStar1')
Code language: Java (java)
while serializing this model will work as you’d expect. More on why it happens can be found in this GitHub thread.
Here are a few things you can do about it:
- add a custom builder for the parent model like suggested here;
- add a custom deserializer for the parent model (see this nice guide on custom deserializers);
- switch to kotlinx.serialization library;
- switch to using a data class.
While all options are pretty much viable, the first 2 will add quite some overhead to your code.
The third option will require refactoring in case you already use Jackson in your app, but might be a good choice when starting a new service.
The fourth option is what I typically do if the service already uses Jackson. While using data classes will have a slight memory overhead in comparison to value classes, you will still have all other benefits they give.
Here’s what your data class will have to look like to work flawlessly with Jackson:
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonValue
data class ServiceName
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
constructor(@JsonValue val value: String)
Code language: Kotlin (kotlin)
@JsonValue
annotation on the line 6 will make Jackson use the value
as actual value when serializing an instance of this class, so that you wouldn’t have a nested object there.
@JsonCreator
annotation will tell Jackson to use the annotated constructor when deserializing a value into an instance of ServiceName
.
When building a library, you might also want to annotate your data classes with @JvmRecord
in case if you expect the consumers to use plain Java. It doesn't bring any performance impact (at least not at the time of writing this), but might make it more convenient for the consumers in the future.
Here's a great article about record classes in Java.
kotlin.time.Duration
class I mentioned before is also a value class, so similar restrictions apply to it when using Jackson.
Another thing regarding serialization with Jackson I typically prefer to do, is make sure time is serialized as a string. There are 2 ways to achieve that, one option is to add @JsonFormat
annotation like this:
import com.fasterxml.jackson.annotation.JsonFormat
import com.fasterxml.jackson.annotation.JsonFormat.Shape.STRING
data class ShmonitoringEvent(
@field:JsonFormat(shape = STRING)
val timestamp: LocalDateTime,
val hostName: HostName,
val serviceName: ServiceName,
val owningTeamName: TeamName,
val status: ServiceStatus,
@field:JsonFormat(shape = STRING)
val upTime: Duration,
val numberOfProcesses: NumberOfProcesses,
val warning: WarningMessage,
)
Code language: Kotlin (kotlin)
Another option is to configure such behavior globally, like this:
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jsonMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
val objectMapper = jsonMapper {
addModule(kotlinModule())
addModule(JavaTimeModule())
disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS)
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
}
Code language: Kotlin (kotlin)
Improving data sanity
Let’s have another look at the event model we have after introducing value classes before:
data class ShmonitoringEvent(
val timestamp: LocalDateTime,
val hostName: HostName,
val serviceName: ServiceName,
val owningTeamName: TeamName,
val status: ServiceStatus,
val upTime: Duration,
val numberOfProcesses: NumberOfProcesses,
val warning: WarningMessage,
)
enum class ServiceStatus {
UP, DOWN, WARNING
}
Code language: Kotlin (kotlin)
All properties there are required at the moment to create an instance. But does it really make sense? Looking at the status model we have, the service might be in 3 different states: UP
, DOWN
and WARNING
. Having upTime
as required field makes sense when the service is up (or maybe in WARNING
state), but it doesn’t make sense to have it when the service is down. Same goes to the number of processes. At the same time it should only have a warning
message when it is in warning state.
What can we do about it?
One option could be to always pass upTime
and numberOfProcesses
set to 0 and warning
set to empty line when the service is in DOWN
state and non-empty/non-zero values when service is in other states.
Another option could be to make those fields nullable, so that they are only passed when it makes sense, the model would look somewhat like this then:
data class ShmonitoringEvent(
val timestamp: LocalDateTime,
val hostName: HostName,
val serviceName: ServiceName,
val owningTeamName: TeamName,
val status: ServiceStatus,
val upTime: Duration? = null,
val numberOfProcesses: NumberOfProcesses? = null,
val warning: WarningMessage? = null,
)
Code language: Kotlin (kotlin)
But what about data sanity? How can we make sure nobody will try to pass warning
with UP
status and upTime
with DOWN
status? Should we add validation for that?
Of course we can add an init
function like that:
init {
when (status) {
ServiceStatus.UP -> require(upTime != null && numberOfProcesses != null && warning == null)
ServiceStatus.DOWN -> require(upTime == null && numberOfProcesses == null && warning == null)
ServiceStatus.WARNING -> require(upTime == null && numberOfProcesses == null && warning != null)
}
}
Code language: Kotlin (kotlin)
But that would mean whoever consumes such an API would only know they did something wrong when they run their code. Not to mention this is something we’d have to maintain and cover with test cases. Why do we even put the cognitive load of thinking of how to instantiate an event right onto the API consumers? Maybe instead we can design it in a way that would make it impossible to create an invalid instance?
Sealed classes to the rescue
Let’s try to come up with a better model:
data class ShmonitoringEvent(
val timestamp: LocalDateTime,
val hostName: HostName,
val serviceName: ServiceName,
val owningTeamName: TeamName,
val status: ServiceStatus,
)
sealed class ServiceStatus {
data class Up(
val upTime: Duration,
val numberOfProcesses: NumberOfProcesses,
) : ServiceStatus()
data class Warning(val message: WarningMessage) : ServiceStatus()
object Down : ServiceStatus()
}
Code language: Kotlin (kotlin)
There are a few things that happened here:
- First of all, we turned
ServiceStatus
that was previously an enum into a sealed class. Sealed classes offer a way to declare a limited class hierarchy in Kotlin. - Next we extracted the properties specific to the specific status type into the respective status subclasses.
With this change it is impossible to create an instance of ShmonitoringEvent
with invalid set of fields, meaning we don’t have to add validations for that and the API consumers don’t have to waste time trying to figure out how to properly instantiate the class.
A note on sealed classes vs sealed interfaces. While you and the consumers of your API use Kotlin it doesn't matter much, but if you build a library that might potentially be used from Java, you should keep in mind that sealed classes can't be extended in Java as well as in Kotlin. But sealed interfaces can easily be extended in Java. So if you want your API to be more restrictive, consider using sealed classes whenever possible.
Instantiation of that model will look like this now:
ShmonitoringEvent(
timestamp = LocalDateTime.now(),
hostName = HostName("DeathStar1"),
serviceName = ServiceName("Laser-beam"),
owningTeamName = TeamName("Imperial troops"),
status = ServiceStatus.Up(
upTime = Duration.ofMillis(1000),
numberOfProcesses = NumberOfProcesses(2),
)
)
Code language: Kotlin (kotlin)
Looks good to me!
By the way, here’s how you can use sealed classes with Spring Boot and Mongo DB.
Sealed classes Pro tip
When your sealed class has a mixture of data class and object children, consider enforcing the inheritors to explicitly implement equals
, hashCode
and toString
methods. It is especially important if you have object
inheritors. You can do it like this:
sealed class ServiceStatus {
abstract override fun equals(other: Any?): Boolean
abstract override fun hashCode(): Int
abstract override fun toString(): String
data class Up(
val upTime: Duration,
val numberOfProcesses: NumberOfProcesses,
) : ServiceStatus()
data class Warning(val message: WarningMessage) : ServiceStatus()
object Down : ServiceStatus() {
override fun equals(other: Any?) = javaClass == other?.javaClass
override fun hashCode(): Int = javaClass.hashCode()
override fun toString() = "Down()"
}
}
Code language: Kotlin (kotlin)
Data classes implement those methods out of the box, but for objects you’d have to provide the implementation yourself. There are a few reasons to do that:
- Obects don’t override default
toString
implementation, so while for your data classestoString
result will look like this:Up(upTime=PT1S, numberOfProcesses=NumberOfProcesses(value=2))
for objects it will look like this:ServiceStatus$Down@6acbcfc0
. - There are cases when you might get another instance of an object (more on that below). Since objects don’t override default
equals
andhashCode
implementations either, that can cause trouble as well.
(De)serialization of sealed classes with Jackson
Now let’s assume again we’re builing a REST API. In this case we’ll need to do a few changes to our model so that it can be (de)serialized properly.
We need to add a way to distinguish the subclass of ServiceStatus
we or our API consumer receive/send. We can do that by adding a few annotations to the models:
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.annotation.JsonTypeInfo.As
import com.fasterxml.jackson.annotation.JsonTypeInfo.Id
import com.fasterxml.jackson.annotation.JsonTypeName
@JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = "type")
sealed class ServiceStatus {
abstract override fun equals(other: Any?): Boolean
abstract override fun hashCode(): Int
abstract override fun toString(): String
@JsonTypeName("up")
data class Up(
val upTime: Duration,
val numberOfProcesses: NumberOfProcesses,
) : ServiceStatus()
@JsonTypeName("warning")
data class Warning(val message: WarningMessage) : ServiceStatus()
@JsonTypeName("down")
object Down : ServiceStatus() {
override fun equals(other: Any?) = javaClass == other?.javaClass
override fun hashCode(): Int = javaClass.hashCode()
override fun toString() = "Down()"
}
}
Code language: Kotlin (kotlin)
@JsonTypeInfo
(line 6) specifies how the type information will be included into the serialized model, here we include logical type name as a property named type
.
@JsonTypeName
(lines 12, 18, 21) specifies the logical type name for each subclass. It’s a good practice to keep that name detached from class FQN, as this way it’s easier to keep your changes to the model backwards compatible.
Cool thing about using@JsonTypeInfo
with Kotlin, is that unlike with Java, you don't have to explicitly provide a list of all inheritors with@JsonSubTypes
annotation. Since sealed classes/interfaces are already enumerated, Jackson's Kotlin module does that automagically.
Here’s what an instance of ServiceStatus.Up
would look like serialized:
{
"type" : "up",
"upTime" : "PT1S",
"numberOfProcesses" : 2
}
Code language: JSON / JSON with Comments (json)
And serialized ServiceStatus.Down
would look like this:
{
"type" : "down"
}
Code language: JSON / JSON with Comments (json)
Now, going back to the sealed classes pro tip I mentioned before, let’s try to deserialize an instance of ServiceStatus.Down
and compare it to the object in our code:
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
val objectMapper = jacksonObjectMapper()
// language=JSON
val serializedDown = """
{
"type" : "down"
}
""".trimIndent()
val deserializedStatus = objectMapper.readValue<ServiceStatus>(serializedDown)
println(deserializedStatus is ServiceStatus.Down)
println(deserializedStatus === ServiceStatus.Down)
Code language: Kotlin (kotlin)
The result of running that code is:
true
false
Code language: Shell Session (shell)
So deserializedStatus
is an instance of ServiceStatus.Down
, but not the same instance as object ServiceStatus.Down
. This is because Jackson creates a new instance of ServiceStatus.Down
on deserialization using reflection. Hence, to protect from such cases, always make sure your objects implement equals
and hashCode
when you’re going to deserialize them with Jackson.
(De)serialization of sealed classes with kotlinx.serialization
To make it work with kotlinx.serialization we’ll also have to add a few annotations and a deserializer for java.time.Duration
(or use kotlin.time.Duration
). Here’s how it’d look like:
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
sealed class ServiceStatus {
abstract override fun toString(): String
@Serializable
@SerialName("up")
data class Up(
@Serializable(DurationSerializer::class)
val upTime: Duration,
val numberOfProcesses: NumberOfProcesses,
) : ServiceStatus()
@Serializable
@SerialName("warning")
data class Warning(val message: WarningMessage) : ServiceStatus()
@Serializable
@SerialName("down")
object Down : ServiceStatus() {
override fun toString() = "Down()"
}
}
Code language: Kotlin (kotlin)
DurationSerializer
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind.STRING
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object DurationSerializer : KSerializer<Duration> {
override val descriptor = PrimitiveSerialDescriptor("java.time.Duration", STRING)
override fun deserialize(decoder: Decoder) = Duration.parse(decoder.decodeString())
override fun serialize(encoder: Encoder, value: Duration) = encoder.encodeString(value.toString())
}
Code language: Kotlin (kotlin)
Notice how in this case we don’t enforce ServiceStatus
inheritors to implement equals
and hashCode
. This is because kotlinx.serialization
can deserialize objects properly and will not create a new instance of ServiceStatus.Down
:
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
// language=JSON
val serializedDown = """
{
"type" : "down"
}
""".trimIndent()
val deserializedStatus = Json.decodeFromString<ServiceStatus>(serializedDown)
println(deserializedStatus is ServiceStatus.Down)
println(deserializedStatus === ServiceStatus.Down)
Code language: Kotlin (kotlin)
will result in:
true
true
Code language: Shell Session (shell)
Generify the model
After switching to sealed classes, next step could be making the model generic. You might not want to always check the status
type. For example when you have just created an instance of the model you know exactly the status type it has, so if you need to process it, you shouldn’t have to check the type. Here’s how the model would look like:
data class ShmonitoringEvent<out T : ServiceStatus>(
val timestamp: LocalDateTime,
val hostName: HostName,
val serviceName: ServiceName,
val owningTeamName: TeamName,
val status: T,
)
Code language: Kotlin (kotlin)
Don’t repeat yourself
Let’s say we we got a new requirement for our service. Now, whenever we get an event saved before, we should also provide the timestamp of when it was received on our end and event ID that was assigned to it.
Serialization of composed models
If we’re building a REST API, here’s how the JSON models should look like:
Request:
{
"timestamp": "2023-06-01T14:50:57.281480213",
"hostName": "DeathStar1",
"serviceName": "Laser-beam",
"owningTeamName": "Imperial troops",
"status": {
"type": "up",
"upTime": "PT1S",
"numberOfProcesses": 2
}
}
Code language: JSON / JSON with Comments (json)
Response:
{
"timestamp" : "2023-06-01T14:50:57.281480213",
"hostName" : "DeathStar1",
"serviceName" : "Laser-beam",
"owningTeamName" : "Imperial troops",
"status" : {
"type" : "up",
"upTime" : "PT1S",
"numberOfProcesses" : 2
},
"receivedTimestamp": "2023-06-01T14:50:58.181480",
"id": "32f4de91-4a52-4cff-828f-01f22cfb48ae"
}
Code language: JSON / JSON with Comments (json)
One of the options to approach that might be to have a single model both for request and response with nullable fields like this:
data class ShmonitoringEvent<out T : ServiceStatus>(
val timestamp: LocalDateTime,
val hostName: HostName,
val serviceName: ServiceName,
val owningTeamName: TeamName,
val status: T,
val receivedTimestamp: LocalDateTime? = null,
val id: EventId? = null,
)
Code language: Kotlin (kotlin)
Since request and response models will be the same, that would mean we will put the responsibility for instantiating the model with the right fields onto the API consumer. I.e. the consumer will have to know they should always send receivedTimestamp
and id
set to null (or don’t send them at all), yet technically they can send some values in those fields. So we will either have to ignore those values on our end, or add some validation to throw an exception if those values present in the request. But that would be a poor design choice. We don’t want the improvements we did before be for nothing!
So instead we will apply CQRS pattern here. We will have a separate model for request and separate model for response.
This solution can also be approached in different ways:
- Have 2 independent models, where both of them will have pretty much the same set of fields. This is a simple solution that might work for you, but maintaing those models and keeping them in sync can become quite a pain.
- Since the set of fields in the response has all the fields of the request, we can try to reuse the request model here. I.e. we can use composition instead. We can create a composed model having the base model and all extra fields, and then just flatten it.
We will have 2 models looking like this:
data class ShmonitoringEventRequest<out T : ServiceStatus>(
val timestamp: LocalDateTime,
val hostName: HostName,
val serviceName: ServiceName,
val owningTeamName: TeamName,
val status: T,
)
data class ShmonitoringEventResponse<out T : ServiceStatus>(
val base: ShmonitoringEventRequest<T>,
val receivedTimestamp: LocalDateTime,
val id: EventId
)
Code language: Kotlin (kotlin)
Now let’s see how those models could be serialized.such a composed model could be serialized.
With Jackson
Serializing response model with Jackson will be an easy task thanks to @JsonUnwrapped
annotation available there. This annotation will “flatten” the model, so that fields of nested ShmonitoringEventRequest
model will be on the same level with receivedTimestamp
and id
:
import com.fasterxml.jackson.annotation.JsonUnwrapped
data class ShmonitoringEventResponse<out T : ServiceStatus>(
@field:JsonUnwrapped
val base: ShmonitoringEventRequest<T>,
val receivedTimestamp: LocalDateTime,
val id: EventId
)
Code language: Kotlin (kotlin)
That’s pretty much it. That’s all you have to do with Jackson. kotlinx.serialiation
is a different story.
With kotlinx.serialization
kotlinx.serialization
library doesn’t support flattening nested models out of the box (there’s an issue for that). Unfrotunately, I don’t know of any nice ways to solve that apart from using a custom deserializer. Here we can take advantage of JsonTransformingSerializer
, so that we don’t have to implement the entire serialization ourselves. Here’s what it might look like:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonTransformingSerializer
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
class UnwrappingJsonSerializer<T : ServiceStatus>(
statusSerializer: KSerializer<T>
) : JsonTransformingSerializer<ShmonitoringEventResponse<T>>(
ShmonitoringEventResponse.serializer(statusSerializer)
) {
override fun transformSerialize(element: JsonElement) = buildJsonObject {
element.jsonObject.forEach { (propertyName, propertyValue) ->
if (propertyName == ShmonitoringEventResponse<*>::base.name) {
propertyValue.jsonObject.forEach(::put)
} else {
put(propertyName, propertyValue)
}
}
}
}
Code language: Kotlin (kotlin)
What happens here is:
- Line 10: we reuse
ShmonitoringEventResponse
serializer generated bykotlinx.serialization
plugin; - Line 12: we start building a new JSON object;
- Line 13: we iterate over JSON object’s properties;
- Line 14: we check if the property name is equal to
ShmonitoringEventResponse.base
(notice we get property name here using a reference instead of using a string literal, this is convenient when you do refactoring and rename fields); - knowing the
base
field contains an object (instance ofShmonitoringEventRequest
), on the line 15 we iterate over that object’s properties and add them to the root of the JSON object we’re building; - Line 17: we add all other properties (with name different from
base
) to the root of the JSON object we’re building.
So we basically extract all properties of base
object one level up.
Since we reuse serializer generated with kotlinx.serialization
plugin for that model, we can’t put @Serializable
annotation with the new serializer onto the model as that would cause a stack overflow, so instead we have to provide the serializer explicitly when calling the mapper. Here’s how:
import kotlinx.serialization.json.Json
Json {
prettyPrint = true
}.encodeToString(
serializer = UnwrappingJsonSerializer(ServiceStatus.serializer()),
value = ShmonitoringEventResponse(
base = ShmonitoringEventRequest(
LocalDateTime.now(),
HostName("DeathStar1"),
ServiceName("Laser-beam"),
TeamName("Imperial troops"),
ServiceStatus.Up(
upTime = Duration.ofMillis(1000),
numberOfProcesses = NumberOfProcesses(2),
)
),
receivedTimestamp = LocalDateTime.now(),
id = EventId(UUID.randomUUID()),
)
)
Code language: Kotlin (kotlin)
The output will look like this:
{
"timestamp": "2023-06-08T18:41:14.300051712",
"hostName": "DeathStar1",
"serviceName": "Laser-beam",
"owningTeamName": "Imperial troops",
"status": {
"type": "up",
"upTime": "PT1S",
"numberOfProcesses": 2
},
"receivedTimestamp": "2023-06-08T18:41:14.300083678",
"id": "714b1b48-c6a2-4d6e-ae50-45113960250a"
}
Code language: JSON / JSON with Comments (json)
Another option is to write a full serializer yourself, like in this guide. In this case you will be able to specify that serializer in the @Serializable
annotation, but obviously you’d also have to update it each time you change the model.
In my opinion in this use case Jackson is far more convenient.
Flattening composed models in code
Okay, we coevered how to flatten a composed model when we serialize it. But what if we want to do a similar thing in the code? There’s a way to do that as well! We can take advantage of delegation in Kotlin.
Here’s what it might look like:
interface ShmonitoringEventBase<out T : ServiceStatus> {
val timestamp: LocalDateTime
val hostName: HostName
val serviceName: ServiceName
val owningTeamName: TeamName
val status: T
}
data class ShmonitoringEventRequest<out T : ServiceStatus>(
override val timestamp: LocalDateTime,
override val hostName: HostName,
override val serviceName: ServiceName,
override val owningTeamName: TeamName,
override val status: T,
) : ShmonitoringEventBase<T>
data class ShmonitoringEventResponse<out T : ServiceStatus>(
private val base: ShmonitoringEventRequest<T>,
val receivedTimestamp: LocalDateTime,
val id: EventId
) : ShmonitoringEventBase<T> by base
Code language: Kotlin (kotlin)
- Line 1: we declare an interface called
ShmonitoringEventBase
that has all the fields ofShmonitoringEventRequest
. - Line 15: we make
ShmonitoringEventRequest
implement this interface. - Line 18: we mark property
base
as private, hiding it from the API consumers. - Line 21: we delegate implementation of
ShmonitoringEventBase
interface to the propertybase
.
Now for any consumer of ShmonitoringEventResponse
the model will look like it is flat. Moreover, a model like this would be serialized by Jackson into a flat JSON object as well! With kotlinx.serialization
you’d still have to use the solution mentioned above.
Summary
In general a good API should be as restrictive as possible to be a truly fool proof API. And that applies not only to the public API, but also to internal services and components you design. Descriptive class names and properties, use case specific models instead of over-generic ones, providing restrictive DSLs (check this guide on how to wrtie one) where possible, all that is an investment into your free time. The time you can spend working on great new features, instead of writing validations for poorly designed models, or firefighting after a service went down because of some invalid data.
Of course there could be exclusions to some of the cases mentioned here. Sometimes it could be better to have a bit of dupliation in your models. Sometimes having nullable fields makes total sense. It’s always a good thing to apply common sense and not just whatever a person on the internet wrote. Just don’t rush to implement a solution, before you give it a thought or two. Time spent designing an API is a time well spent.
The final code is available here: https://github.com/monosoul/shmonitoring/tree/main/good-api-with-kotlin