The Functional Way to Build Reliable Apps — Arrow’s Either
“Good engineers fix bugs. Great engineers design systems where bugs can’t hide.”
The Functional Way to Build Reliable Apps — Arrow’s Either
“Good engineers fix bugs. Great engineers design systems where bugs can’t hide.”
Error handling is one of the most overlooked yet critical parts of Android development. Too often, we rely on exceptions and try-catch blocks, which can clutter code, hide errors, and make testing a nightmare.
Enter **Arrow — a functional programming library for Kotlin that gives developers elegant, type-safe tools to write predictable and maintainable code. One of its most powerful constructs is [Either](https://apidocs.arrow-kt.io/arrow-core/arrow.core/-either/index.html) — a monadic type that represents two possible outcomes: success (Right) or failure* (Left*).

What Is Either?
Either<L, R> is a sealed class that holds one of two possible values:
- Left<L>→ typically represents failure or error
- Right<R>→ represents success
sealed class Either<out L, out R> {
data class Left<out L>(val value: L) : Either<L, Nothing>()
data class Right<out R>(val value: R) : Either<Nothing, R>()
}
It’s the functional equivalent of saying:
“A function either returns the correct result, or an error — but never both.”
This makes your data flow explicit, type-safe, and fully testable.
Why Use Either in Android?
Concept Exceptions Either Error propagation Implicit, via stack trace Explicit, via types Predictability Unclear failure points Controlled and typed Testing Harder to mock failures Easy to simulate both paths Functional purity Side effects Pure and composable
Example:
val result = repository.getUserDetails("123")
val message = result.fold(
ifLeft = { "Error: $it" },
ifRight = { "User: ${it.name}" }
)
Project Architecture Overview
We’ll use a Clean MVVM architecture with Arrow’s Either.
com.example.eitherapp/
│
├── data/
│ ├── model/User.kt
│ ├── remote/ApiService.kt
│ └── repository/UserRepository.kt
│
├── domain/
│ ├── model/Failure.kt
│ ├── usecase/GetUserDetailsUseCase.kt
│
├── presentation/
│ ├── viewmodel/UserViewModel.kt
│ ├── ui/UserScreen.kt
│ └── ui/state/UserUiState.kt
│
└── di/
└── AppModule.kt
Setting Up Dependencies
Add Arrow to your Gradle:
dependencies {
implementation("io.arrow-kt:arrow-core:2.1.2")
}
Enable plugin (optional):
plugins {
id("io.arrow-kt.arrow") version "2.1.2"
}
Step 1: Define Model & API
// data/model/User.kt
data class User(
val id: String,
val name: String,
val email: String
)
// data/remote/ApiService.kt
interface ApiService {
@GET("users/{id}")
suspend fun getUserDetails(@Path("id") id: String): Response<User>
}
Step 2: Handle Failures with a Sealed Class
// domain/model/Failure.kt
sealed class Failure {
object NetworkError : Failure()
object NotFound : Failure()
data class UnknownError(val message: String?) : Failure()
}
Step 3: Repository Using Either
class UserRepository @Inject constructor(
private val apiService: ApiService
) {
suspend fun getUserDetails(id: String): Either<Failure, User> {
return try {
val response = apiService.getUserDetails(id)
if (response.isSuccessful && response.body() != null) {
Either.Right(response.body()!!)
} else {
Either.Left(Failure.NotFound)
}
} catch (e: IOException) {
Either.Left(Failure.NetworkError)
} catch (e: Exception) {
Either.Left(Failure.UnknownError(e.message))
}
}
}
Step 4: UseCase Layer
class GetUserDetailsUseCase @Inject constructor(
private val repository: UserRepository
) {
suspend operator fun invoke(userId: String): Either<Failure, User> {
return repository.getUserDetails(userId)
}
}
Step 5: ViewModel — Consuming Either
@HiltViewModel
class UserViewModel @Inject constructor(
private val getUserDetailsUseCase: GetUserDetailsUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> = _uiState
fun fetchUser(userId: String) {
viewModelScope.launch {
_uiState.value = UserUiState.Loading
when (val result = getUserDetailsUseCase(userId)) {
is Either.Left -> {
val message = when (result.value) {
is Failure.NetworkError -> "Network connection issue."
is Failure.NotFound -> "User not found."
is Failure.UnknownError -> "Something went wrong."
}
_uiState.value = UserUiState.Error(message)
}
is Either.Right -> {
_uiState.value = UserUiState.Success(result.value)
}
}
}
}
}
Step 6: Jetpack Compose UI — Reactive & Clean
@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.fetchUser("1")
}
when (uiState) {
is UserUiState.Loading -> LoadingView()
is UserUiState.Success -> SuccessView((uiState as UserUiState.Success).user)
is UserUiState.Error -> ErrorView((uiState as UserUiState.Error).message) {
viewModel.fetchUser("1")
}
}
}
Reusable Sub-Composables
@Composable
fun LoadingView() = Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
@Composable
fun SuccessView(user: User) = Column(
Modifier.fillMaxSize().padding(16.dp)
) {
Text("User Details", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(8.dp))
Text("Name: ${user.name}")
Text("Email: ${user.email}")
}
@Composable
fun ErrorView(message: String, onRetry: () -> Unit) = Column(
Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(message, color = Color.Red)
Spacer(Modifier.height(8.dp))
Button(onClick = onRetry) { Text("Retry") }
}
Step 7: Dependency Injection with Hilt
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
fun provideApiService(): ApiService {
return Retrofit.Builder()
.baseUrl("https://jsonplaceholder.typicode.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiService::class.java)
}
}
Step 8: Unit Testing with JUnit, Turbine & MockK
Add dependencies:
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
testImplementation("app.cash.turbine:turbine:1.1.0")
testImplementation("io.mockk:mockk:1.13.10")
Coroutine Test Rule
@OptIn(ExperimentalCoroutinesApi::class)
class CoroutineTestRule : TestWatcher() {
val dispatcher = StandardTestDispatcher()
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
ViewModel Tests
@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
@get:Rule val coroutineRule = CoroutineTestRule()
private lateinit var useCase: GetUserDetailsUseCase
private lateinit var viewModel: UserViewModel
@Before fun setup() {
useCase = mockk()
viewModel = UserViewModel(useCase)
}
@Test
fun `when fetchUser succeeds emit Loading then Success`() = runTest {
val mockUser = User("1", "Dharma", "dharma@example.com")
coEvery { useCase("1") } returns Either.Right(mockUser)
viewModel.fetchUser("1")
viewModel.uiState.test {
assert(awaitItem() is UserUiState.Loading)
val success = awaitItem() as UserUiState.Success
assertEquals("Dharma", success.user.name)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `when fetchUser fails emit Loading then Error`() = runTest {
coEvery { useCase("404") } returns Either.Left(Failure.NotFound)
viewModel.fetchUser("404")
viewModel.uiState.test {
assert(awaitItem() is UserUiState.Loading)
val error = awaitItem() as UserUiState.Error
assertTrue(error.message.contains("not found", true))
cancelAndIgnoreRemainingEvents()
}
}
}
Test Output
> Task :testDebugUnitTest
UserViewModelTest > when fetchUser succeeds emit Loading then Success PASSED
UserViewModelTest > when fetchUser fails emit Loading then Error PASSED
Remember:
Concept Description
------- -----------
Either Type-safe, functional error handling
No try/catch Clear data flow with predictable results
Clean Architecture Repository → UseCase → ViewModel → UI
Reactive UI Jetpack Compose observes StateFlow
Unit Tested Verify both success and failure paths
Composable Retry Clean, reusable error handling in UI
My Thoughts
Functional programming might seem abstract at first, but once you integrate tools like Arrow’s Either, you’ll notice:
- Cleaner architecture
- Safer code
- More predictable UI state management
- And simpler testing
In a world of asynchronous calls and complex data flows, Either is your best ally for reliability and clarity.
Connect: LinkedIn | GitHub | Website
📢 Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! Please leave a comment below. I’d love to hear from you! 👇
Happy coding! 💻
메타데이터
- post_id
- 2fc3ed297297
- slug
- the-functional-way-to-build-reliable-apps-arrows-either-2fc3ed297297
- url
- https://medium.com/@dharmakshetri/the-functional-way-to-build-reliable-apps-arrows-either-2fc3ed297297
- canonical_url
- https://medium.com/@dharmakshetri/the-functional-way-to-build-reliable-apps-arrows-either-2fc3ed297297
- author_url
- https://medium.com/@dharmakshetri
- status
- ok
- fetched_at
- 2026-07-16 19:00:48