I’m pleased to announce the GA release of Couchbase Kotlin SDK version 1.0. In truth, I’m over the moon. This project has been a labor of love. After working with Java for decades, I have a new favorite language.

In this article, I’ll say some nice things about Kotlin, and then show how to use the Couchbase Kotlin SDK to connect to the Capella Database-as-a-Service. Finally, I’ll share some design decisions that shaped the SDK’s public API. I hope you’ll stick around for that last part, especially if you’re designing the API of your own Kotlin library.

Why Kotlin?

Kotlin changed the way we think about asynchronous programming on the JVM. Kotlin’s coroutines and suspending functions are evidence that reactive programming might be a stepping stone to something better, something that doesn’t require sacrificing readable code at the altar of scalability. Kotlin has shown there’s a better way to write high-performance asynchronous code, and we don’t need to wait for Project Loom’s fibers and continuations.

Capella + Kotlin

Couchbase Capella is the Database-as-a-Service (DBaaS) for Couchbase Server. It’s solid tech, and when I signed up for a free trial a few weeks ago, the process was completely painless.

Let’s say you have a Capella trial cluster and you’ve added your IP to the Allow List, and you’ve created a database user who can read the pre-installed travel-sample bucket. Here’s how to connect to your cluster using the Kotlin SDK: 

Once you have a Cluster object, you can execute a N1QL query:

Or get a reference to a Collection and read a specific document:

The complete version of this example is included in the Kotlin SDK documentation, along with several others.

SDK API design decisions

The rest of this article is devoted to sharing some notes on the decisions we made while designing the public API of the Couchbase Kotlin SDK. In some cases, I’ll be comparing the Kotlin SDK to its older sibling, the Java SDK.

Extension vs independent SDK

The Couchbase Kotlin SDK depends on the same core-io library as the Java SDK but does not depend on the Java SDK.

Rejected alternatives

We considered supporting Kotlin by supplying extension functions for classes from the Java SDK. Unfortunately, some design decisions we made for the Java SDK did not translate well to Kotlin, and could not be compensated for just with extension functions.

We also considered providing a full native Kotlin API wrapper that simply delegated to the Java SDK, but we were concerned that having two versions of all the classes (one for Kotlin, one for Java) would be confusing for users.

Suspend or bust!

The Kotlin SDK does not provide a blocking API; the methods that do network I/O are all suspend functions.

Rejected alternatives

We considered adding “blocking” variants of Cluster, Bucket, Scope, Collection, etc. but this seems like something users can do themselves with very little effort just by wrapping calls to the suspend functions with runBlocking.

Optional parameters

Since Java does not have optional parameters, the Couchbase Java SDK emulates them with an “options block” constructed using the builder pattern. 

In the following example, withExpiry is an optional boolean parameter that defaults to false. The code snippets show a call site where the developer wants to pass true instead.

Java:

The Kotlin SDK takes advantage of Kotlin’s native support for default parameters:

Kotlin:

Rejected alternatives

We considered using method-specific option blocks for Kotlin as well, which would have looked something like:

This was rejected because it was clunky for users and difficult to maintain for SDK developers (consider the impact of adding a new option common to all methods).

We also considered using a lambda/mini-DSL for options, which would have looked something like:

This was the most tempting of the rejected alternatives because it would have been excellent for binary compatibility (method signatures wouldn’t change as new optional parameters are added). It also “feels like Kotlin.” It was rejected because:

    • IDE code completion did not provide the same level of guidance for DSLs as for method parameters (although IDEs will likely improve over time).
    • We wanted to reserve the final lambda parameter for other purposes.

Common parameters

Some optional parameters are common to many methods in the Couchbase SDK API. Examples include timeout duration, retry strategy, and tracing span.

In Java, these common options are properties of a CommonOptions base class which all the method-specific option blocks extend; to the user, they look no different than other parameters:

Java:

