1. Overview
In this tutorial, we’ll implement Railway Oriented Programming (ROP), a term coined by Scott Wlaschin, in Kotlin. ROP helps to write code in functional programming (FP) with validation, logging, network and service errors, and other side effects.
The happy path in ROP relates to the code that runs without exceptions and errors. The unhappy or failure path is when some exceptions and errors are handled by the developer. We’ll explain how to use ROP in FP to handle successful and failing paths.
2. What Is Railway Oriented Programming?
Let’s suppose we have a function that takes in customer details like name and email address from the user. Then it creates the Customer object to save into the database.
Let’s check what the happy path looks like:
val input = read()
val customer = parse(input)
saveCustomer(customer)
Following are the parse and saveCustomer functions.
fun parse(inp: List<String>): Customer {
return Customer(inp.get(0), inp.get(1))
}
fun saveCustomer(customer: Customer): Unit {
return println("Customer successfully saved." + customer)
}
Below are the validation functions that we call along the unhappy path after parsing input:
fun validateCustomerName(inpName: String): Boolean {
if (inpName.length < 10){
return false
} else {
return true
}
}
fun validateCustomerEmail(inpEmail: String): Boolean {
if (inpEmail.contains("@")) {
return true
} else {
return false
}
}
The unhappy path consists of handling failing validations in saving the customer object:
val customer = parse(read())
val validatedCustomerName = validateCustomervalidatedCustomerEmail_Name(customer.name)
val validatedCustomerEmail = validateCustomerEmail(customer.email)
if (validatedCustomerName && validatedCustomerEmail) {
save(customer)
} else if (!validatedCustomerName) {
println("Validation Error: invalid name, the name length must be 10 characters or more")
} else if (!validatedCustomerEmail) {
println("Validation Error: invalid email, the email must contain "@" symbol.")
}
Unhappy paths can build up quickly, for example, in the case of exception handling:
if (validatedCustomerName && validatedCustomerEmail) {
try {
save(customer)
} catch(pe: PersistenceException) {
println("Exception while saving the customer DAO into database.")
}
} else if (!validatedCustomerName) {
println("Validation Error: invalid name, the name length must be 20 characters or more")
} else if (!validatedCustomerEmail) {
println("Validation Error: invalid email, the email must contain "@" symbol.")
}
A lot of code looks like this. The developer starts with a simple feature. As he adds unhappy paths, the code loses the main intent in a series of nested if/else and try/catch statements.
As unhappy paths grow in number, this increases the likelihood of unexpected states. Then, we need unit tests to raise confidence in code by exhaustively testing both happy and unhappy paths.
ROP offers a functional approach to handling happy and unhappy paths.
3. Railway Oriented Programming in Kotlin
The main principle behind ROP is that, like a railway junction, every function returns a success or a failure Result type. In our example, parse, validateCustomerName, validateCustomerEmail and save(customer) have a Success and Failure outcome.
Let’s rewrite the save, error, parse, validateCustomerName and validateCustomerEmail functions:
private fun parse(inp: List<String>): Result<Customer> {
return Success(Customer(inp.get(0), inp.get(1)))
}
private fun validateCustomerName(customer: Customer): Result<Customer> {
if (customer.name.length < 10) {
return Failure("Name validation failed; name length must be greater than 10 characters")
} else {
return Success(customer)
}
}
private fun validateCustomerEmail(customer: Customer): Result<Customer> {
if (customer.emailAddress.contains("@")) {
return Success(customer)
} else {
return Failure("Email validation failed; email must contain the '@' symbol")
}
}
private fun saveCustomer(customer: Customer): Result<String> {
return Success("Customer successfully saved: " + customer)
}
private fun error(message: String): Failure<String> {
return Failure("Error: ${message}")
}
The functions now return the Result type defined as:
sealed class Result<T>
data class Success<T>(val value: T) : Result<T>()
data class Failure<T>(val errorMessage: String) : Result<T>()
infix fun <T, U> Result<T>.then(f: (T) -> Result<U>) =
when (this) {
is Success -> f(this.value)
is Failure -> Failure(this.errorMessage)
}
infix fun <T> Result<T>.otherwise(f: (String) -> Failure<T>) =
when (this) {
is Success -> Success(this.value)
is Failure -> f(this.errorMessage)
}
The Result type is a parameterized type with two sub-types: Success and Failure, which wrap the underlying result.
The infix then function defined on the Result object chains together the series of functions along the happy path:
Success(args.toList()) then ::parse then ::validateCustomerName
then ::validateCustomerEmail then ::saveCustomer
We apply the then function to the Result of the current function only if the previous function returned a Success.
The infix otherwise function executes the error function defined above in case of Failure:
Success(args.toList()) then ::parse then ::validateCustomerName
then ::validateCustomerEmail then ::saveCustomer otherwise ::error
otherwise calls the error function at any point the happy path fails and returns the error.
4. Conclusion
In this tutorial, we used ROP to show how FP can be used to build resilient, robust, and fault-tolerant applications. The Result type enables us to to avoid writing complex nested if/else and try/catch statements without compromising on error handling. The happy path is distinct and visible while we still error-handle the unhappy path.