Skip to content

Exception Handling

Available Exceptions as AppError

AppError is a sealed class hierarchy designed to represent various types of errors that can occur within the application. It extends Throwable, allowing it to be used in standard Kotlin error handling mechanisms.

The base AppError class takes a message, an optional cause (another Throwable), and an optional context (a lazy message provider).

Key subtypes include:

  • AppError.Composite: Represents a collection of multiple AppError instances.
  • AppError.Generic: For general errors not covered by more specific types.
  • AppError.Internal: Base class for errors originating from within the application’s internal logic or infrastructure.
    * ExternalService: Errors from external service integrations.
    * NotImplemented: For features or operations that are not yet implemented.
    * Implementation: Errors in the application’s own code logic.
    * Infrastructure: Errors related to underlying infrastructure (e.g., database, network).
    * IncompatibleState: When the application is in a state that’s incompatible with the requested operation.
    * InternalService: Errors from other internal services.
    * InternalTimeout: Timeouts when communicating with internal services.
  • AppError.Invocation: Base class for errors related to the invocation of an operation, often due to invalid input or state.
    * GeneralValidation: General validation failures.
    * ContextValidation: Errors when the operational context is invalid.
    * ArgumentValidation: Specific argument validation failures.
    * NullArgument: When a required argument is null.
    * NotFound: When a requested resource cannot be found.
    * Duplicate: When an attempt is made to create a resource that already exists.
  • AppError.Authorization: Base class for errors related to authorization and authentication.
    * NotAuthenticated: When an operation requires an authenticated user but none is present.
    * NotAuthorized: When the authenticated user does not have the necessary permissions.

Helper functions are available to convert standard exceptions into AppError instances:

  • Throwable.normalizeToAppError(): AppError: Converts common exceptions (like JsonConvertException, BadRequestException, SerializationException, IllegalArgumentException) into an AppError.GeneralValidation. Other Throwable types are wrapped in an AppError.Implementation.
  • Throwable.normalize(messageTarget: String, context: LazyMessage? = null): AppError: Similar to normalizeToAppError, but allows specifying a messageTarget for more specific error reporting, typically resulting in an AppError.ArgumentValidation for input-related exceptions.

Examples (id=exception-examples)

// Creating a specific AppError
val notFoundError = AppError.NotFound("UserResource", context = { "UserId: 123" })

// Using a helper to create an AppError
val todoError = TODOResult<Unit>("Implement user deletion") // Returns Result.failure(AppError.NotImplemented(...))

// Normalizing an existing exception
try {
    // some operation that might throw IllegalArgumentException
    throw IllegalArgumentException("Invalid age")
} catch (e: Throwable) {
    val appError = e.normalize("age")
    // appError will be AppError.ArgumentValidation("age", "Invalid age", null, e)
}

try {
    // some operation that might throw an unknown exception
    throw RuntimeException("Something unexpected happened")
} catch (e: Throwable) {
    val appError = e.normalizeToAppError()
    // appError will be AppError.Implementation("Unexpected Error: Something unexpected happened", null, e)
}

Normalizing Throwable instances to AppError

The common-module provides extension functions to convert standard Throwable instances into more specific AppError types. This is particularly useful for handling exceptions caught from libraries or external systems and translating them into the application’s error domain.

Throwable.normalizeToAppError(): AppError

This is the primary function for converting a generic Throwable into an AppError. Its behavior is as follows:

  • If the Throwable is an instance of:
    * JsonConvertException
    * BadRequestException
    * SerializationException
    * IllegalArgumentException
    It is converted into an AppError.GeneralValidation with the message “Unexpected InputError: [original message]”. A warning is logged.
  • For any other type of Throwable, it is converted into an AppError.Implementation with the message “Unexpected Error: [original message]”. An error is logged.

This function helps standardize error handling by ensuring that caught exceptions are mapped to the AppError hierarchy.

Example:

fun processInput(input: String): Result<Unit> {
    return try {
        if (input.isBlank()) {
            throw IllegalArgumentException("Input cannot be blank")
        }
        // ... processing logic ...
        Result.success(Unit)
    } catch (e: Throwable) {
        Result.failure(e.normalizeToAppError()) // Converts IllegalArgumentException to AppError.GeneralValidation
    }
}

fun performOperation(): Result<Unit> {
    return try {
        // ... some operation that might throw an unexpected exception ...
        throw RuntimeException("A critical system component failed")
    } catch (e: Throwable) {
        Result.failure(e.normalizeToAppError()) // Converts RuntimeException to AppError.Implementation
    }
}

