Kotlin London

March 2024

Building Kotlin-first Libraries

Kotlin London - 7th March 2024

Jamie Sanson

Jamie Sanson

Staff Software Engineer @ M&S

Agenda

  1. Vintage Kotlin
  2. “Modern” Kotlin
  3. Designing a library API
  4. Building Java-second
  5. Tracking your API

The early days of Kotlin

A faster Scala

2012

Kotlin 1.0

2016

The language of Android

2017

-ktx

2017

Kotlin/Multiplatform

2017

Coroutines

2018

Android Jetpack Libraries

2019

Compose

2021

“Modern” Kotlin

What makes a library modern?

  • Thoughtful on typing
  • Frugal on language features
  • Kotlin for Kotlin

Aside - In the dependency tree

Aside - The Design Space tree 1

  1. https://two-wrongs.com/software-design-tree-and-program-families.html ↩︎

Thoughtful on typing

Typing & the Law of Demeter

Typing & the Law of Demeter

// Too much knowledge
fun <T> getFlagValue(
    name: String, 
    deserialize: (JSON) -> T
): T

Typing & the Law of Demeter

class Flag<T>(
    val name: String,
    internal val deserialize: (JSON) -> T,
)

Typing & the Law of Demeter

// Juuuuuust right
fun <T> getFlagValue(
    flag: Flag<T> 
): T

Typing & invalid states

Typing & invalid states

class Flag<T>(
    val name: String,
    internal val deserialize: (JSON) -> T,
)

Typing & invalid states

val myFlag = Flag(
    name = "Jamie Sanson", 
    deserialize = ...
)

Typing & invalid states

class Flag<T>(
    val name: Name,
    internal val deserialize: (JSON) -> T,
) {
    value class Name(val value: String)
}   

Frugal on language features

Language features - data classes

class Flag<T>(
    val name: Name,
    internal val deserialize: (JSON) -> T,
) {
    value class Name(val value: String)
}   

Binary compatibility and data classes

Binary compatibility and data classes

 data class Flag<T>(
   val name: Name,
+  val description: String?,
   internal val deserialize: (JSON) -> T,
) {
+  constructor(
+      name: Name, 
+      deserialize: (JSON) -> T
+   ) : this(name, null, deserialize)
}

Binary compatibility and data classes

// Before change ✅
val (name, deserialize: (JSON) -> Int) = flag

// After change 💥
val (name, deserialize: (JSON) -> Int) = flag
            ^ 'component2()' function returns 
              'String?', but '(JSON) -> Int' is expected

Mitigating data class binary compatibility

Mitigating data class binary compatibility

@Poko class Flag<T>(
   val name: Name,
   val description: String?,
   internal val deserialize: (JSON) -> T,
) 

Sealed types in public API

Sealed types & exhaustive when

sealed interface FlagType {
    data object Feature: FlagType

    @Poko class Experiment(
        val id: String
    ): FlagType
}

Sealed types & exhaustive when

when (val type = flag.type) {
    is Feature -> 
        doSomething()
    is Experiment -> 
        experiment(type.id)
}

Sealed types & exhaustive when

 sealed interface FlagType {
     data object Feature: FlagType

+    data object Rollout: FlagType

     @Poko class Experiment(
         val id: String
     ): FlagType
 }

Sealed types in private API

Sealed types in private API

class User internal constructor(
    internal val type: Type,
) {
    constructor(id: ID) : this(
        type = Type.Identified(id = id),
    )

    companion object {
        val Guest: User get() = User(Type.Guest)

        value class ID(val id: String)

        internal sealed interface Type {
            data object Guest : Type
            value class Identified(val id: ID) : Type
        }
    }

Be wary of function composition

Interceptor pattern - OkHttp 1

  1. https://square.github.io/okhttp/features/interceptors/ ↩︎

Interceptor pattern

Returned JSON content is always prepended with a while(1){} clause to mitigate abuse.

Interceptor pattern

class RemoveBodyPrefixInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val response = chain.proceed(chain.request())

        if (response.body == null) return response

        val newBody =
            response.body
                ?.string()
                ?.removePrefix("while (1) {}\n")
                ?.toResponseBody()

        return response.newBuilder()
            .body(newBody)
            .build()
    }
}

