How to keep use cases and entity layer in Functional Clean Architecture pure when having database read/write interactions?

Viewed 483

Intro

I've been diving into functional programming in the last few months, and since I'm really intrigued by the Kotlin language, I've been using the Arrow library to toy around with a few things.

A few weeks ago, I've been researching for a guest lecture at university on Clean Architecture and while doing so, I stumbled upon this great blog Post by Mark Seemann, describing how using functional programming automatically leads to Clean Architecture (or with a language like Haskell may the compiler may even enforce Clean Architecture).

That inspired me to come up with a draft (checkout and build of the repo should be a breeze, if you're interested) of a restaurant reservation Software (staying true to Mark Seemann's domain ;) ). However, I'm not entirely sure if the use case layer in this draft can be called pure, and I wanted some feedback from people with more experience and knowledge with FP than myself.

Entity layer

A basic use case is trying to create a new reservation for a certain number of seats in our restaurant. I've modelled the entity layer for that the following way:

fun reservationPossible(
    requestedSeats: Int,
    reservedSeats: Int,
    capacity: Int
): Either<RequestedTooManySeats, ReservationPossible> =
    if (reservedSeats + requestedSeats <= capacity) {
        ReservationPossible(requestedSeats + reservedSeats).right()
    } else {
        RequestedTooManySeats.left()
    }

const val CAPACITY = 10

object RequestedTooManySeats : Error()
sealed class Error

data class ReservationPossible(val newNumberOfReservedSeats: Int)

Nothing too fancy going on here, just a function checking if a reservation with a certain number of requested seats would be possible or not. Some Error and Result classes are also down below as well as a (for the sake of simplicity) const val to model the capacity of our restaurant.

Frameworks/Adapters #1

To make sense in a real world application, some data would also need to be stored in and loaded from some sort of persistence layer. So, in the outermost layer of our onion architecture, there would be a Database which I mocked for this example:

suspend fun getCurrentlyReservedSeats(): Either<ReadError, Int> {
    delay(1) // ... get stuff from db
    return 4.right()
}

suspend fun saveReservation(value: String, reservationPossible: ReservationPossible): Either<WriteError, Long> {
    delay(1) // ... writing something to db
    return 42L.right() // newRecordId
}

abstract class DbError : Error()
object ReadError : DbError()
object WriteError : DbError()

Again, not too much going on here... Just stubs for Database read/write ops. Note however, that (by convention proposed by Arrow) these functions are marked with the suspend modifier as impure functions.

Use Case

So now for the use case, which basically describes our application flow:

  • get number of currently reserved seats from DB
  • check if the requested number of seats is still available
  • if so, persist the new reservation
  • and return the newly created reservation id

which is translated to code in the reservationUseCase function:

data class UseCaseData(
    val requestedSeats: Int,
    val reservationName: String,
    val getCurrentlyReservedSeats: suspend () -> Either<ReadError, Int>,
    val writeVal: suspend (String, ReservationPossible) -> Either<WriteError, Long>,
)

fun reservationUseCase(data: UseCaseData): suspend () -> Either<Error, UseCaseResultData> = {
    data.getCurrentlyReservedSeats()
        .flatMap { reservationPossible(data.requestedSeats, it, CAPACITY) }
        .flatMap { data.writeVal(data.reservationName, it) }
        .flatMap { UseCaseResultData(it).right() }
}

data class UseCaseResultData(val newRecordId: Long)

Here is the point, where it gets interesting: This function takes some UseCaseData as input and returns a suspend function to be executed at the program entry like this:

suspend fun main() {
    reservationUseCase(
        UseCaseData(
            requestedSeats = 5,
            reservationName = "John Dorian",
            ::getCurrentlyReservedSeats,
            ::saveReservation,
        )
    ).invoke().fold(
        ifLeft = { throw Exception(it.toString()) },
        ifRight = { println(it.newRecordId) },
    )
}

So now my questions are:

  • Can the reservationUseCase function itself be considered pure? I've read some blog post (taking F# as example language, however) suggesting that pure functions which receive impure functions as parameters could be pure, but cannot be guaranteed to be pure. reservationUseCase in this example clearly does receive impure functions with the UseCaseData.
  • If it can't be considered pure, how could one write a pure use case like the one described above in Kotlin and Arrow?
1 Answers

As you already assume, strictly speaking, reservationUseCase is not a pure function.

The only way I see how you could make it a pure function is to pass all the needed data directly instead of a function which provides access to that data but I doubt that this makes your code finally more clean or nicer to read.

This would lead to the conclusion that use case functions which orchestrate "workflows" can rarely be pure as almost always some interaction with some kind of repository is needed.

If you want some core logic to be pure you would have to extract those into functions which again only accept and return pure data.

Related