val blankInputResult = processInput("")
// blankInputResult will be Result.failure(AppError.GeneralValidation("Unexpected InputError: Input cannot be blank", ...))

val operationFailedResult = performOperation()
// operationFailedResult will be Result.failure(AppError.Implementation("Unexpected Error: A critical system component failed", ...))

Other Normalization Functions

  • Throwable.normalize(messageTarget: String, context: LazyMessage? = null): AppError:
    This function offers more fine-grained control.
    * If the Throwable is already an AppError, it’s returned as is.
    * For JsonConvertException, BadRequestException, SerializationException, or IllegalArgumentException, it’s converted to AppError.ArgumentValidation(messageTarget, this.message, context, this).
    * Other Throwable types are converted to AppError.Implementation("Unexpected Error for: $messageTarget", context, this).
    This is useful when you can pinpoint the source or target of the error (e.g., a specific input field).
  • Throwable.normalizeExceptionToAppError(argumentName: String, validationMessage: String, context: LazyMessage? = null): Throwable (Deprecated):
    This function is deprecated. Developers should migrate to using normalizeToAppError() for general cases or normalize(messageTarget, ...) or direct construction of AppError subtypes (like AppError.ArgumentValidation) for more specific scenarios.

It is generally recommended to use normalizeToAppError() for broad exception catching and normalize(messageTarget, ...) or direct AppError instantiation when more context about the error is available.

Collecting Validations

When multiple validation errors can occur, it’s useful to collect all of them rather than failing on the first one. AppError.Composite is designed for this purpose, allowing you to bundle multiple AppError instances into a single error.

Several extension functions on Iterable<Result<T>> facilitate the collection and handling of multiple results:

  • collectAll(): Result<List<T>>: Transforms an iterable of Result objects into a single Result containing a list of all successful values. If any of the input Result objects is a failure, collectAll returns the first encountered failure.
  • collectAny(): Result<List<T>>: Transforms an iterable of Result objects into a single Result containing a list of successful values. It collects all successes, even if there are failures. This function always returns a Result.success containing a list of the values from the successful results (the list might be empty if all results were failures).
  • sieve(): Pair<List<T>, List<Throwable>>: Separates an iterable of Result objects into a pair of lists: one for successful values and one for the Throwable instances from failed results.

Examples (id=composite-examples)

val result1: Result<Int> = Result.success(1)
val result2: Result<Int> = Result.failure(AppError.ArgumentValidation("inputB", "cannot be zero"))
val result3: Result<Int> = Result.success(3)
val result4: Result<Int> = Result.failure(AppError.GeneralValidation("Overall check failed"))

val allResults = listOf(result1, result2, result3, result4)

// Collect all successes, fail fast
val collectedAll = allResults.collectAll() // Result.failure(AppError.ArgumentValidation("inputB", "cannot be zero"))

// Collect any successes
val collectedAny = allResults.collectAny() // Result.success(listOf(1, 3))

// Sieve successes and failures
val (successes, failures) = allResults.sieve()
// successes will be listOf(1, 3)
// failures will be listOf(AppError.ArgumentValidation(...), AppError.GeneralValidation(...))

// If you need to create a composite error from the failures:
if (failures.isNotEmpty()) {
    val compositeError = AppError.Composite(
        message = "Multiple validation errors occurred",
        causes = failures.map { it.normalize("validation") } // Normalize all throwables to AppError
    )
    // Handle compositeError
}

Functional Error Propagation: Result

The Result class is a core component for functional error handling, representing either a successful outcome (Result.success(value)) or a failure (Result.failure(throwable)). A suite of extension functions is provided to work with Result objects in a functional and concise way.

Key Result extension functions:

  • TODOResult<T>(msg: String = "Not Implemented"): Result<T>: A utility to quickly create a Result.failure with an AppError.NotImplemented.
  • unitify(): Result<Unit>: Converts a Result<*> to a Result<Unit>, preserving the success/failure status but discarding the original value.
  • flatMap<T, R>(tr: (T) -> Result<R>): Result<R>: If the Result is a success, applies the transformation function tr (which itself returns a Result) to the value. If it’s a failure, it propagates the failure.
  • mapCatching<T, R>(tr: (T) -> R): Result<R>: If the Result is a success, applies the transformation function tr to the value and wraps the operation in a runCatching block. This catches any exceptions thrown by tr and converts them into a Result.failure.
  • flatten<T>(): Result<T>: Converts a Result<Result<T>> into a Result<T>.
  • asyncFlatMap<T, R>(tr: suspend (T) -> Result<R>): Result<R>: The asynchronous version of flatMap, for use with suspending transformation functions.
  • asyncMapCatching<T, R>(tr: suspend (T) -> R): Result<R>: The asynchronous version of mapCatching, for use with suspending transformation functions that might throw exceptions.
  • notNull<T>(err: AppError?): Result<T>: Converts a Result<T?> to Result<T>. If the original result was successful but contained a null value, it transforms it into a Result.failure using the provided err or a default AppError.NullArgument.
  • orElse(other: Result<T>): Result<T>: If the primary Result is a failure, this function returns the other Result. If both are failures, it returns a Result.failure containing an AppError.Composite that includes both original errors.

