Kotlin API Design
API design is a crucial skill for any Kotlin developer. Whether you're building libraries, frameworks, or just structuring your application code, well-designed APIs make your code more maintainable, intuitive, and easier to use correctly. This guide covers Kotlin-specific API design principles that will help you create elegant, idiomatic, and user-friendly interfaces.
Introduction to API Design in Kotlin
An API (Application Programming Interface) is the contract between your code and the code that uses it. In Kotlin, we have unique language features that enable creating expressive, type-safe, and concise APIs. Good API design considers:
- Usability: How easy is your API to understand and use correctly?
- Readability: Does code using your API read naturally?
- Safety: Does your API prevent common mistakes?
- Consistency: Does your API follow established patterns and conventions?
- Flexibility: Can your API accommodate various use cases without becoming overly complex?
Let's explore how to achieve these goals with Kotlin's features.
Naming Conventions
Clear naming is the foundation of good API design. Kotlin follows specific conventions:
- Functions that compute something are named as verbs:
calculate()
,convert()
,find()
- Properties and functions that return a boolean often start with
is
,has
, orcan
:isEmpty()
,hasItems
,canExecute()
- Classes are typically nouns in PascalCase:
UserRepository
,PaymentProcessor
- Functions and properties use camelCase:
getUserById()
,totalAmount
- Constants use UPPER_SNAKE_CASE:
MAX_COUNT
,DEFAULT_TIMEOUT
// Good naming examples
class UserRepository {
fun findUserById(id: String): User? { /* ... */ }
fun saveUser(user: User): Boolean { /* ... */ }
val totalUserCount: Int
get() = /* ... */
fun isUserActive(id: String): Boolean { /* ... */ }
companion object {
const val MAX_USERS = 1000
}
}
Leveraging Kotlin Type System
Use Nullable Types for Optional Values
Kotlin's null safety features help create clearer, safer APIs:
// Bad: Using Optional or special values to indicate absence
fun getUser(id: String): User {
// Returns a "dummy" user if not found - confusing!
return database.find(id) ?: User(id = "-1", name = "Not found")
}
// Good: Using nullable types to indicate optional results
fun getUser(id: String): User? {
return database.find(id)
}
// Usage
val user = getUser("123")
if (user != null) {
// Safe processing
println(user.name)
}
// Or using safe call
println(user?.name)
Sealed Classes for Limited Hierarchies
Use sealed classes to represent finite sets of possibilities:
// Representing API responses
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val code: Int, val message: String) : ApiResult<Nothing>()
object Loading : ApiResult<Nothing>()
}
// Usage
fun handleResult(result: ApiResult<User>) {
when (result) {
is ApiResult.Success -> displayUser(result.data)
is ApiResult.Error -> showError(result.message)
is ApiResult.Loading -> showLoadingIndicator()
// No else branch needed - compiler knows it's exhaustive
}
}
Value Classes for Type Safety
Use value classes to create type-safe wrappers without runtime overhead:
// Without value classes - easy to mix up parameters
fun createUser(id: String, name: String, email: String) { /* ... */ }
// Could be called incorrectly: createUser(email, name, id)
// With value classes - type safety without overhead
@JvmInline
value class UserId(val value: String)
@JvmInline
value class EmailAddress(val value: String) {
init {
require(value.contains("@")) { "Invalid email address format" }
}
}
// Now the API is type-safe
fun createUser(id: UserId, name: String, email: EmailAddress) { /* ... */ }
// createUser(EmailAddress("[email protected]"), "John", UserId("123")) // Won't compile!
createUser(UserId("123"), "John", EmailAddress("[email protected]")) // Correct
Designing Function Signatures
Parameter Ordering
Place required parameters first, followed by optional parameters (with defaults):
// Good parameter ordering
fun sendEmail(
recipient: String,
subject: String,
body: String,
isHtml: Boolean = false,
priority: Priority = Priority.NORMAL,
attachments: List<Attachment> = emptyList()
) { /* ... */ }
// Usage - clear and concise for common cases
sendEmail("[email protected]", "Hello", "This is a test")
// With named parameters for clarity when using optional params
sendEmail(
recipient = "[email protected]",
subject = "Report",
body = "<h1>Monthly Report</h1>",
isHtml = true
)
Named Parameters
Design APIs with named parameters in mind for improved readability:
// Function designed for named parameter usage
fun createWindow(
width: Int,
height: Int,
title: String = "Untitled",
resizable: Boolean = true,
modal: Boolean = false
) { /* ... */ }
// Usage becomes self-documenting
createWindow(
width = 800,
height = 600,
title = "Settings",
modal = true
)
Extension Functions for Contextual Operations
Use extension functions to add operations to existing types in a readable way:
// Without extension functions
fun convertStringToDate(dateStr: String, format: String = "yyyy-MM-dd"): LocalDate {
return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern(format))
}
// With extension functions - more natural API
fun String.toLocalDate(format: String = "yyyy-MM-dd"): LocalDate {
return LocalDate.parse(this, DateTimeFormatter.ofPattern(format))
}
// Usage
val date = "2023-10-15".toLocalDate()
val customDate = "15/10/2023".toLocalDate(format = "dd/MM/yyyy")
Building DSLs with Builders
Kotlin's features make it excellent for creating Domain-Specific Languages (DSLs):
// HTML builder DSL example
fun createHtmlDocument() = html {
head {
title("Kotlin API Design")
meta(name = "description", content = "Learn about API design in Kotlin")
}
body {
h1("Welcome to Kotlin API Design")
p {
+"This is a paragraph about "
b("Kotlin DSLs")
+". They're powerful!"
}
div(classes = "footer") {
p("Copyright 2023")
}
}
}
// The above DSL might generate:
// <html>
// <head>
// <title>Kotlin API Design</title>
// <meta name="description" content="Learn about API design in Kotlin">
// </head>
// <body>
// <h1>Welcome to Kotlin API Design</h1>
// <p>This is a paragraph about <b>Kotlin DSLs</b>. They're powerful!</p>
// <div class="footer">
// <p>Copyright 2023</p>
// </div>
// </body>
// </html>
Here's how to implement such a DSL:
@DslMarker
annotation class HtmlDsl
@HtmlDsl
class HtmlBuilder {
private val content = StringBuilder()
fun head(init: HeadBuilder.() -> Unit) {
content.append("<head>")
val head = HeadBuilder().apply(init)
content.append(head.build())
content.append("</head>")
}
fun body(init: BodyBuilder.() -> Unit) {
content.append("<body>")
val body = BodyBuilder().apply(init)
content.append(body.build())
content.append("</body>")
}
fun build(): String = "<html>$content</html>"
}
// Other builder classes would be similarly implemented
fun html(init: HtmlBuilder.() -> Unit): String {
return HtmlBuilder().apply(init).build()
}
API Stability and Evolution
Versioning
When designing public APIs, consider versioning from the start:
// Version in package name
package com.example.library.v1
// Or explicit versioning in class/function names
class UserServiceV1 { /* ... */ }
class UserServiceV2 { /* ... */ }
Deprecation
Use @Deprecated
to guide users toward newer APIs:
@Deprecated(
message = "Use findUserById instead",
replaceWith = ReplaceWith("findUserById(id)")
)
fun getUserById(id: String): User? {
return findUserById(id)
}
fun findUserById(id: String): User? { /* ... */ }
API Documentation
Use KDoc comments to document your API:
/**
* Processes a payment transaction.
*
* @param amount The payment amount in cents
* @param currency The three-letter currency code (e.g., "USD")
* @param description Optional description of the transaction
* @return A [Transaction] object representing the processed payment
* @throws InsufficientFundsException if the account has insufficient funds
* @throws InvalidCurrencyException if the currency is not supported
*/
@Throws(InsufficientFundsException::class, InvalidCurrencyException::class)
fun processPayment(
amount: Long,
currency: String,
description: String? = null
): Transaction { /* ... */ }
Practical Example: Building a File Processing API
Let's design a complete API for a file processing library:
/**
* A library for simplified file operations with Kotlin idioms.
*/
class KotlinFiles private constructor() {
companion object {
/**
* Creates a temporary file with the specified content.
*
* @param content The content to write to the file
* @param prefix Optional prefix for the file name
* @param suffix Optional suffix for the file name
* @return The created temporary file
*/
fun createTempFile(
content: String,
prefix: String = "temp",
suffix: String? = null
): File {
val file = kotlin.io.createTempFile(prefix, suffix)
file.writeText(content)
return file
}
/**
* Reads a text file with specified encoding.
*
* @param file The file to read
* @param charset The character encoding to use
* @return The file contents as a string
*/
fun readTextFile(
file: File,
charset: Charset = Charsets.UTF_8
): String {
return file.readText(charset)
}
}
}
// Extension functions for more idiomatic usage
/**
* Processes each line of a file.
*
* @param action The function to execute for each line
*/
fun File.forEachLine(action: (String) -> Unit) {
useLines { lines -> lines.forEach(action) }
}
/**
* Transforms each line of a file and collects the results.
*
* @param transform The transformation function
* @return A list of transformed values
*/
fun <T> File.mapLines(transform: (String) -> T): List<T> {
return useLines { it.map(transform).toList() }
}
// Example usage
fun processLogsExample() {
val logFile = File("application.log")
// Count error entries
val errorCount = logFile.mapLines { line ->
line.contains("ERROR")
}.count { it }
println("Found $errorCount errors in the log")
// Process each log entry
logFile.forEachLine { line ->
if (line.contains("CRITICAL")) {
sendAlert(line)
}
}
}
fun sendAlert(message: String) {
// Implementation...
}
Best Practices Summary
- Design for your users: Consider how your API will be used in practice
- Follow consistent naming conventions: Use Kotlin's standard naming patterns
- Leverage the type system: Use nullability, sealed classes, and value classes for safer APIs
- Use extension functions: Create more readable, contextual APIs
- Consider parameter order and defaults: Required parameters first, followed by optional ones
- Design for named parameters: Make complex calls readable with named arguments
- Document your API: Use KDoc to explain usage, parameters, and exceptions
- Plan for evolution: Consider versioning and use @Deprecated to guide users
- Build DSLs for complex construction: Use Kotlin's functional features for expressive DSLs
- Follow the principle of least surprise: Make your API behave as users would expect
Real-World Application: Configuration Library
Here's a more complex example showing a configuration library with good API design:
// A type-safe configuration library
class Configuration private constructor(
private val properties: Map<String, Any>
) {
// Type-safe getters with default values
inline fun <reified T> get(key: String, defaultValue: T): T {
val value = properties[key] ?: return defaultValue
return when (T::class) {
String::class -> value as? T ?: defaultValue
Int::class -> (value.toString().toIntOrNull() as? T) ?: defaultValue
Boolean::class -> (value.toString().lowercase() == "true" as? T) ?: defaultValue
// Add other type conversions as needed
else -> defaultValue
}
}
// Nullable version for required properties
inline fun <reified T> getOrNull(key: String): T? {
val value = properties[key] ?: return null
return when (T::class) {
String::class -> value as? T
Int::class -> value.toString().toIntOrNull() as? T
Boolean::class -> (value.toString().lowercase() == "true") as? T
else -> null
}
}
// Builder pattern
class Builder {
private val properties = mutableMapOf<String, Any>()
fun set(key: String, value: Any): Builder {
properties[key] = value
return this
}
fun build(): Configuration = Configuration(properties.toMap())
}
companion object {
fun builder(): Builder = Builder()
// Factory method for creating from properties file
fun fromPropertiesFile(filePath: String): Configuration {
val properties = Properties()
File(filePath).inputStream().use { properties.load(it) }
val map = properties.entries.associate { entry ->
entry.key.toString() to entry.value
}
return Configuration(map)
}
// DSL entry point
fun create(block: Builder.() -> Unit): Configuration {
return builder().apply(block).build()
}
}
}
// Usage examples
fun configurationExamples() {
// Builder pattern usage
val config1 = Configuration.builder()
.set("app.name", "MyApp")
.set("app.version", "1.0")
.set("debug.enabled", true)
.build()
// DSL usage
val config2 = Configuration.create {
set("app.name", "MyApp")
set("app.version", "1.0")
set("debug.enabled", true)
}
// From properties file
val config3 = Configuration.fromPropertiesFile("app.properties")
// Type-safe access with defaults
val appName: String = config1.get("app.name", "DefaultApp")
val maxConnections: Int = config1.get("db.maxConnections", 10)
val debugMode: Boolean = config1.get("debug.enabled", false)
// Required properties (will return null if missing)
val requiredSetting: String? = config1.getOrNull("security.key")
requiredSetting?.let {
println("Using security key: $it")
} ?: run {
println("Security key not configured!")
}
}
Summary
Designing good APIs in Kotlin is about leveraging the language's features to create interfaces that are:
- Intuitive - following conventions that feel natural to Kotlin developers
- Safe - using the type system to prevent errors
- Concise - reducing boilerplate while maintaining clarity
- Flexible - accommodating various use cases elegantly
- Documented - providing clear guidance on usage and behavior
By applying the principles covered in this guide, you'll create APIs that are a pleasure to use, are less prone to misuse, and stand the test of time.
Additional Resources
- Effective Kotlin by Marcin Moskala
- Kotlin Official Style Guide
- Kotlin DSL Guide
- API Design Patterns Book by JJ Geewax
Exercises
- Refactor an existing Java API to follow Kotlin API design principles
- Design a type-safe builder for creating HTTP requests
- Create a mini-DSL for a specific domain (like test assertions, UI components, or configuration)
- Implement a versioning strategy for an evolving API
- Design a library API with extension functions that enhance an existing Kotlin or Java library
By mastering these API design techniques, you'll improve not just the libraries you create, but also the architecture of your applications by designing better internal interfaces between components.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)