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 { /* ... */ }