Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Evolving with Confidence: Kotlin Library API De...

Avatar for Arkadii Ivanov Arkadii Ivanov
July 03, 2024
170

Evolving with Confidence: Kotlin Library API Design and Compatibility Challenges

Maintaining a Kotlin library API is different from a regular Kotlin project. We have to care about compatibility when changing the API surface, so that users have seamless experience when updating to the new version. There are many challenges on this journey, some of them are Kotlin specific. In this talk we will walk through some real-life examples and explore various approaches and tools when evolving the API.

Avatar for Arkadii Ivanov

Arkadii Ivanov

July 03, 2024

Transcript

  1. Disclaimer 3 NOT GOOGLE Even though I work for Google,

    I’m doing this talk on my own capacity, unrelated to my employment and role. All opinions are my own.
  2. What is API? “An application programming interface (API) is a

    way for two or more computer programs or components to communicate with each other.” 5 By Wikipedia
  3. What is API? 6 class Foo { private val bar

    = Bar() fun foo() { bar.bar() } } internal class Bar { fun bar() {...} }
  4. What is API? 7 class Foo { private val bar

    = Bar() fun foo() { bar.bar() } } internal class Bar { fun bar() {...} } API of the public class API of the internal class
  5. What is API? 8 class Foo { private val bar

    = Bar() fun foo() { bar.bar() } } internal class Bar { fun bar() {...} } API of the public class (safe to change?) API of the internal class (safe to change!)
  6. Is it safe to change an API? An API is

    safe to change if we can also update all its clients. 9
  7. What is API? 10 Possible to update all clients? Change

    the API Changing an API Avoid breaking changes! End Yes No Update all clients
  8. Forward and Backward Compatibility • An API is forward-compatible if

    we can downgrade the API version without changing the clients. • An API is backward-compatible if we can upgrade the API version without changing the clients. 11
  9. Semantic Versioning (SemVer) Given a version number MAJOR.MINOR.PATCH, increment the:

    • MAJOR version when you make incompatible API changes. • MINOR version when you add functionality in a backward compatible manner. • PATCH version when you make backward compatible bug fixes. 12 semver.org
  10. Forward and Backward Compatibility • An API is forward-compatible if

    we can downgrade the API version without changing the clients. • An API is backward-compatible if we can upgrade the API version without changing the clients. 13
  11. Source and Binary Compatibility • An API change is source

    compatible if the client code still compiles on upgrade. • An API change is binary compatible if the client binaries compiled with the older version also work with the newer version. 14
  12. API Evolution Java Language Specification Chapter 13. Binary Compatibility 20

    docs.oracle.com/javase/specs/jls/se8/html/jls-13.html
  13. Binary Compatibility Validator 25 4. Build fails when API is

    changed without updating the .api files
  14. Binary Compatibility Validator 26 5. Run ./gradlew apiDump again 6.

    Check the diff in .api files 7. Commit the changes if all good
  15. Adding a New Argument to a Function 28 fun logError(

    tag: String, message: String, error: Throwable?, ) {...}
  16. Adding a New Argument to a Function 29 fun logError(

    tag: String, message: String, error: Throwable?, ) {...} SOURCE INCOMPATIBLE
  17. Adding a New Argument to a Function 30 fun logError(

    tag: String, message: String, error: Throwable? = null, ) {...}
  18. Adding a New Argument to a Function 31 fun logError(

    tag: String, message: String, error: Throwable? = null, ) {...}
  19. Adding a New Argument to a Function 32 “... adding

    a parameter to … a method … declaration creates a method … with a new signature, and has the combined effect of deleting the method … with the old signature and adding a method … with the new signature.” “Deleting a method … from a class may break compatibility with any pre-existing binary that referenced this method …; a NoSuchMethodError may be thrown when such a reference from a pre-existing binary is linked.” Java Language Specification (13.4.14):
  20. Adding a New Argument to a Function 33 fun logError(

    tag: String, message: String, error: Throwable? = null, ) {...} BINARY INCOMPATIBLE
  21. Adding a New Argument to a Function 34 fun logError(                            

    tag: String,                          message: String,                      error: Throwable? = null,             ) {...}                                   @Deprecated(                              message = "For binary compatibility", level = DeprecationLevel.HIDDEN,      )                                         fun logError( tag: String, message: String, ) {...}
  22. Adding a New Argument to a Function 36 fun logError(                            

    tag: String,                          message: String,                      error: Throwable? = null,             ) {...}                                   @Deprecated(                              message = "For binary compatibility", level = DeprecationLevel.HIDDEN,      )                                         fun logError( tag: String, message: String, ) {...} COMPATIBLE
  23. Adding a New Argument to a Function 37 fun logError(

    tag: String, message: () -> String, error: Throwable? = null, ) {...}
  24. Adding a New Argument to a Function 38 fun logError(

    tag: String, message: () -> String, error: Throwable? = null, ) {...} fun onFatalError() { logError(tag = "MyApp") { "Fatal error" } }
  25. Adding a New Argument to a Function 39 fun logError(

    tag: String, message: () -> String, error: Throwable? = null, ) {...} fun onFatalError() { logError(tag = "MyApp") { "Fatal error" } } SOURCE INCOMPATIBLE
  26. Adding a New Argument to a Function 40 fun logError(

    tag: String, error: Throwable? = null, message: () -> String, ) {...}
  27. Adding a New Argument to a Function 41 fun onFatalError(message:

    () -> String) { logError("MyApp", message) } fun logError( tag: String, error: Throwable? = null, message: () -> String, ) {...}
  28. Adding a New Argument to a Function 42 fun onFatalError(message:

    () -> String) { logError("MyApp", message) } fun logError( tag: String, error: Throwable? = null, message: () -> String, ) {...} SOURCE INCOMPATIBLE
  29. Adding a New Argument to a Function 43 fun logError(

    tag: String, message: () -> String, ) {...} fun logError(                tag: String,             error: Throwable?,       message: () -> String,   ) {...}                     
  30. Adding a New Argument to a Function 44 fun logError(

    tag: String, message: () -> String, ) {...} fun logError(                tag: String,             error: Throwable?,       message: () -> String,   ) {...}                      COMPATIBLE
  31. Adding a New Function to an Interface 45 interface Logger

    { fun logDebug(tag: String, message: String) fun logError(tag: String, message: String) }
  32. Adding a New Function to an Interface 46 interface Logger

    { fun logDebug(tag: String, message: String) fun logError(tag: String, message: String) } SOURCE INCOMPATIBLE
  33. Adding a New Function to an Interface 47 interface Logger

    { fun logDebug(tag: String, message: String) fun logError(tag: String, message: String) { logDebug(tag = tag, message = message)   }                                            }
  34. Adding a New Function to an Interface 48 interface Logger

    { fun logDebug(tag: String, message: String) fun logError(tag: String, message: String) { logDebug(tag = tag, message = message)   }                                            } COMPATIBLE
  35. Preventing Interface Implementations 50 sealed interface Logger { fun logDebug(tag:

    String, message: String) } class DefaultLogger : Logger { override fun logDebug(tag: String, message: String) {...} }
  36. Preventing Interface Implementations 51 sealed interface Logger { fun logDebug(tag:

    String, message: String) } class DefaultLogger : Logger { override fun logDebug(tag: String, message: String) {...} } internal class InternalLogger : Logger { override fun logDebug(tag: String, message: String) {...} }
  37. Preventing Interface Implementations 52 sealed interface Logger { fun logDebug(tag:

    String, message: String) fun logError(tag: String, message: String)                } class DefaultLogger : Logger { override fun logDebug(tag: String, message: String) {...} override fun logError(tag: String, message: String) {...} } internal class InternalLogger : Logger { override fun logDebug(tag: String, message: String) {...} override fun logError(tag: String, message: String) {...} }
  38. Preventing Interface Implementations 53 sealed interface Logger { fun logDebug(tag:

    String, message: String) fun logError(tag: String, message: String)                } class DefaultLogger : Logger { override fun logDebug(tag: String, message: String) {...} override fun logError(tag: String, message: String) {...} } internal class InternalLogger : Logger { override fun logDebug(tag: String, message: String) {...} override fun logError(tag: String, message: String) {...} } COMPATIBLE
  39. Preventing Abstract Class Implementations 54 abstract class Logger internal constructor(){

    abstract fun logDebug(tag: String, message: String) abstract fun logError(tag: String, message: String)       } class DefaultLogger : Logger() { override fun logDebug(tag: String, message: String) {...} override fun logError(tag: String, message: String) {...} } internal class InternalLogger : Logger() { override fun logDebug(tag: String, message: String) {...} override fun logError(tag: String, message: String) {...} }
  40. Preventing Abstract Class Implementations 55 abstract class Logger internal constructor(){

    abstract fun logDebug(tag: String, message: String) abstract fun logError(tag: String, message: String)       } class DefaultLogger : Logger() { override fun logDebug(tag: String, message: String) {...} override fun logError(tag: String, message: String) {...} } internal class InternalLogger : Logger() { override fun logDebug(tag: String, message: String) {...} override fun logError(tag: String, message: String) {...} } COMPATIBLE
  41. Enums 57 fun ErrorCode.description(): String = when (this) { ErrorCode.UNKNOWN

    -> "Unknown error" ErrorCode.BAD_REQUEST -> "Bad Request" ErrorCode.SERVER_ERROR -> "Server Error" } enum class ErrorCode { UNKNOWN, BAD_REQUEST, SERVER_ERROR, UNAUTHORIZED,       }
  42. Enums 58 fun ErrorCode.description(): String = when (this) { ErrorCode.UNKNOWN

    -> "Unknown error" ErrorCode.BAD_REQUEST -> "Bad Request" ErrorCode.SERVER_ERROR -> "Server Error" } enum class ErrorCode { UNKNOWN, BAD_REQUEST, SERVER_ERROR, UNAUTHORIZED,⠀⠀⠀⠀⠀ } SOURCE/BINARY INCOMPATIBLE
  43. Constants Instead of Enums 59 @JvmInline value class ErrorCode private

    constructor(val code: Int) { companion object { val Unknown: ErrorCode = ErrorCode(0) val BadRequest: ErrorCode = ErrorCode(1) val ServerError: ErrorCode = ErrorCode(2) val Unauthorized: ErrorCode = ErrorCode(3)         } } fun ErrorCode.description(): String = when (this) { ErrorCode.Unknown -> "Unknown error" ErrorCode.BadRequest -> "Bad Request" ErrorCode.ServerError -> "Server Error" else -> "Unknown error" }
  44. Constants Instead of Enums 60 @JvmInline value class ErrorCode private

    constructor(val code: Int) { companion object { val Unknown: ErrorCode = ErrorCode(0) val BadRequest: ErrorCode = ErrorCode(1) val ServerError: ErrorCode = ErrorCode(2) val Unauthorized: ErrorCode = ErrorCode(3)         } } fun ErrorCode.description(): String = when (this) { ErrorCode.Unknown -> "Unknown error" ErrorCode.BadRequest -> "Bad Request" ErrorCode.ServerError -> "Server Error" else -> "Unknown error" } COMPATIBLE
  45. Sealed Classes 61 sealed class ErrorCode { data object Unknown

    : ErrorCode() data object BadRequest : ErrorCode() data object ServerError : ErrorCode() data object Unauthorized : ErrorCode() }
  46. Sealed Classes 62 sealed class ErrorCode { data object Unknown

    : ErrorCode() data object BadRequest : ErrorCode() data object ServerError : ErrorCode() data object Unauthorized : ErrorCode() } fun ErrorCode.description(): String = when (this) { is ErrorCode.Unknown -> "Unknown" is ErrorCode.BadRequest -> "BadRequest" is ErrorCode.ServerError -> "ServerError" }
  47. Sealed Classes 63 sealed class ErrorCode { data object Unknown

    : ErrorCode() data object BadRequest : ErrorCode() data object ServerError : ErrorCode() data object Unauthorized : ErrorCode() } fun ErrorCode.description(): String = when (this) { is ErrorCode.Unknown -> "Unknown" is ErrorCode.BadRequest -> "BadRequest" is ErrorCode.ServerError -> "ServerError" } SOURCE/BINARY INCOMPATIBLE
  48. Sealed Classes 64 sealed class ErrorCode { data object Unknown

    : ErrorCode() data object BadRequest : ErrorCode() data object ServerError : ErrorCode() private data object NotExhaustive : ErrorCode() } fun ErrorCode.description(): String = when (this) { is ErrorCode.Unknown -> "Unknown" is ErrorCode.BadRequest -> "BadRequest" is ErrorCode.ServerError -> "ServerError" else -> "Unknown" // Required! }
  49. Sealed Classes 65 sealed class ErrorCode { data object Unknown

    : ErrorCode() data object BadRequest : ErrorCode() data object ServerError : ErrorCode() data object Unauthorized : ErrorCode()          private data object NotExhaustive : ErrorCode() } fun ErrorCode.description(): String = when (this) { is ErrorCode.Unknown -> "Unknown" is ErrorCode.BadRequest -> "BadRequest" is ErrorCode.ServerError -> "ServerError" else -> "Unknown" // Required! }
  50. Sealed Classes 66 sealed class ErrorCode { data object Unknown

    : ErrorCode() data object BadRequest : ErrorCode() data object ServerError : ErrorCode() data object Unauthorized : ErrorCode()          private data object NotExhaustive : ErrorCode() } fun ErrorCode.description(): String = when (this) { is ErrorCode.Unknown -> "Unknown" is ErrorCode.BadRequest -> "BadRequest" is ErrorCode.ServerError -> "ServerError" else -> "Unknown" // Required! } COMPATIBLE
  51. Data Classes 75 data class ErrorResponse( val code: ErrorCode, val

    message: String? = null, ) { @Deprecated("Binary compatibility", level = DeprecationLevel.HIDDEN) constructor(code: ErrorCode) : this(code = code)                     @Deprecated("Binary compatibility", level = DeprecationLevel.HIDDEN) fun copy(code: ErrorCode = this.code): ErrorResponse =               copy(code = code)                                                }
  52. Data Classes 79 data class ErrorResponse( val code: ErrorCode, val

    message: String? = null, ) { @Deprecated("Binary compatibility", level = DeprecationLevel.HIDDEN) constructor(code: ErrorCode) : this(code = code)                     @Deprecated("Binary compatibility", level = DeprecationLevel.HIDDEN) fun copy(code: ErrorCode = this.code): ErrorResponse =               copy(code = code)                                                } COMPATIBLE
  53. Data Classes Public API challenges in Kotlin By Jake Wharton

    80 jakewharton.com/public-api-challenges-in-kotlin/
  54. Experimental APIs 81 @RequiresOptIn @Retention(AnnotationRetention.BINARY) annotation class ExperimentalLibraryApi @ExperimentalLibraryApi fun

    logError( tag: String, message: String, error: Throwable? = null,           ) {...}
  55. Experimental APIs 82 @RequiresOptIn @Retention(AnnotationRetention.BINARY) annotation class ExperimentalLibraryApi @ExperimentalLibraryApi fun

    logError( tag: String, message: String, error: Throwable? = null,           ) {...} OK TO BREAK
  56. Tips and Hints • Reduce the API surface ◦ Only

    expose things that solve real use cases ◦ It’s easier to add a new API than change an existing one 84
  57. Tips and Hints • Reduce the API surface ◦ Only

    expose things that solve real use cases ◦ It’s easier to add a new API than change an existing one • Consider creating superior API ◦ Developers can build their own extensions on top of a flexible API 85
  58. Tips and Hints • Reduce the API surface ◦ Only

    expose things that solve real use cases ◦ It’s easier to add a new API than change an existing one • Consider creating superior API ◦ Developers can build their own extensions on top of a flexible API • Use the Binary Compatibility Validator plugin ◦ Prevents accident API changes ◦ Simplifies validation of breaking changes 86
  59. Tips and Hints • Reduce the API surface ◦ Only

    expose things that solve real use cases ◦ It’s easier to add a new API than change an existing one • Consider creating superior API ◦ Developers can build their own extensions on top of a flexible API • Use the Binary Compatibility Validator plugin ◦ Prevents accident API changes ◦ Simplifies validation of breaking changes • Use experimental OptIn annotations ◦ Promote API to stable when confident 87
  60. Tips and Hints • Reduce the API surface ◦ Only

    expose things that solve real use cases ◦ It’s easier to add a new API than change an existing one • Consider creating superior API ◦ Developers can build their own extensions on top of a flexible API • Use the Binary Compatibility Validator plugin ◦ Prevents accident API changes ◦ Simplifies validation of breaking changes • Use experimental OptIn annotations ◦ Promote API to stable when confident • Follow SemVer ◦ Go through alpha/beta stages before releasing a stable version ◦ Increment major version when releasing breaking changes 88
  61. Links • SemVer spec ◦ semver.org • Java Language Specification

    (Binary Compatibility) ◦ docs.oracle.com/javase/specs/jls/se8/html/jls-13.html • Binary Compatibility Validator ◦ github.com/Kotlin/binary-compatibility-validator • Public API challenges in Kotlin (by Jake Wharton) ◦ jakewharton.com/public-api-challenges-in-kotlin/ 89 @arkann1985