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 at some of the approaches we take at Wolt to build a good API with Kotlin and make our lives easier.

Let’s imagine we have to build a service at Wolt 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
}

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,
)

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)
    }
}

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, 
   ""
)

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 = ""
)

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)
    }
}

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,
)

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("")
)

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>

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

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.

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)
}

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()
    }
}

Now if I try to instantiate this class win invalid host name, I’ll get an exception:

HostName("asd")

Well result in:

Exception in thread "main" java.lang.IllegalArgumentException: Host name [asd] is invalid.
Should have the following format: "DeathStar<number>"

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"))
        }
    }
}

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
    }
}

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), and we at Wolt deeply care about it.

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>)"
}

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" : ""
}

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')

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)

@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,
)

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)
}

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
}

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,
)

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)
    }
}

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()
}

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),
    )
)

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()"
    }
}

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()"
    }
}

@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
}

And serialized ServiceStatus.Down would look like this:

{
  "type" : "down"
}

Now, going back to what 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)

The result of running that code is:

true
false

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()"
    }
}
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())
}

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)

will result in:

true
true

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,
)

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
  }
}

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"
}

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,
)

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
)

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
)

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)
            }
        }
    }
}

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()),
    )
)

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"
}

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
  • 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 truly fool proof. 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


Are you interested in joining our Kotlin community? We’re hiring — check out our open roles!

How to reduce JVM docker image size

If your JVM based app docker images take more than 400MB, with this easy guide you can dramatically reduce the image size by at least 2/3.

Scala at Wolt: Our Scala Organization (Part I)

Scala is one the core technologies we use for backend services. In this blog we'll dive deeper into how we're building a Scala organization at Wolt.

Our hybrid & remote working set up for product teams at Wolt

Working in distributed, autonomous teams enables us to work effectively together and provide great results, even when remote or hybrid. That's why we believe in supporting hybrid and remote working for our product teams at Wolt. Find out what this means in practice.

Accessibility on Wolt.com

Wolt serves customers across 23 countries around the world. We make it our priority to design and engineer our products in a way that they are usable by anyone, anywhere. To ensure this, there are many things we take into consideration in our daily work, one of them being accessibility.

Coding remotely - Tips and truths from Mila

In this blog post, our backend engineer Mila sheds light on how it is to work remotely at Wolt.

Creating Responsive Layouts with WoltResponsiveLayoutGrid: A CoffeeMaker Demo App

In the previous post, we had an introduction to the Wolt Responsive Layout Grid library and explained the technical implementation details of its components. In this post, we will demonstrate the power of the library in creating dynamic and adaptive layouts for the CoffeeMaker demo application.

From Polling to WebSockets: Improving Order Tracking User Experience

Where is my order? Is the app stuck? Even when everything else in an application is polished, the user experience might be far from delightful if the information on the screen rarely updates. In this blog post you'll get insights on how we have improved order tracking user experience at Wolt.

Kristian's journey from junior engineer to competence lead at Wolt

Hello, who are you? 👋🏻 My name is Kristian: I’m a software engineer from a small beautiful place called the...

Niilo, VP of Engineering: “I was a Typescript and React guy who joined Wolt because of the people. Now I’m scaling our whole engineering team.”

Solid state-of-the-art technology. That’s what Wolt’s food delivery is built on. Our Apple-awarded app makes it incredibly easy to discover...