Examples (id=result-examples)

fun processItem(id: String): Result<String> {
    if (id.isBlank()) {
        return AppError.ArgumentValidation("id", "cannot be blank").let { Result.failure(it) }
    }
    return Result.success("Processed: $id")
}

fun furtherProcessing(data: String): Result<Int> {
    if (data.length < 10) {
        return AppError.GeneralValidation("Data too short for further processing").let { Result.failure(it) }
    }
    return Result.success(data.length)
}

val validResult: Result<String> = processItem("item123")
val invalidResult: Result<String> = processItem("")

// flatMap
val flatMappedSuccess = validResult.flatMap { furtherProcessing(it) } // Result.success(16) (length of "Processed: item123")
val flatMappedFailure = invalidResult.flatMap { furtherProcessing(it) } // Result.failure(AppError.ArgumentValidation("id", "cannot be blank"))
val flatMappedFailure2 = validResult.flatMap { furtherProcessing(it.substring(0,5)) } // Result.failure(AppError.GeneralValidation("Data too short..."))

// mapCatching
val mappedSuccess = validResult.mapCatching { it.length } // Result.success(16)
val mappedFailure = invalidResult.mapCatching { it.length } // Result.failure(AppError.ArgumentValidation(...))

// Example of mapCatching with an exception in the transform
val exceptionInMap = Result.success("short").mapCatching { 
    if(it.length < 10) throw IllegalStateException("Too short!")
    it.length
} // Result.failure(IllegalStateException("Too short!")) -> this will be wrapped by runCatching

// unitify
val unitified = validResult.unitify() // Result.success(Unit)

// flatten
val nestedResult: Result<Result<Int>> = validResult.map { furtherProcessing(it) }
val flattenedResult = nestedResult.flatten() // Same as flatMappedSuccess

// notNull
val nullableSuccess: Result<String?> = Result.success("hello")
val definitelyNotNull: Result<String> = nullableSuccess.notNull(AppError.Generic("Expected non-null string")) // Result.success("hello")

val nullSuccess: Result<String?> = Result.success(null)
val nowFailure: Result<String> = nullSuccess.notNull(AppError.Generic("Expected non-null string")) // Result.failure(AppError.Generic("Expected non-null string"))
val nowFailureDefault: Result<String> = nullSuccess.notNull(null) // Result.failure(AppError.NullArgument("value"))

// orElse
val primaryFailure = AppError.NotFound("ResourceA").let { Result.failure<String>(it) }
val secondarySuccess = Result.success("FallbackValue")
val secondaryFailure = AppError.NotImplemented("FallbackOperation").let { Result.failure<String>(it) }

val useFallback = primaryFailure.orElse(secondarySuccess) // Result.success("FallbackValue")
val compositeFailure = primaryFailure.orElse(secondaryFailure)
// compositeFailure will be Result.failure(AppError.Composite(...)) containing both NotFound and NotImplemented errors.

// TODOResult
val todo = TODOResult<Int>("Calculate complex value") // Result.failure(AppError.NotImplemented("Calculate complex value", ...))

// Async example (conceptual)
suspend fun asyncFetch(id: String): Result<String> = Result.success("data for $id")
suspend fun asyncProcess(data: String): Result<Int> = Result.success(data.length)

val asyncExample = Result.success("id1")
    .asyncFlatMap { asyncFetch(it) }        // Result.success("data for id1")
    .asyncMapCatching { asyncProcess(it) } // Result.success(Result.success(12)) -> needs flatten or use asyncFlatMap for asyncProcess if it returns Result
    // Corrected async chain:
    .asyncFlatMap { asyncFetch(it) }        // Result.success("data for id1")
    .asyncFlatMap { asyncProcess(it) }    // Result.success(12)

Copyright: © Arda Systems 2025, All rights reserved

Comments