Save 37% off PRO during our Black Friday Sale! »

Railway Oriented Programming with Kotlin

Railway Oriented Programming with Kotlin

Many examples in Kotlin assume that you are always on the "happy path". But real-world applications must deal with a lot of types of errors: validation, network, and others.

So, how do you handle all of this?

5cefbec62d834db21a3823a8e66d59d8?s=128

Marcello Galhardo
PRO

June 05, 2019
Tweet

Transcript

  1. Marcello Galhardo marcello.galhardo@gmail.com A functional approach to error handling Railway

    Oriented Programming with Kotlin.
  2. This talk is not about code… …it is about a

    paradigm.
  3. “As a user I want to update my name and

    email address.”
  4. Button clicks send Action to ViewModel

  5. Button clicks send Action to ViewModel ViewModel invokes UseCase to

    validate and canonicalise Action data
  6. Button clicks send Action to ViewModel ViewModel invokes UseCase to

    validate and canonicalise Action data UseCase ask Repository to update user data
  7. Button clicks send Action to ViewModel ViewModel invokes UseCase to

    validate and canonicalise Action data UseCase ask Repository to update user data Returns result to UI
  8. Action.kt sealed class Action { data class UpdateUser( val id:

    UserId, val name: String, val email: String ) : Action() }
  9. Action.kt sealed class Action { data class UpdateUser( val id:

    UserId, val name: String, val email: String ) : Action() }
  10. Action.kt sealed class Action { data class UpdateUser( val id:

    UserId, val name: String, val email: String ) : Action() }
  11. View.kt // Action dispatcher in a View okButton.setOnClickListener { val

    name = nameTextField.value val email = emailTextField.value val action = Action.UpdateUser(userId, name, email) viewModel.dispatch(action) }
  12. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { validate(action) val

    canonicalizedEmail = canonicalizeEmail(action) val user = repository.getUserById(id).copy( name = action.name, email = canonicalizedEmail ) repository.update(user) return Result.success(user) }
  13. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { validate(action) val

    canonicalizedEmail = canonicalizeEmail(action) val user = repository.getUserById(id).copy( name = action.name, email = canonicalizedEmail ) repository.update(user) return Result.success(user) }
  14. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { validate(action) val

    canonicalizedEmail = canonicalizeEmail(action) val user = repository.getUserById(id).copy( name = action.name, email = canonicalizedEmail ) repository.update(user) return Result.success(user) }
  15. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { validate(action) val

    canonicalizedEmail = canonicalizeEmail(action) val user = repository.getUserById(id).copy( name = action.name, email = canonicalizedEmail ) repository.update(user) return Result.success(user) }
  16. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { validate(action) val

    canonicalizedEmail = canonicalizeEmail(action) val user = repository.getUserById(id).copy( name = action.name, email = canonicalizedEmail ) repository.update(user) return Result.success(user) }
  17. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { validate(action) val

    canonicalizedEmail = canonicalizeEmail(action) val user = repository.getUserById(id).copy( name = action.name, email = canonicalizedEmail ) repository.update(user) return Result.success(user) }
  18. Functions // ValidateUser.kt fun validate(action: Action.UpdateUser): Boolean // CanonicalizeEmail.kt fun

    canonicalizeEmail(action: Action.UpdateUser): String // UserRepository.kt @Throws(Exception::class) fun Repository.getUserById(id: UserId): User? @Throws(Exception::class) fun Repository.update(user: User)
  19. Functions // ValidateUser.kt fun validate(action: Action.UpdateUser): Boolean // CanonicalizeEmail.kt fun

    canonicalizeEmail(action: Action.UpdateUser): String // UserRepository.kt @Throws(Exception::class) fun Repository.getUserById(id: UserId): User? @Throws(Exception::class) fun Repository.update(user: User)
  20. Functions // ValidateUser.kt fun validate(action: Action.UpdateUser): Boolean // CanonicalizeEmail.kt fun

    canonicalizeEmail(action: Action.UpdateUser): String // UserRepository.kt @Throws(Exception::class) fun Repository.getUserById(id: UserId): User? @Throws(Exception::class) fun Repository.update(user: User)
  21. Functions // ValidateUser.kt fun validate(action: Action.UpdateUser): Boolean // CanonicalizeEmail.kt fun

    canonicalizeEmail(action: Action.UpdateUser): String // UserRepository.kt @Throws(Exception::class) fun Repository.getUserById(id: UserId): User? @Throws(Exception::class) fun Repository.update(user: User)
  22. What do you do when something goes wrong?

  23. “As a user I want to update my name and

    email address… …and see error messages when something goes wrong!"
  24. “As a user I want to update my name and

    email address… …and see error messages when something goes wrong!"
  25. Button clicks send Action to ViewModel ViewModel invokes UseCase to

    validate and canonicalise Action data UseCase ask Repository to update user data Returns result to UI
  26. Button clicks send Action to ViewModel ViewModel invokes UseCase to

    validate and canonicalise Action data UseCase ask Repository to update user data Returns result to UI
  27. Name is blank or email not valid Button clicks send

    Action to ViewModel ViewModel invokes UseCase to validate and canonicalise Action data UseCase ask Repository to update user data Returns result to UI
  28. Name is blank or email not valid Button clicks send

    Action to ViewModel ViewModel invokes UseCase to validate and canonicalise Action data UseCase ask Repository to update user data Returns result to UI
  29. Name is blank or email not valid User not found

    Button clicks send Action to ViewModel ViewModel invokes UseCase to validate and canonicalise Action data UseCase ask Repository to update user data Returns result to UI
  30. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { validate(action) val

    canonicalizedEmail = canonicalizeEmail(action) val user = repository.getUserById(id).copy( name = action.name, email = canonicalizedEmail ) repository.update(user) return Result.success(user) }
  31. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { validate(action) val

    canonicalizedEmail = canonicalizeEmail(action) val user = repository.getUserById(id).copy( name = action.name, email = canonicalizedEmail ) repository.update(user) return Result.success(user) }
  32. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { val isValid

    = validate(action) if (!isValid) { return Result.error(UserNotValidException()) } val canonicalizedEmail = canonicalizeEmail(action) val user = repository.getUserById(id).copy( name = action.name, email = canonicalizedEmail ) repository.update(user) return Result.success(user) }
  33. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { val isValid

    = validate(action) if (!isValid) { return Result.error(UserNotValidException()) } val canonicalizedEmail = canonicalizeEmail(action) val user = repository.getUserById(id).copy( name = action.name, email = canonicalizedEmail ) repository.update(user) return Result.success(user) }
  34. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { val isValid

    = validate(action) if (!isValid) { return Result.error(UserNotValidException()) } val canonicalizedEmail = canonicalizeEmail(action) val user = repository.getUserById(id).copy( name = action.name, email = canonicalizedEmail ) repository.update(user) return Result.success(user) }
  35. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { val isValid

    = validate(action) if (!isValid) { return Result.error(UserNotValidException()) } val canonicalizedEmail = canonicalizeEmail(action) var user = null try { user = repository.getUserById(id).copy( name = action.name, email = canonicalizedEmail ) repository.update(user) } catch (error: Exception) { return Result.error(error) } return Result.success(user) }
  36. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { val isValid

    = validate(action) if (!isValid) { return Result.error(UserNotValidException()) } val canonicalizedEmail = canonicalizeEmail(action) var user = null try { user = repository.getUserById(id).copy( name = action.name, email = canonicalizedEmail ) repository.update(user) } catch (error: Exception) { return Result.error(error) } return Result.success(user) }
  37. 7 lines added, more 140%

  38. • Each use case will be equivalent to a single

    function • The function will return a sum type with two cases: Success or Failure • The use case function will be built from a series of smaller functions, each representing one step in a data flow • The errors from each step will be combined into a single failure
  39. • Each use case will be equivalent to a single

    function • The function will return a sum type with two cases: Success, Failure • The use case function will be built from a series of smaller functions, each representing one step in a data flow • The errors from each step will be combined into a single failure
  40. • Each use case will be equivalent to a single

    function • The function will return a sum type with two cases: Success, Failure • The use case function will be built from a series of smaller functions, each representing one step in a data flow • The errors from each step will be combined into a single failure
  41. • Each use case will be equivalent to a single

    function • The function will return a sum type with two cases: Success, Failure • The use case function will be built from a series of smaller functions, each representing one step in a data flow • Each step will be combined into a single result
  42. The following Result class and methods are simplified/custom versions for

    easy understanding
  43. How do you return a sum type with two cases?

  44. bit.do/kotlin-std-result sealed class Result<Value> { data class Success( val value:

    Value ): Result<Value> data class Failure( val error: Exception ): Result<Nothing> }
  45. bit.do/kotlin-std-result sealed class Result<Value> { data class Success( val value:

    Value ): Result<Value> data class Failure( val error: Exception ): Result<Nothing> }
  46. bit.do/kotlin-std-result sealed class Result<Value> { data class Success( val value:

    Value ): Result<Value> data class Failure( val error: Exception ): Result<Nothing> }
  47. bit.do/kotlin-std-result sealed class Result<Value> { data class Success( val value:

    Value ): Result<Value> data class Failure( val error: String ): Result<Nothing> }
  48. How to combine each step into a single result?

  49. public inline fun <R, T> Result<T>.map(transform: (value: T) -> R):

    Result<R> { return when { isSuccess -> Result.success(transform(value as T)) else -> Result(value) } } bit.do/kotlin-std-result
  50. public inline fun <R, T> Result<T>.map(transform: (value: T) -> R):

    Result<R> { return when { isSuccess -> Result.success(transform(value as T)) else -> Result(value) } } bit.do/kotlin-std-result
  51. public inline fun <R, T> Result<T>.map(transform: (value: T) -> R):

    Result<R> { return when { isSuccess -> Result.success(transform(value as T)) else -> Result(value) } } bit.do/kotlin-std-result
  52. public inline fun <R, T> Result<T>.map(transform: (value: T) -> R):

    Result<R> { return when { isSuccess -> Result.success(transform(value as T)) else -> Result(value) } } bit.do/kotlin-std-result
  53. public inline fun <R, T> Result<T>.flatMap( transform: (value: T) ->

    Result<R> ): Result<R> { return when { isSuccess -> transform(value as T) else -> Result(value) } } bit.do/kotlin-std-result
  54. public inline fun <R, T> Result<T>.flatMap( transform: (value: T) ->

    Result<R> ): Result<R> { return when { isSuccess -> transform(value as T) else -> Result(value) } } bit.do/kotlin-std-result
  55. public inline fun <R, T> Result<T>.flatMap( transform: (value: T) ->

    Result<R> ): Result<R> { return when { isSuccess -> transform(value as T) else -> Result(value) } } bit.do/kotlin-std-result
  56. public inline fun <R, T> Result<T>.flatMap( transform: (value: T) ->

    Result<R> ): Result<R> { return when { isSuccess -> transform(value as T) else -> Result(value) } } bit.do/kotlin-std-result
  57. ViewModel invokes UseCase to validate and canonicalise Action data

  58. ViewModel invokes UseCase to validate and canonicalise Action data Failure

    Success
  59. ViewModel invokes UseCase to validate and canonicalise Action data Failure

    UseCase ask Repository to update user data ? Success
  60. Success ViewModel invokes UseCase to validate and canonicalise Action data

    UseCase ask Repository to update user data Failure Success Failure ?
  61. Success ViewModel invokes UseCase to validate and canonicalise Action data

    Failure UseCase ask Repository to update user data Failure Success
  62. GetUserById.kt fun getUserById(input: Action.UpdateUser): Result<User> { val user = repository.getUserByIdOrNull(input.id)

    return when { user == null -> Result.failure("User not found.") else -> Result.success(user) } }
  63. NameNotBlank.kt fun nameNotBlank(user: User) = when { user.name == ""

    -> Result.failure("Name must not be blank.") else -> Result.success(user) }
  64. NameIsBelowMaxLength.kt fun nameIsBelowMaxLength(user: User) = when { user.name.length > 50

    -> Result.failure("Name must not be longer than 50 chars.") else -> Result.success(user) }
  65. EmailNotBlank.kt fun emailNotBlank(user: User) = when { user.email == ""

    -> Result.failure("Email must not be blank.") else -> Result.success(user) }
  66. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { return getUserById(action.id)

    .map { user -> user.copy(name = action.name) } .map { user -> user.copy(email = action.email) } .map(::nameNotBlank) .map(::nameIsBelowMaxLength) .map(::emailNotBlank) .map(::canonicalizeEmail) .map(::saveUser) }
  67. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { return getUserById(action.id)

    .map { user -> user.copy(name = action.name) } .map { user -> user.copy(email = action.email) } .map(::nameNotBlank) .map(::nameIsBelowMaxLength) .map(::emailNotBlank) .map(::canonicalizeEmail) .map(::saveUser) }
  68. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { return getUserById(action.id)

    .map { user -> user.copy(name = action.name) } .map { user -> user.copy(email = action.email) } .map(::nameNotBlank) .map(::nameIsBelowMaxLength) .map(::emailNotBlank) .map(::canonicalizeEmail) .map(::saveUser) }
  69. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { return getUserById(action.id)

    .map { user -> user.copy(name = action.name) } .map { user -> user.copy(email = action.email) } .flatMap(::nameNotBlank) .flatMap(::nameIsBelowMaxLength) .flatMap(::emailNotBlank) .map(::canonicalizeEmail) .map(::saveUser) }
  70. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { return getUserById(action.id)

    .map { user -> user.copy(name = action.name) } .map { user -> user.copy(email = action.email) } .flatMap(::nameNotBlank) .flatMap(::nameIsBelowMaxLength) .flatMap(::emailNotBlank) .flatMap(::canonicalizeEmail) .map(::saveUser) }
  71. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { return getUserById(action.id)

    .map { user -> user.copy(name = action.name) } .map { user -> user.copy(email = action.email) } .flatMap(::nameNotBlank) .flatMap(::nameIsBelowMaxLength) .flatMap(::emailNotBlank) .flatMap(::canonicalizeEmail) .flatMap(::saveUser) }
  72. UpdateUser.kt fun updateUser( action: Action.UpdateUser ): Result<User> { return getUserById(action.id)

    .map { user -> user.copy(name = action.name) } .map { user -> user.copy(email = action.email) } .flatMap(::nameNotBlank) .flatMap(::nameIsBelowMaxLength) .flatMap(::emailNotBlank) .flatMap(::canonicalizeEmail) .flatMap(::saveUser) }
  73. ViewModel.kt fun dispatch(action: Action) { useCase.invoke() .onSuccess { // Do

    something cool! }.onFailure { // Ops, something went wrong… } }
  74. ViewModel.kt fun dispatch(action: Action) { updateUser(action) .onSuccess { // Do

    something cool! }.onFailure { // Ops, something went wrong… } }
  75. ViewModel.kt fun dispatch(action: Action) { updateUser(action) .onSuccess { value ->

    // Do something cool! }.onFailure { // Ops, something went wrong… } }
  76. ViewModel.kt fun dispatch(action: Action) { updateUser(action) .onSuccess { value ->

    // Do something cool! }.onFailure { error -> // Ops, something went wrong… } }
  77. Recap

  78. • A strategy for handling errors in a functional way

    • Design for errors: unhappy paths are requirements too • Do not use Exception handling for Control Flow. “Do or do not, there is no try”
  79. • A strategy for handling errors in a functional way

    • Design for errors: unhappy paths are requirements too • Do not use Exception handling for Control Flow. “Do or do not, there is no try”
  80. • A strategy for handling errors in a functional way

    • Design for errors: unhappy paths are requirements too • Do not use Exception handling for Control Flow “Do or do not, there is no try”
  81. • A strategy for handling errors in a functional way

    • Design for errors: unhappy paths are requirements too • Do not use Exception handling for Control Flow “Do or do not, there is no try.”
  82. Marcello Galhardo marcello.galhardo@gmail.com Any questions? Thank you.