Posts Tagged ‘AsyncAPI’
[KotlinConf2019] Simplifying Async APIs with Kotlin Coroutines
Tom Hanley, a senior software engineer at Toast, enthralled KotlinConf2019 with a case study on using Kotlin coroutines to tame a complex asynchronous API for an Android card reader. Drawing from his work integrating a third-party USB-connected card reader, Tom shared how coroutines transformed callback-heavy code into clean, sequential logic. His practical insights on error handling, debugging, and testing offered a roadmap for developers grappling with legacy async APIs.
Escaping Callback Hell
Asynchronous APIs often lead to callback hell, where nested callbacks make code unreadable and error-prone. Tom described the challenge of working with a third-party Android SDK for a card reader, which relied on void methods and listener interfaces for data retrieval. A naive implementation to fetch device info involved mutable variables and blocking loops, risking infinite loops and thread-safety issues. Such approaches, common with legacy APIs, complicate maintenance and scalability. Tom emphasized that coroutines offer a lifeline, allowing developers to wrap messy APIs in a clean, non-blocking interface that reads like sequential code, preserving the benefits of asynchrony.
Wrapping the Card Reader API with Coroutines
To streamline the card reader API, Tom developed a Kotlin extension that replaced callback-based interactions with suspend functions. The original API required a controller to send commands and a listener to receive asynchronous responses, such as device info or errors. By introducing a suspend getDeviceInfo
function, Tom enabled callers to await results directly. This extension ensured referential transparency, where functions clearly return their results, and allowed callers to control asynchrony—waiting for completion or running tasks concurrently. The approach also enforced sequential execution for dependent operations, critical for the card reader’s connection and transaction workflows.
Communicating with Channels
Effective inter-thread communication was key to the extension’s success. Rather than relying on shared mutable variables, Tom used Kotlin channels to pass events and errors between coroutines. When the listener received device info, it sent the data to a public channel; errors were handled similarly. The controller extension used a select
expression to await the first event from either the device info or error channel, throwing errors or returning results as needed. Channels, with their suspending send
and receive
operations, provided a thread-safe alternative to blocking queues. Despite their experimental status in Kotlin 1.3, Tom found them production-ready, supported by smooth IDE migration paths.
Mastering Exception Handling
Exception handling in coroutines requires careful design, as Tom learned through structured concurrency introduced in Kotlin 1.3. This feature enforces a parent-child relationship, where canceling a parent coroutine cancels its children. However, Tom discovered that a child’s failure propagates upward, potentially crashing the app in launch
coroutines if uncaught. For async
coroutines, exceptions are deferred until await
is called, allowing try-catch blocks to handle them. To isolate failures, Tom used supervisorJob
to prevent child cancellations from affecting siblings and coroutineScope
blocks to group all-or-nothing operations, ensuring robust error recovery for the card reader’s unreliable USB connection.
Debugging and Testing Coroutines
Debugging coroutines posed initial challenges, but Tom leveraged powerful tools to simplify the process. Enabling debug mode via system properties assigns unique names to coroutines, appending them to thread names and enhancing stack traces with creation details. The debug agent, a JVM tool released post-project, tracks live coroutines and dumps their state, aiding deadlock diagnosis. For testing, Tom wrapped suspend functions in runBlocking
blocks, enabling straightforward unit tests. He advised using launch
and async
only when concurrency is needed, marking functions as suspend
to simplify testing by allowing callers to control execution context.
Moving Beyond Exceptions with Sealed Classes
Reflecting on exception handling’s complexity, Tom shifted to sealed classes for error handling. Initially, errors from the card reader were wrapped in exceptions, but frequent USB failures made catching them cumbersome. Exceptions also obscured control flow and hindered functional purity. Inspired by arguments likening exceptions to goto statements, Tom adopted domain-specific sealed classes (e.g., Success
, Failure
, Timeout
) for each controller command’s result. This approach enforced explicit error handling via when
statements, improved readability, and allowed result types to evolve independently, aligning with the card reader’s diverse error scenarios.