Use case classes, also known as interactors, are probably the most recognizable aspect of “Clean Architecture” school of thought. In this post, I’ll describe how you can implement use cases in Kotlin.
Note that in my previous post I showed several approaches to implement use cases in Java. Except for specific code examples, that article contains much theoretical discussion which I don’t intend to repeat here. Therefore, even if you aren’t going to write Java code, I still recommend that you read the previous article before proceeding with this one.
Synchronous Use Cases
If you want to encapsulate a time-consuming flow inside a synchronous use case, then that’s how you’d write it in Kotlin:
class LoginUseCaseSync { sealed class LoginResult { ... } @WorkerThread fun logIn(username: String, password: String): LoginResult { ... } }
As you can see, there is very little difference in implementation of synchronous use cases between Java and Kotlin. The most profound one is that you can use sealed classes to represent different invocation results.
Asynchronous Use Cases Using Observer Design Pattern
To implement asynchronous use cases, you can also resolve to the classical Observer design pattern:
class LoginUseCase: BaseObservable<LoginUseCase.Listener>() { interface Listener { fun onLoggedIn() fun onLoginFailed() } fun logIn(username: String, password: String) { ... } @UiThread private fun notifySuccess() { for (listener in listeners) { listener.onLoggedIn() } } @UiThread private fun notifyFailure() { for (listener in listeners) { listener.onLoginFailed() } } }
Note that concurrency aspects of this implementation aren’t visible to the outside clients. Therefore, you can use whatever framework you want to offload execution to a background thread and then notify the listeners on UI thread. For instance, you can use Kotlin Coroutines framework to implement this pattern in the following way:
fun logIn(username: String, password: String) { GlobalScope.launch(Dispatchers.Main) { val result = withContext(Dispatchers.IO) { ... } if (result.isSuccess) { notifySuccess() } else { notifyFailure() } } }
There is a general recommendation to avoid GlobalScope, but, in my opinion, it’s totally appropriate for cases when you don’t need cancellation, like this one.
Asynchronous Use Cases Using Coroutines and Suspension
Until now, I simply translated to Kotlin the same approaches you’d use in Java. However, if you use Kotlin, then you can also leverage the full power of Coroutines framework which allows you to replace thread blocking with coroutine suspension.
That’s how you implement asynchronous use cases using suspending function:
class LoginUseCase { sealed class LoginResult { ... } suspend fun logIn(username: String, password: String): LoginResult { return withContext(Dispatchers.IO) { ... } } }
Then, whenever you need to execute this flow, you wrap the invocation of the use case in a new coroutine:
fun onLoginClicked() { coroutineScope.launch(Dispatchers.Main) { val loginResult = loginUseCase.logIn(getUsername(), getPassword()) ... handle login result on UI thread } }
The first benfit of this approach is that the use case itself becomes smaller. You don’t need to extend BaseObservable anymore and you don’t need to notify listeners about the results. However, sparing several lines of code is not a major benefit in my opinion.
The real benefit of using suspending coroutines is that it makes the code more readable. When you use this approach, the logic that handles the result of use case’s invocation will be placed right next to the invocation point itself, even though the invocation is asynchronous. No need to search for callback methods scattered across the calling class anymore.
Additional benefit is that synchronous and asynchronous use cases become indistinguishable for external clients. They just expose a single suspending method, which can behave as either sync or async, depending on the internal implementation. Therefore, it becomes very easy to compose use cases to construct complex flows.
Now, don’t rush to convert all your use cases to suspending coroutines yet. It’s not all rainbows and unicorns, so let’s talk about the drawbacks.
The main drawback of using suspending coroutines is similar to the main drawback of simple callbacks: suspending calls are (potentially) asynchronous, so they can complete after the calling component is already in its “stopped” state. Therefore, whenever you use this approach, you must deal with cancellation.
Many developers will claim that Coroutines make cancellation very simple, so this concern is fully addressed by the framework. However, I believe that it’s the other way around: Coroutines make cancellation more complex by introducing some not-so-obvious risks.
Coroutines Cancellation
If you’d want to cancel the execution of coroutine from the previous section, you could do it like this:
fun onLoginClicked() { job = coroutineScope.launch(Dispatchers.Main) { val loginResult = loginUseCase.logIn(getUsername(), getPassword()) ... handle login result on UI thread } } override fun onStop() { super.onStop() job?.cancel() }
You can also cancel multiple Jobs at once, or even cancel the entire CoroutineScope. Some components, like ViewModel, also have predefined scopes which are cancelled automatically for you. All in all, on the surface, it looks like cancelling coroutines is indeed piece of cake. But let’s dig a bit deeper.
Here is a simple question: is it safe to cancel the above coroutine? Think about it for a minute.
When you use Observer design pattern, you simply unsubscribe from use cases and don’t receive notifications. Therefore, the flow inside the use case runs to completion and there is no risk of having partial side effects or atomicity violations. It’s very different with Coroutines.
Let’s say that this is the internal implementation of the use case:
class LoginUseCase(private val loginServerEndpoint: LoginServerEndpoint) { sealed class LoginResult { object Success: LoginResult() object Failure: LoginResult() } suspend fun logIn(username: String, password: String): LoginResult { return withContext(Dispatchers.IO) { val serverResult = loginServerEndpoint.login(username, password) if (serverResult.isSuccess) { updateInMemoryState1(serverResult) updateInMemoryState2(serverResult) return@withContext LoginResult.Success } else { return@withContext LoginResult.Failure } } } }
Is it safe to cancel this use case?
If both updateInMemoryState1()
and updateInMemoryState2()
are non-suspending, this use case is probably safe to cancel. The non-suspending nature of these methods implies that either none of them will execute, or both will execute. [Except for the corner case when app’s process might be killed, but in that case the entire in-memory state is reset]
However, if these methods are suspending, then the answer depends on their internal implementation. It boils down to the fact that each suspending call inside your codebase is a potential cancellation point.
If these methods are suspending, then the first question you’ll need to ask is whether there is a relationship between “in memory state 1” and “in memory state 2”. Whenever these states are independent, or, at least, don’t need to be in sync with one another, then cancelling this use case will probably be safe. If that’s not the case, then it gets trickier and requires looking deeper into methods’ implementations. For example, you’ll need to see wehther these methods switch context and whether NonCancellable Job is involved there.
As for persistence, working with SQLite transactions from within Coroutines is very tricky. I won’t discuss this aspect at all, but you can read this post to get an idea of how you’d do that with Room.
Furthermore, even if you write your use cases to be cancel-safe, each time the code will be changed there will be a risk of breaking this safety. Especially if you add suspending calls somewhere within the flow. Therefore, you’ll need to be constantly aware of this possibility and invest additional effort to make sure that all your suspending use cases remain safe to cancel in the future as well.
This isn’t an article about coroutines, so I won’t get into the technical details of how to make coroutines code safe for cancellation. All I want you to understand is that if you use coroutines in suspending manner, you need to deal with the full complexity of their cancellation. Maybe it’s not something you’d worry about in the context of trivial examples usually found in the official documentation, but be serious about it in any real-world application that might implement complex flows that involve many steps. Especially when reliability is important.
Summary
In this article we discussed how to implement use case classes in Kotlin. Now you understand the various trade-offs involved in the choice of implementation strategy.
One of the main goals I wanted to achieve with this post is to debunk the myth that I hear repeated over and over like mantra: “Coroutines make concurrency simple and allow for easy cancellation”. Both of these statements are false in my opinion. Coroutines is the most complex concurrency framework I’ve ever worked with and coroutines’ cancellation is simple only if you don’t care about reliability of your code.
Said all that, I, personally, haven’t made my mind about Coroutines yet because I haven’t seen enough production code that uses them yet. So I can’t decide even for myself whether Coroutines framework is a welcome change, or excessive complexity. One thing is certain, though: you should be at least as careful when working with coroutines as you’d be if you’d work with bare threads. Probably even more.
Thanks for reading. You can leave your comments and questions below.
Wouldn’t the same apply for the Observer solution. What if the observer called a method that spawned another thread? I think the assumption is being made that the Observer solution is always single threaded.
I’m not sure what you mean by “wouldn’t the same apply”, but the solution using Observer pattern can use any number of threads and be of any complexity. As long as the clients register/unregister properly, it’ll just work.
Your comment about the coroutione implementation:
“If both updateInMemoryState1() and updateInMemoryState2() are non-suspending, this use case is probably safe to cancel. The non-suspending nature of these methods implies that either none of them will execute, or both will execute. [Except for the corner case when app’s process might be killed, but in that case the entire in-memory state is reset]”
I am saying that if you did the same in the Observer implementation (spawning different threads instead of coroutines/suspends) you would have the issue
The “problem” with “suspending” use case is that you must cancel it. Therefore, any inner call to suspending method has the potential to be cancellation point. You can’t know for sure without looking at the implementation of these inner calls.
It’s not a problem in Observable use case (even if you implement it using coroutine in GlobalScope) because you don’t need to cancel it to begin with.
Hi Vasiliy,
I have one question, I have read many articles that say your domain layer shouldn’t know about any threading-related stuff and as I’ve seen you are using coroutines inside the use cases. What I should do in this case? In my projects, I’ve been injecting dispatchers into my use cases to abstract that threading logic, but still, my domain layer “knows” about threading. Thanks!
Hello Renan,
Usually I say that “application” layer (i.e. controllers) shouldn’t know about threading, not domain layer. But it’s always preferably when you don’t couple different responsibilities in domain layer either. That’s why I demonstrated the “trick” with synchronous use cases and async wrappers in the previous post. You can employ the same approach with coroutines based code, though the ROI will be lower because coroutine based code can already be sync-ish.
In addition, if you’ll want to invoke suspending use case from controllers, you’ll have to launch a coroutine there. So, this coupling seems to be unavoidable.
All in all, I think your approach is fine. Coroutines aren’t just rainbows and unicorns (as many devs are so willing to believe). It’s a complex framework with lots of nuances. When you bring it into your project, you need to decide with carcrifices you’re willing to make.