Result and Error
A core principle of the NextNonce architecture is robust and predictable error handling. Instead of relying on traditional exceptions for control flow, the application employs a type-safe, explicit error propagation system inspired by Rust. This ensures that any operation that can fail must be handled explicitly, preventing unexpected runtime crashes and making the data flow easier to reason about.
The DataError
Type
At the heart of this system is the DataError
sealed interface. This type represents all possible, known errors that can occur within the data layer of the application. By defining errors in a structured hierarchy, we can handle them with precision.
DataError
is split into two categories: Remote
for network-related issues and Local
for on-device problems.
DataError.kt
package com.nextnonce.app.core.domain
sealed interface DataError : Error {
/**
* Represents remote data errors (network, server, etc.).
*/
enum class Remote : DataError {
NO_INTERNET,
SERIALIZATION,
UNAUTHORIZED,
FORBIDDEN,
NOT_FOUND,
// ... and other remote errors
}
/**
* Represents local data errors (disk, session, etc.).
*/
enum class Local : DataError {
DISK_FULL,
NO_SESSION,
UNKNOWN
}
}
The Custom Result
Wrapper
All operations that can fail (e.g., network requests, database queries) do not throw exceptions. Instead, they return a custom Result
object. This is a generic sealed interface that encapsulates either a successful outcome or a failure.
This custom implementation is preferred over the standard Kotlin Result
because it allows for a custom Error
type, enabling the use of our structured DataError
system.
Result.kt
package com.nextnonce.app.core.domain
sealed interface Result<out D, out E : Error> {
data class Success<D>(val data: D) : Result<D, Nothing>
data class Error<E : com.nextnonce.app.core.domain.Error>(val error: E) : Result<Nothing, E>
}
// Helper extension functions like .map, .onSuccess, .onError are also provided
Consistent Propagation Through Layers
The Result
type is used as the return type for failable functions throughout all layers of the architecture, creating a clear and predictable data flow.
- Data Source Layer: The lowest layer initiates the pattern.
// RemotePortfolioDataSource.kt
interface RemotePortfolioDataSource {
suspend fun getPortfolios(): Result<List<PortfolioDto>, DataError.Remote>
// ...
}
-
Repository Layer: The repository consumes the
Result
from the data source. It uses awhen
expression, which is exhaustive, forcing the developer to handle both theSuccess
andError
cases.// PortfolioRepositoryImpl.kt override suspend fun getWalletById(walletId: String): Result<WalletModel, DataError> { return when (val result = remoteDataSource.getWallet(walletId)) { is Result.Success -> Result.Success(result.data.toWalletModel()) // Map DTO to Domain Model is Result.Error -> result // Pass the error up the chain } }
This pattern continues up through the UseCase
layer to the ViewModel
, ensuring that any error is explicitly passed and handled at each step.
Displaying Errors in the UI
The final step is to translate these structured DataError
types into user-friendly messages. This is accomplished with a simple extension function, toUIText()
, which maps each specific error case to a StringResource
.
This decouples the presentation layer from the underlying error logic. The UI doesn't need to know what DataError.Remote.FORBIDDEN
means; it only needs to display the corresponding text.
// DataErrorToString.kt
fun DataError.toUIText(): StringResource {
val stringRes = when (this) {
is DataError.Local -> when (this) {
DataError.Local.NO_SESSION -> Res.string.error_no_session
// ...
}
is DataError.Remote -> when (this) {
DataError.Remote.NO_INTERNET -> Res.string.error_no_internet
DataError.Remote.EMAIL_ALREADY_REGISTERED -> Res.string.error_account_exists
// ...
}
}
return stringRes
}
In the ViewModel
, this function is called within the error handling block to set a user-readable error message in the UI state, completing the full, type-safe error handling lifecycle.