Three ways to do functional programming when working with the Play Framework.
Use functions
This one seems obvious, but Play Framework has a lot of influence from Java so tends to favour object composition. To do functional programming, pass a function as a dependency instead of passing an object.
In the example below, we're implementing a UserService
class to aggregate everything needed to display the user's profile on a social media site. The UserService
currently depends on two traits, and uses a method from each.
trait UserDB {
def get(id: UserId): User
}
trait TimelineDB {
def get(id: TimelineId): Timeline
}
class UserService(userDB: UserDB,
timelineDB: TimelineDB) {
def getProfile(id: UserId): UserProfile = {
val user = userDB.getUser(id)
val timeline = timelineDB.get(user.timelineId)
UserProfile(user, timeline)
}
}
We can change our implementation of the UserService
to rely on functions instead of the traits.
class UserService(
getUser: UserId => User,
getTimeline: TimelineId => Timeline) {
def getProfile(id: UserId): UserProfile = {
val user = getUser(id)
val timeline = getTimeline(user.timelineId)
UserProfile(user, timeline)
}
}
Testing this class becomes easier. Switching out implementations does too, as the class no longer relies on another. It is just functions. Your tests can use a function like this:
val getUser = (id: UserId) => User(id, "Alice")
Or even a function like this, to test error handling.
val getUser = (u: UserId) =>
throw new DBError("Cannot connect to DB")
One of the great things about using functions instead of classes is that mocking libraries become less essential.
Guice works on type signatures, and happily supports binding and injecting functions into classes. Use the @Named
annotation in order to distinguish between different functions with the same type signature. Alternatively, don't use Guice, now that Play 2.6 has made it an optional dependency!
Consider higher-kinded types
In our previous example, the UserDB
gave back a User
. This type signature implies a synchronous call to get a User.
In practice, this means there is a thread blocking, or the code would return a Future[User]
instead. Perhaps we're using a non-blocking database driver, or perhaps the DB is actually a microservice and an HTTP request/response. Perhaps we actually want a thread to block until we have our result. Or perhaps we haven't decided yet, and want to delay making decisions about the execution model of our application...
Using a higher-kinded type allows us to express this, and also explicitly write how we sequence interactions. Future
is a monad, but we don't necessarily need Future
to write our code. If we know it is a Monad
we can still sequence functions together:
import cats.Monad
import cats.syntax.functor._
import cats.syntax.flatMap._
class UserService[F[_]: Monad](
getUser: UserId => F[User],
getTimeline: TimelineId => F[Timeline]){
def getProfile(id: UserId) = for {
user <- getUser(id)
timeline <- getTimeline(user.timelineId)
} yield UserProfile(user, timeline)
}
The benefit of higher-kinded types is that F
doesn't need to be a Future
. If you set F
as Id
, essentially the identity higher-kinded type, then the everything becomes synchronous again.
If the test below was fleshed out, it would pass. The Id
monad disappears without having to implement polling or waiting for a Future
:
import cats.Id
...
val service = new UserService[Id](getUser, getTimeline)
val profile = service.getProfile(id)
profile.id shouldBe id
...
A whole problem space disappears! You can concentrate on business logic without worrying about flakey tests.
Higher-kinded types are slightly tricky to implement in Play Framework when using Guice, but you can make an @Provider
to make sure F
is set to your chosen Monad:
@Singleton
@Provides
def usersService(
getUser: UserId => Future[User],
getTimeline: TimelineId => Future[TimelineId])
(implicit ec: ExecutionContext){
import cats.instances.future._
new UsersService[Future](getUser, getTimeline)
}
Do not throw exceptions
A function that can throw an exception is not a pure function. The great thing about pure functions is that they tend to be easier to reason about. Not using exceptions can increase code clarity.
One way of doing this is to pass back errors via an Either
instead of throwing an exception. If the error is an algebraic data type implemented with a sealed trait, that's even better.
sealed trait UserError
case object UserNotFound extends UserError
case class UserBanned(reason: BanReason) extends UserError
def getUser[F](id: UserId): F[Either[UserError, User]] = ...
Combining higher-kinded types with Either
works well. If your interfaces return Future[Either[A,B]]
you need to be careful whenever the Future
may fail and throw an exception. With higher-kinded types, you're just dealing with an F
.
Using explicit types instead of exceptions will also eventually lead you into the wonderful world of monad transformers!