A curated collection of architectural patterns for business logic in Kotlin. This project demonstrates how to implement the UseCase pattern using different error-handling strategies while maintaining a clean, symmetric API.
flowchart LR
subgraph Inputs
direction TB
I1["Unit"]
I2["Unit"]
I3["Input"]
I4["Input"]
end
subgraph UseCases
direction TB
UC1["Action"]
UC2["Query<Output>"]
UC3["Command<Input>"]
UC4["Exchange<Input,Output>"]
end
subgraph Outputs
direction TB
O1["Unit"]
O2["Output"]
O3["Unit"]
O4["Output"]
end
I1 --> UC1 --> O1
I2 --> UC2 --> O2
I3 --> UC3 --> O3
I4 --> UC4 --> O4
In Kotlin, you cannot overload generics by the number of type parameters. This project solves the issue of handling
Unit as Input or Output without polluting the implementation with boilerplate, using a sealed interface
hierarchy.
While traditional CQRS separates reads and writes at the service or database level, this pattern applies the same principle at the UseCase level:
- Action — fire and forget (no input, no output)
- Query — read operation (no input, returns result)
- Command — write operation (takes input, returns nothing)
- Exchange — combined read-write (takes input, returns result)
This gives you the benefits of CQRS (clarity, separation of concerns) without the infrastructure complexity.
Arrow (Typed Errors) - recommended
The most robust version, leveraging Arrow-kt and its Raise DSL for type-safe error handling. It transforms exceptions into values using Either.
- Typed Errors: Errors are part of the signature via
Either<Throwable, T>. - Raise DSL: Uses Arrow's computation blocks for clean error propagation.
- No Exceptions: Business errors become values, not stack traces.
A pragmatic approach using Kotlin's built-in Result type. It uses runCatching to safely wrap execution.
-
Safety: Automatically catches exceptions and wraps them in a Result.
-
Zero Dependencies: Uses only the Kotlin Standard Library.
Warning
This variant does not handle errors. Exceptions propagate directly to the caller. Use only when you have a global exception handler (e.g., coroutine exception handler).
The most minimalist version. It calls functions directly and throws exceptions if something goes wrong.
-
Performance: Zero overhead from wrappers.
-
Simplicity: Ideal for simple projects where global exception handling is preferred.
-
Type Safety: Eliminates the need to pass
Unitas an argument. -
Consistency: All use cases across your project follow the same structure.
-
Readability: Specialized interfaces like
QueryorCommandmake the intent of the code clear at a glance.
Arrow (Typed Errors) - recommended
interface AuthenticationService {
suspend fun clearSession(): Either<Throwable, Unit>
}
class Logout(private val service: AuthenticationService) : UseCase.Action {
override suspend fun Raise<Throwable>.action() = service.clearSession().bind()
}interface ProductService {
suspend fun getProducts(): Either<Throwable, List<Product>>
}
class ListProducts(private val service: ProductService) : UseCase.Query<List<Product>> {
override suspend fun Raise<Throwable>.query() = service.getProducts().bind()
}interface ProfileService {
suspend fun uploadImage(id: String, url: String): Either<Throwable, Unit>
}
class UpdateAvatar(private val service: ProfileService) : UseCase.Command<UpdateAvatar.Input> {
data class Input(val id: String, val url: String)
override suspend fun Raise<Throwable>.command(input: Input) = with(input) {
service.uploadImage(id = id, url = url).bind()
}
}interface AuthenticationService {
suspend fun signIn(email: String): Either<Throwable, Credentials>
}
class LoginWithEmail(private val service: AuthenticationService) : UseCase.Exchange<LoginWithEmail.Input, Credentials> {
data class Input(val email: String)
override suspend fun Raise<Throwable>.exchange(input: Input) = service.signIn(email = input.email).bind()
}Result (Standard Wrapper)
interface AuthenticationService {
suspend fun clearSession(): Result<Unit>
}
class Logout(private val service: AuthenticationService) : UseCase.Action {
override suspend fun action() = service.clearSession().getOrThrow()
}interface ProductService {
suspend fun getProducts(): Result<List<Product>>
}
class ListProducts(private val service: ProductService) : UseCase.Query<List<Product>> {
override suspend fun query() = service.getProducts().getOrThrow()
}interface ProfileService {
suspend fun uploadImage(id: String, url: String): Result<Unit>
}
class UpdateAvatar(private val service: ProfileService) : UseCase.Command<UpdateAvatar.Input> {
data class Input(val id: String, val url: String)
override suspend fun command(input: Input) = with(input) {
service.uploadImage(id = id, url = url).getOrThrow()
}
}interface AuthenticationService {
suspend fun signIn(email: String): Result<Credentials>
}
class LoginWithEmail(private val service: AuthenticationService) : UseCase.Exchange<LoginWithEmail.Input, Credentials> {
data class Input(val email: String)
override suspend fun exchange(input: Input) = service.signIn(email = input.email).getOrThrow()
}Raw (Direct Execution)
[!WARNING]
This variant does not handle errors. Exceptions propagate directly to the caller. Use only when you have a global exception handler (e.g., coroutine exception handler).
interface AuthenticationService {
suspend fun clearSession()
}
class Logout(private val service: AuthenticationService) : UseCase.Action {
override suspend fun action() = service.clearSession()
}interface ProductService {
suspend fun getProducts(): List<Product>
}
class ListProducts(private val service: ProductService) : UseCase.Query<List<Product>> {
override suspend fun query() = service.getProducts()
}interface ProfileService {
suspend fun uploadImage(id: String, url: String)
}
class UpdateAvatar(private val service: ProfileService) : UseCase.Command<UpdateAvatar.Input> {
data class Input(val id: String, val url: String)
override suspend fun command(input: Input) = with(input) {
service.uploadImage(id = id, url = url)
}
}interface AuthenticationService {
suspend fun signIn(email: String): Credentials
}
class LoginWithEmail(private val service: AuthenticationService) : UseCase.Exchange<LoginWithEmail.Input, Credentials> {
data class Input(val email: String)
override suspend fun exchange(input: Input) = service.signIn(email = input.email)
}This project is licensed under the MIT License - see the LICENSE file for details.