Be wary of function composition

Go wild (internally)

Building a modern Kotlin library

Example: Feature flagging

Requirements

  • Feature flags in feature modules
  • Pluggable backends
  • Consistent API across Kotlin and Swift

The Design

Abstractions

  • Flag
  • Source of Flag values

Flag

@Poko class Flag<T>(
   val name: Name,
   val description: Description,
   internal val defaultValue: () -> T,
   internal val deserialize: (JSON) -> T,
) 

FlagProvider

typealias FlagProvider = (Flag.Name) -> JSON?

FlagProvider

fun vetoProvider(
    delegate: FlagProvider
) = { name ->
    // Get the value
    val result = delegate(name)
    // Throw it out
    null
}

Constrained functional inputs

Constrained functional inputs

fun FlagProvider(
    name: String, 
    resolve: (Flag.Name) -> JSON?
): FlagProvider

Constrained functional inputs

class FlagProvider internal constructor(  
    internal val resolve: (Flag.Name) -> Output,
)

Constrained functional inputs

internal data class Output(
    internal val outcome: Outcome,
    internal val effect: Effect,
)

Keeping functional behaviours internal

fun FlagProvider(vararg providers: FlagProvider): FlagProvider =
   FlagProvider composite@{ flagName ->
        for (provider in providers) {
            val output = provider.resolve(flagName)

            if (output.outcome is Outcome.Found) {
                return@composite output
            }
        }

        FlagProvider.Output(
            outcome = Outcome.NotFound(flagName),
            effect = Effect.Empty,
        )
    }

Bringing it all together

Bringing it all together

class FlagProvider(...) {
    fun <Value> valueOf(flag: Flag<Value>): Value
}

Extra - Reactive Flags

Extra - Reactive Flags

val flagProvider: StateFlow<FlagProvider>

What about Java?

Supporting Java retroactively

Supporting Java retroactively

// For use in Java 8 😢
fun FlagProvider(
    name: String, 
    resolve: (String) -> String?
): FlagProvider

Supporting Java retroactively

// Usage from Java 8 😕
FlagProvider flagProvider = 
    FlagProviderKt.FlagProvider(
        "name", 
        flagName -> null
    );

Supporting Java retroactively

@file:JvmName("FlagProviders")
package com.marksandspencer.flagging

⋮

@JvmName("create")
fun FlagProvider(...): FlagProvider

Supporting Java retroactively

// Usage from Java 8 🙂
FlagProvider flagProvider = 
    FlagProviders.create(
        "name", 
        flagName -> null
    );

Supporting Java retroactively

@JvmSynthetic
fun FlagProvider(
    name: String, 
    resolve: (Flag.Name) -> JSON?
): FlagProvider

Supporting Java retroactively

@JvmName("create")
- fun FlagProvider(...): FlagProvider
+ internal fun FlagProvider(...): FlagProvider 

Keeping your API consistent

Metalava API tracking

Metalava API tracking

@dev.drewhamilton.poko.Poko public final class Flag<Value> {
    ctor public Flag(String name, String description, kotlin.jvm.functions.Function1<? super com.marksandspencer.flagging.JSON,? extends Value> decode, kotlin.jvm.functions.Function0<? extends Value> defaultValue);
    method public String getDescription();
    method public String getName();
    property public final String description;
    property public final String name;
    field public static final com.marksandspencer.flagging.Flag.Companion Companion;
  }

Metalava Gradle Plugin

https://github.com/tylerbwong/metalava-gradle

Conclusions

Conclusions

  • Think about complexity
  • Think about typing
  • Be wary of API foot guns, like data classes
  • Think Kotlin, first. Don’t worry about Java!

Questions

Slides

https://jamie.sanson.dev/kotlin-first-libraries