Debuggability
Users of your library will build on its functionality, and the features they build will contain errors that need to be identified and resolved. This error resolution process might be conducted within a debugger during development or using logging and observability tools in production. Your library can follow these best practices to make debugging it easier.
For every type that contains state, provide a meaningful toString
implementation. This implementation should return an intelligible representation of the instance's current content, even for internal types.
Since toString
representations of types are often written to logs, consider security when implementing this method and avoid returning sensitive user data.
Ensure the format used to describe the state is as consistent as possible across the different types in your library. This format should be explicitly described and thoroughly documented when it is part of a contract implemented by your API. The output from your toString
methods may support parsing, for example in automated test suites.
For example, consider the following types from a library supporting service subscriptions:
enum class SubscriptionResultReason {
Success, InsufficientFunds, IncompatibleAccount
}
class SubscriptionResult(
val result: Boolean,
val reason: SubscriptionResultReason,
val description: String
)
Without a toString
method, printing a SubscriptionResult
instance is not very useful:
fun main() {
val result = SubscriptionResult(
false,
IncompatibleAccount,
"Users account does not support this type of subscription"
)
//prints 'org.example.SubscriptionResult@13221655'
println(result)
}
Nor is the information readily displayed in the debugger:
Adding a simple toString
implementation improves the output significantly in both cases:
//prints 'Subscription failed (reason=IncompatibleAccount, description="Users
// account does not support this type of subscription")'
override fun toString(): String {
val resultText = if(result) "succeeded" else "failed"
return "Subscription $resultText (reason=$reason, description=\"$description\")"
}
While it might be tempting to use data classes to gain a toString
method automatically, it's not recommended for backward compatibility reasons. Data classes are discussed in more detail in the Avoid using data classes in your API section.
Note that the state described in the toString
method does not need to be information from the problem domain. It can relate to the status of ongoing requests (as in the example above), the health of connections to external services, or intermediate state within an ongoing operation.
For example, consider the following builder type:
class Person(
val name: String?,
val age: Int?,
val children: List<Person>
) {
override fun toString(): String =
"Person(name=$name, age=$age, children=$children)"
}
class PersonBuilder {
var name: String? = null
var age: Int? = null
val children = arrayListOf<Person>()
fun child(personBuilder: PersonBuilder.() -> Unit = {}) {
children.add(person(personBuilder))
}
fun build(): Person = Person(name, age, children)
}
fun person(personBuilder: PersonBuilder.() -> Unit = {}): Person =
PersonBuilder().apply(personBuilder).build()
This is how you would use this type:

If you halt the code at the breakpoint displayed on the image above, the information displayed will not be helpful:
Adding a simple toString
implementation results in a much more helpful output:
override fun toString(): String =
"PersonBuilder(name=$name, age=$age, children=$children)"
With this addition, the debugger shows:
This way, you can immediately see which fields are set and which are not.
As discussed in the Choose appropriate error handling mechanism section, there are occasions when it's appropriate for your library to throw an exception to signal an error. You may create your own exception types for this purpose.
Libraries that abstract and simplify low-level APIs will also need to handle exceptions thrown by their dependencies. A library might choose to suppress the exception, pass it on as it is, convert it to a different type of an exception, or signal the error to users in a different way.
Any of these options could be valid, depending on the context. For example:
If a user adopts library A purely for the convenience of simplifying library B, it may be appropriate for library A to rethrow any exceptions generated by library B without modification.
If library A adopts library B purely as an internal implementation detail, then library-specific exceptions thrown by library B should never be exposed to users of library A.
You must adopt and document a consistent approach to exception handling so users can make productive use of your library. This is especially important for debugging. Users of your library should be able to recognise, in the debugger and in logs, when an exception has originated from your library.
The type of the exception should indicate the type of the error, and the data in the exception should help the user locate the root cause of the issue. A common pattern is to wrap a low-level exception in a library-specific one, with the original exception accessible as the cause
.
In the next part of the guide, you'll learn about testability.