Monosoul's Dev Blog A blog to write down dev-related stuff I face
How to build a good API with Kotlin

How to build a good API with Kotlin

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.

No compilation failures

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:

Compilation failure when using an interface

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 in A and B, so that TypeSafeCondition<HostName, ServiceName> will not be a subtype of TypeSafeCondition<Any, Any>.
  • equals extension function (line 9) is only declared for TypeSafeCondition instances having the same type in A and B with an exception that A 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.

Compilation failure when using a simple DSL

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:

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 classes toString 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 and hashCode 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
falseCode 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
trueCode 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:

  1. 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.
  2. 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 by kotlinx.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 of ShmonitoringEventRequest), 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 of ShmonitoringEventRequest.
  • 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 property base.

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

Like it? Share it!

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.