Engineering

Andrei Nevedomskii

Jun 19, 2023

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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 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:

1 2 3 4 5 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:

1 2 3 4 5 6 7 8 9 10 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:

1 2 3 4 5 6 7 8 9 10 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:

1 2 3 4 5 6 7 8 9 10 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?

1 2 3 4 5 6 7 8 9 10 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 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.

Side note: There's also kotlin.time.Duration available in Kotlin, but using it has a few caveats mentioned below (see (De)serialization with value classes).

Instantiation of that model can look like this:

1 2 3 4 5 6 7 8 9 10 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.

No compilation failure

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:

1 2 3 4 5 6 7 8 9 10 11 12 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:

Compilation failure with 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:

1 2 3 4 5 6 7 8 9 10 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.

Compilation failure with 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:

1 2 3 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:

1 2 3 4 5 6 7 8 9 10 11 12 @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:

1 HostName("asd")

Will result in:

1 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @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:

1 2 3 4 5 6 7 8 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:

1 2 3 4 @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 with value classes we declared earlier:

1 2 3 4 5 6 7 8 9 10 { "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:

1 2 3 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:

1 2 3 4 5 6 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.

Side note: 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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:

1 2 3 4 5 6 7 8 9 10 11 12 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 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:

1 2 3 4 5 6 7 8 9 10 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:

1 2 3 4 5 6 7 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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 side 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:

1 2 3 4 5 6 7 8 9 10 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 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.

Side note: 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:

1 2 3 4 5 { "type" : "up", "upTime" : "PT1S", "numberOfProcesses" : 2 }

And serialized ServiceStatus.Down would look like this:

1 2 3 { "type" : "down" }

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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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:

1 2 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 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()" } }

DurationSerializer

1 2 3 4 5 6 7 8 9 10 11 12 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 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:

1 2 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:

1 2 3 4 5 6 7 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:

1 2 3 4 5 6 7 8 9 10 11 { "timestamp": "2023-06-01T14:50:57.281480213", "hostName": "DeathStar1", "serviceName": "Laser-beam", "owningTeamName": "Imperial troops", "status": { "type": "up", "upTime": "PT1S", "numberOfProcesses": 2 } }

Response:

1 2 3 4 5 6 7 8 9 10 11 12 13 { "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:

1 2 3 4 5 6 7 8 9 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 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:

1 2 3 4 5 6 7 8 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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:

1 2 3 4 5 6 7 8 9 10 11 12 13 { "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:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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 custom deserializer 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


Interested in joining our team? Make sure to check out our open roles! 🚀

Andrei Nevedomskii

Andrei Nevedomskii

Software Engineer

Read more