In Kotlin, we take a different approach that balances the convenience of default parameters with some pragmatic concessions for maintainability and binary compatibility. The common parameters are represented by an option block called CommonOptions. Methods accept an optional parameter whose default value is a CommonOptions instance representing the default options. Overriding the defaults looks like this:

Kotlin:

Rejected alternatives

We considered treating the common options as normal parameters, like this:

While this would certainly be pleasant for users, it was rejected because adding or removing a common parameter would require changing the signature of nearly every public method in the code base, and would make maintaining binary compatibility quite a chore. We looked into automating this process using code generation, but the complexity of that approach seemed to outweigh the value.

In the end, we opted to use the CommonOptions class as a kind of API bulkhead for isolating maintenance issues related to common options.

Binary compatibility

These decisions about common and optional parameters have the following implications for binary compatibility:

Adding an optional parameter to a method breaks binary compatibility only for that method. Compatibility can be restored by adding a method with the old signature, annotated as Deprecated(level=HIDDEN). The upshot is that the maintenance impact is isolated to a single method, and the code changes for retaining compatibility are likewise restricted in scope.

Adding a common parameter breaks binary compatibility only for the CommonOptions class. Compatibility can be restored by adding a constructor with the old signature, annotated as Deprecated(level=HIDDEN). Significantly, we don’t have to change the signature of the methods that take CommonOptions as a parameter.

Mutually exclusive parameters

Sometimes a method might have two different ways of specifying a parameter value. For example, several methods take an expiry argument which can be specified as either a Duration or an Instant. In the Java API, there’s nothing preventing you from writing this code:

These two ways of specifying the expiry are mutually exclusive, but Java lets you write the code anyway. If there’s a validity check, it has to happen at runtime. (In this specific example, the second call to expiry clobbers the value set by the earlier call.)

In Kotlin the upsert method has a single expiry parameter of type Expiry, where Expiry is a sealed class:

or

This pattern is applied across the API; mutually exclusive options are always represented as a single parameter that takes an instance of a sealed class whose instances represent the different ways of specifying the value.

Streaming results

The Couchbase Query, Analytics, View, and Full-Text Search services can all return very large results sets. In order to process these results efficiently without exhausting the heap, the query methods for these services return their results as a Kotlin Flow.

We provide two execute extension methods on this flow. One method buffers the result rows into memory before returning the whole result set (to be used only when the result set is known to be small). The other method lets the user provide a lambda to apply to each result row as it is received from the server. Both versions take advantage of the backpressure/flow control provided by the core-io library.

Rejected alternatives

We considered exposing the Project Reactor Flux/Mono objects used by the core-io library but decided that after getting a taste of coroutines we don’t miss Reactor at all, and we believe most users will feel the same way.

DSL vs. hierarchical builder

The Couchbase SDKs have many configuration options grouped into separate categories. For the JVM SDKs, these options are properties of the ClusterEnvironment. In Java, these options are configured using a ClusterEnvironment builder. Here’s an example in Java that disables compression, DNS SRV, and the Key/Value service circuit breaker:

The Kotlin API takes advantage of Kotlin’s DSL support, allowing the same configuration to be expressed as:

The Kotlin API also allows configuring the environment inline with the connect method:

Users who prefer the traditional cluster environment builder over the DSL can continue to use the builder if they wish.

Rejected alternatives

We could just as easily have used data classes instead of a DSL:

That would have been okay, but the DSL is more concise and feels more like we’re playing to Kotlin’s strength.

Summary

We put a lot of careful thought into designing the Couchbase Kotlin SDK’s public API. I can’t promise we got everything right, but hopefully, the result feels like something that respects Kotlin idioms and best practices.

The Couchbase Kotlin SDK is finally ready to use in production, whether you’re using the Capella DBaaS or managing your own Couchbase Server cluster. Everything not annotated as volatile or uncommitted is now officially part of the stable public API. A huge Thank You! goes out to everyone in the community who shared their feedback along the way.

Author

Posted by David Nault

David Nault writes code at Couchbase, where he works on the SDK & Connectors team.

Leave a reply