5 tips for writing great client SDK libraries
Recently I have learned a lot about what qualities make for a user friendly (and non-user friendly) client library.
Over the last 1.5 years I have been part of a team at Wix that’s developing event-driven data streaming infra on top of the Kafka message broker. The most important part of this infra is a JVM-based client library called Greyhound, which has been in continuous development for the past 5 years and was recently re-written from scratch and open-sourced.
Following are 5 tips for writing great libraries, touching on design, implementation and APIs.
1. Check if what you require isn’t open-sourced already
How to Search
Before jumping into the deep water of writing a library from scratch, first make sure there is no good open-source solution already available.
Search Github, Maven Central or NPM for current implementations. There are millions of NPM packages and JVM jars out there. But not all search hits will be a good fit. Make sure to checkout these important metrics
- Amount of downloads it has and/or the amount of stars on Github
- How many versions have been released, has a stable API v1.0 been released yet
- Does it have good, detailed documentation and/or tutorials
- Does it have an active community behind it on Gitter, Discord or Slack
Why OSS is a great place to start from
The great thing about open-source code is, that even if you find that some specific feature is missing, you can try to contribute it yourself, or in the worst case, fork the library and change it to your liking (just make sure to that you follow the guidelines of its software license on usage and modification — MIT is the most permissive and GPL is probably the most restrictive).
The changes that you would make to an existing codebase will probably be made much faster than if you start your own library from scratch. Even if you don’t find exactly what you need, the search may still expose you to different styles and techniques of writing libraries found in the wild.
Our Greyhound library was started back in 2015, when Kafka adoption was still relatively small, so there weren’t many OSS projects yet to work with.
2. Allow easy and flexible configuration with Builders
Naturally, as you add features to your library, the amount of configuration possibilities increases with exponentially numerous ways to combine them. Great client libraries let the user configure the client easily and just the way they want it.
Example: Configurations pile up
For example, let’s evolve the API of consumer creation in Greyhound for Wix devs — At first you start out with a constructor that accepts 2 or 3 configuration parameters.
Then, when you want to add another parameter — read offsets (from beginning, or latest), you avoid it and introduce a parameter object instead. Which offers better readability.
But as more features are added, more parameters are added, and the object becomes unwieldy to the users who still need to decide by themselves how to configure all of them (unless the language supports default attributes like Scala does). Now our object is not so readable and a bit scary for novice users.
The call site will require specifying a lot of primitive types (Strings, numbers, etc…) for GroupId, Kafka server, level of parallelism, etc. which will make it harder to read.
A better approach would be to use the builder pattern.
Builder pattern to the rescue
Once a library is mature and feature-full you end up with an initial builder that configures objects that have their own builders, which have objects with their own builders and so on…
The example below shows how nice it is to use such an API with our GreyhoundBuilder for Wix devs — who use Scala Future syntax (a bit simplified)
Notice how the producer builder itself contains an additional builder that allows to mutate its configuration by accepting a lambda.
An API design that relies on builders is more flexible, understandable, and easier to use and evolve.
3. The API should guide the user on how to proceed
As a client library creator, you want your users to make as few mistakes as possible when they call the library in their code. If some configuration item has ramifications for other configuration items, adding phases to the builder pattern (introduced in the previous section) that constrict the available options on the next phase according to choice of the current phase can come in very handy.
A potential conflict
Take for example Greyhound’s BufferedProducer, which uses an internal queue to buffer incoming produce requests. Part of its configuration is deciding if order of produced messages has to be kept.
In case order has to be kept, “external” retries are not allowed (External means not by Kafka SDK but by Greyhound itself).
The naive builder trait (interface) for BufferedProducer will look something like this:
The problem is that the users can create a
ProducerRetryPolicy that will potentially conflict with their own request to produce messages in order, in case they specify external retries:
One way to solve this is to create multiple “phases” traits.
The first phase
BufferedProducerMakerPhase1 will only allow configuring the ordering of produced messages. As a result of ordering choice two two different flavours will be created for the second phase.
The second phase flavours will only allow configuring retry policy, and will take into account what was the ordering choice when it configures external retries.
While implementing these multiple phases requires some boilerplate, it will keep the usage pattern as simple as it was in the example above but without the possibility of conflict.
It should be clear what NOT to use — limit the access to private code
Another aspect of guiding the user, is making sure any interface or implementation that should not be used directly or extended by the user should be made private, or package private.
Control of access will make it clear to the users what they should and shouldn’t use from the API and increase clarity. It will also allow to freely change more internal APIs without needing to deprecate and support extended migration periods.
4. A Multi-Layer design for various kinds of users
A great library empowers both casual/beginner users and “power” users. One approach to accommodate both would be, to have a basic API that exposes the “bare bones” features with maximum amount of configurability and customization, and provide additional “simpler” APIs on top for the most common use cases with presets built in.
Additional interop layers can also help in case of a technological stack that has many different styles, e.g., the JVM stack.
In Greyhound’s case, we’ve introduced multiple layers to support many different kinds of users:
- Wix Scala Future and Scala ZIO developers
- OSS Scala Future and Scala ZIO developers
- OSS Java developers
Under all the Layers
The core logic should be written in the most powerful and expressive language — In our case it is the ZIO pure functional Scala library — which executes code on fibers (very light “threads”) and has great built-in operations for concurrency and asynchronous code like foreachPar, fork, race, and Schedule.
Consider for example this code example from Greyhound ProduceFlusher that easily retries on produce failure according to a specified schedule that includes both intervals and predicates.
The core logic is also highly generic — it makes heavy use of generic types for dependency injection (ZIO environment) and domain objects.
One example is the RecordHandler that is generic in Environment type, error type and Kafka message Key and Value type:
The layers on top provide interoperability with other styles or languages. e.g., a Scala Future layer.
Here the interop layer accepts a RecordHandler with a Scala Future based signature.
ZIO provides easy interoperability with Scala Future using
ZIO.fromFuture operator that turns a Future based side-effect to a pure ZIO effect value.
The Wix layer is more specific and opinionated with specific configurations built-in.
For instance, a specific
GreyhoundConfig is loaded by
ConfigFactory from config file by that some of its properties have default values and others are populated by the production deployment system with the relevant values (e.g. broker addresses)
This multi layer approach provides maximum flexibility for our various users to be able to use features in multiple languages and styles and to be able to use features out-of-the-box or make their own custom adaptations and changes easily.
5. Don’t be afraid to rewrite bad APIs
One of the downsides of writing a client library versus only providing server-side api, is that it’s harder to evolve the API in a standard way. One cannot use a flexible IDL like protobuf that has simple guidelines for maintaining backward compatibility.
On the other hand, backward compatibility guidelines have their limit and will sometimes involve compromises. The resulting APIs will become less clear over time, as more fields and methods are added in non-optimal places.
How it will look like
Consider for example Greyhound’s ConsumeRetryPolicy retries steps API.
The first version of the API included retries steps defined with
Long type for backoff period.
But Scala has a much better type for defining intervals —
Duration. So a new case class was created for defining a retry configuration based on Duration
Notice how the old
ConsumerRetryPolicy#apply method has a deprecated annotation on it — guiding any new user not to choose this method.
With the new API, the user can define retry steps easily using either
Duration or unix-time defined with
Long if they prefer.
In the interim period, the
ConsumerRetryPolicy implementation can still use the old
RetryStep by converting from the new SingleRetry case class to the old RetryStep case class
I think the underlying rule of thumb is, to always keep the user of your library in first place when you consider the design, implementation and evolution of your API. Sometimes it will cause you to write more complex code with a bit more boilerplate, but it’s worth it if it will keep the API simple and user friendly over time.
Thank you for reading!
If you’d like to get updates, follow me on Twitter and Medium.
You can also visit my website, where you will find my previous blog posts, talks I gave in conferences and open-source projects I’m involved with.
If anything is unclear or you want to point out something, please comment down below.