Web Development: Play Framework and Http4s

Web development in Scala offers powerful frameworks that leverage the language's strengths for building scalable, maintainable web applications. In this lesson, we'll explore two leading frameworks: Play Framework for traditional MVC applications and Http4s for purely functional web services.

Understanding Scala Web Frameworks

Play Framework

  • Full-stack MVC framework
  • Built on Akka and Akka HTTP
  • Hot reloading in development
  • Integrated testing framework
  • Strong ecosystem integration

Http4s

  • Purely functional HTTP library
  • Built on Cats Effect
  • Composable and type-safe
  • Streaming-first approach
  • Minimal and modular

Play Framework: Full-Stack Web Development

Play Framework provides a complete solution for web application development with a focus on developer productivity and type safety.

Setting Up Play Framework

// project/plugins.sbt
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.19")

// build.sbt
lazy val root = (project in file("."))
  .enablePlugins(PlayScala)
  .settings(
    name := "play-app",
    version := "1.0.0",
    scalaVersion := "2.13.12",
    libraryDependencies ++= Seq(
      guice,
      "org.scalatestplus.play" %% "scalatestplus-play" % "5.1.0" % Test,
      "com.typesafe.play" %% "play-slick" % "5.1.0",
      "com.typesafe.play" %% "play-slick-evolutions" % "5.1.0",
      "com.h2database" % "h2" % "2.2.224",
      "org.postgresql" % "postgresql" % "42.6.0"
    )
  )

Basic Play Application Structure

// app/controllers/HomeController.scala
package controllers

import javax.inject._
import play.api._
import play.api.mvc._
import play.api.libs.json._
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class HomeController @Inject()(
  val controllerComponents: ControllerComponents
)(implicit ec: ExecutionContext) extends BaseController {

  def index() = Action { implicit request: Request[AnyContent] =>
    Ok(views.html.index())
  }

  def api() = Action {
    Ok(Json.obj(
      "message" -> "Hello from Play API",
      "timestamp" -> System.currentTimeMillis()
    ))
  }
}

Models and Database Integration

// app/models/User.scala
package models

import play.api.libs.json._

case class User(
  id: Option[Long] = None,
  name: String,
  email: String,
  age: Int
)

object User {
  implicit val userFormat: Format[User] = Json.format[User]
}

// app/models/UserRepository.scala
package models

import javax.inject.{Inject, Singleton}
import play.api.db.slick.DatabaseConfigProvider
import slick.jdbc.JdbcProfile
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class UserRepository @Inject()(
  dbConfigProvider: DatabaseConfigProvider
)(implicit ec: ExecutionContext) {

  private val dbConfig = dbConfigProvider.get[JdbcProfile]

  import dbConfig._
  import profile.api._

  private class UserTable(tag: Tag) extends Table[User](tag, "users") {
    def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
    def name = column[String]("name")
    def email = column[String]("email")
    def age = column[Int]("age")

    def * = (id.?, name, email, age) <> (User.tupled, User.unapply)
  }

  private val users = TableQuery[UserTable]

  def create(user: User): Future[User] = {
    val insertQuery = users returning users.map(_.id) into ((user, id) => user.copy(id = Some(id)))
    db.run(insertQuery += user)
  }

  def list(): Future[Seq[User]] = db.run(users.result)

  def findById(id: Long): Future[Option[User]] = {
    db.run(users.filter(_.id === id).result.headOption)
  }

  def update(id: Long, user: User): Future[Option[User]] = {
    val updateQuery = users.filter(_.id === id).update(user.copy(id = Some(id)))
    db.run(updateQuery).map {
      case 0 => None
      case _ => Some(user.copy(id = Some(id)))
    }
  }

  def delete(id: Long): Future[Boolean] = {
    db.run(users.filter(_.id === id).delete).map(_ > 0)
  }
}

RESTful API Controller

// app/controllers/UserController.scala
package controllers

import javax.inject._
import play.api.mvc._
import play.api.libs.json._
import models.{User, UserRepository}
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class UserController @Inject()(
  cc: ControllerComponents,
  userRepository: UserRepository
)(implicit ec: ExecutionContext) extends AbstractController(cc) {

  def list = Action.async { implicit request =>
    userRepository.list().map { users =>
      Ok(Json.toJson(users))
    }
  }

  def get(id: Long) = Action.async { implicit request =>
    userRepository.findById(id).map {
      case Some(user) => Ok(Json.toJson(user))
      case None => NotFound(Json.obj("error" -> s"User with id $id not found"))
    }
  }

  def create = Action.async(parse.json) { implicit request =>
    request.body.validate[User].fold(
      errors => Future.successful(BadRequest(Json.obj("errors" -> JsError.toJson(errors)))),
      user => {
        userRepository.create(user).map { createdUser =>
          Created(Json.toJson(createdUser))
        }
      }
    )
  }

  def update(id: Long) = Action.async(parse.json) { implicit request =>
    request.body.validate[User].fold(
      errors => Future.successful(BadRequest(Json.obj("errors" -> JsError.toJson(errors)))),
      user => {
        userRepository.update(id, user).map {
          case Some(updatedUser) => Ok(Json.toJson(updatedUser))
          case None => NotFound(Json.obj("error" -> s"User with id $id not found"))
        }
      }
    )
  }

  def delete(id: Long) = Action.async { implicit request =>
    userRepository.delete(id).map { deleted =>
      if (deleted) Ok(Json.obj("message" -> "User deleted successfully"))
      else NotFound(Json.obj("error" -> s"User with id $id not found"))
    }
  }
}

Routing Configuration

// conf/routes
# Routes
# This file defines all application routes (Higher priority routes first)

GET     /                           controllers.HomeController.index
GET     /api                        controllers.HomeController.api

# User API routes
GET     /api/users                  controllers.UserController.list
GET     /api/users/:id              controllers.UserController.get(id: Long)
POST    /api/users                  controllers.UserController.create
PUT     /api/users/:id              controllers.UserController.update(id: Long)
DELETE  /api/users/:id              controllers.UserController.delete(id: Long)

# Map static resources from the /public folder to the /assets URL path
GET     /assets/*file               controllers.Assets.versioned(path="/public", file: Asset)

Authentication and Security

// app/controllers/AuthController.scala
package controllers

import javax.inject._
import play.api.mvc._
import play.api.libs.json._
import play.api.Configuration
import pdi.jwt.{JwtJson, JwtAlgorithm}
import java.time.Clock
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Success, Failure}

case class LoginRequest(email: String, password: String)
case class LoginResponse(token: String, user: User)

object LoginRequest {
  implicit val format: Format[LoginRequest] = Json.format[LoginRequest]
}

object LoginResponse {
  implicit val format: Format[LoginResponse] = Json.format[LoginResponse]
}

@Singleton
class AuthController @Inject()(
  cc: ControllerComponents,
  userRepository: UserRepository,
  config: Configuration
)(implicit ec: ExecutionContext) extends AbstractController(cc) {

  private val secretKey = config.get[String]("play.http.secret.key")
  private implicit val clock: Clock = Clock.systemUTC

  def login = Action.async(parse.json) { implicit request =>
    request.body.validate[LoginRequest].fold(
      errors => Future.successful(BadRequest(Json.obj("errors" -> JsError.toJson(errors)))),
      loginRequest => {
        // In a real app, you'd verify the password hash
        authenticateUser(loginRequest.email, loginRequest.password).map {
          case Some(user) =>
            val token = generateToken(user)
            Ok(Json.toJson(LoginResponse(token, user)))
          case None =>
            Unauthorized(Json.obj("error" -> "Invalid credentials"))
        }
      }
    )
  }

  private def authenticateUser(email: String, password: String): Future[Option[User]] = {
    // Simplified authentication - in real apps, use proper password hashing
    userRepository.list().map(_.find(_.email == email))
  }

  private def generateToken(user: User): String = {
    val claims = Json.obj(
      "userId" -> user.id,
      "email" -> user.email,
      "exp" -> (System.currentTimeMillis() / 1000 + 3600) // 1 hour expiry
    )
    JwtJson.encode(claims, secretKey, JwtAlgorithm.HS256)
  }
}

// app/actions/AuthenticatedAction.scala
package actions

import javax.inject._
import play.api.mvc._
import play.api.libs.json._
import play.api.Configuration
import pdi.jwt.{JwtJson, JwtAlgorithm}
import models.User
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Success, Failure}

class AuthenticatedRequest[A](val user: User, request: Request[A]) extends WrappedRequest[A](request)

@Singleton
class AuthenticatedAction @Inject()(
  parser: BodyParsers.Default,
  config: Configuration
)(implicit ec: ExecutionContext) extends ActionBuilderImpl(parser) {

  private val secretKey = config.get[String]("play.http.secret.key")

  override def invokeBlock[A](request: Request[A], block: Request[A] => Future[Result]): Future[Result] = {
    request.headers.get("Authorization") match {
      case Some(authHeader) if authHeader.startsWith("Bearer ") =>
        val token = authHeader.substring(7)
        JwtJson.decode(token, secretKey, Seq(JwtAlgorithm.HS256)) match {
          case Success(claims) =>
            val userId = (claims \ "userId").as[Long]
            val email = (claims \ "email").as[String]
            // In a real app, you'd fetch the user from the database
            val user = User(Some(userId), "User", email, 0)
            block(new AuthenticatedRequest(user, request))
          case Failure(_) =>
            Future.successful(Results.Unauthorized(Json.obj("error" -> "Invalid token")))
        }
      case _ =>
        Future.successful(Results.Unauthorized(Json.obj("error" -> "Missing authorization header")))
    }
  }
}

Http4s: Purely Functional Web Services

Http4s provides a purely functional approach to web development with excellent composability and type safety.

Setting Up Http4s

// build.sbt
val Http4sVersion = "0.23.23"
val CirceVersion = "0.14.6"

libraryDependencies ++= Seq(
  "org.http4s" %% "http4s-ember-server" % Http4sVersion,
  "org.http4s" %% "http4s-ember-client" % Http4sVersion,
  "org.http4s" %% "http4s-circe" % Http4sVersion,
  "org.http4s" %% "http4s-dsl" % Http4sVersion,
  "io.circe" %% "circe-generic" % CirceVersion,
  "org.typelevel" %% "cats-effect" % "3.5.2",
  "ch.qos.logback" % "logback-classic" % "1.4.11"
)

Basic Http4s Application

// src/main/scala/Main.scala
import cats.effect._
import org.http4s.ember.server.EmberServerBuilder
import com.comcast.ip4s._

object Main extends IOApp {
  def run(args: List[String]): IO[ExitCode] = {
    val server = EmberServerBuilder
      .default[IO]
      .withHost(ipv4"0.0.0.0")
      .withPort(port"8080")
      .withHttpApp(Routes.httpApp)
      .build

    server.use(_ => IO.never).as(ExitCode.Success)
  }
}

Models and JSON Codecs

// src/main/scala/models/User.scala
package models

import io.circe.{Decoder, Encoder}
import io.circe.generic.semiauto._

case class User(
  id: Option[Long],
  name: String,
  email: String,
  age: Int
)

object User {
  implicit val userDecoder: Decoder[User] = deriveDecoder[User]
  implicit val userEncoder: Encoder[User] = deriveEncoder[User]
}

case class CreateUserRequest(name: String, email: String, age: Int)

object CreateUserRequest {
  implicit val decoder: Decoder[CreateUserRequest] = deriveDecoder[CreateUserRequest]
}

case class ApiError(message: String)

object ApiError {
  implicit val encoder: Encoder[ApiError] = deriveEncoder[ApiError]
}

Service Layer

// src/main/scala/services/UserService.scala
package services

import cats.effect._
import cats.implicits._
import models.{User, CreateUserRequest}
import scala.collection.mutable

trait UserService[F[_]] {
  def getUsers: F[List[User]]
  def getUser(id: Long): F[Option[User]]
  def createUser(request: CreateUserRequest): F[User]
  def updateUser(id: Long, request: CreateUserRequest): F[Option[User]]
  def deleteUser(id: Long): F[Boolean]
}

class InMemoryUserService[F[_]: Sync] extends UserService[F] {
  private val users = mutable.Map[Long, User]()
  private var nextId = 1L

  def getUsers: F[List[User]] = Sync[F].delay {
    users.values.toList
  }

  def getUser(id: Long): F[Option[User]] = Sync[F].delay {
    users.get(id)
  }

  def createUser(request: CreateUserRequest): F[User] = Sync[F].delay {
    val user = User(Some(nextId), request.name, request.email, request.age)
    users += (nextId -> user)
    nextId += 1
    user
  }

  def updateUser(id: Long, request: CreateUserRequest): F[Option[User]] = Sync[F].delay {
    users.get(id).map { _ =>
      val updatedUser = User(Some(id), request.name, request.email, request.age)
      users += (id -> updatedUser)
      updatedUser
    }
  }

  def deleteUser(id: Long): F[Boolean] = Sync[F].delay {
    users.remove(id).isDefined
  }
}

HTTP Routes

// src/main/scala/Routes.scala
import cats.effect._
import cats.implicits._
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.circe.CirceEntityCodec._
import org.http4s.server.Router
import services.{UserService, InMemoryUserService}
import models.{User, CreateUserRequest, ApiError}

object Routes {

  private val userService: UserService[IO] = new InMemoryUserService[IO]

  private val userRoutes = HttpRoutes.of[IO] {

    case GET -> Root / "users" =>
      userService.getUsers.flatMap { users =>
        Ok(users)
      }

    case GET -> Root / "users" / LongVar(id) =>
      userService.getUser(id).flatMap {
        case Some(user) => Ok(user)
        case None => NotFound(ApiError(s"User with id $id not found"))
      }

    case req @ POST -> Root / "users" =>
      req.as[CreateUserRequest].flatMap { createRequest =>
        userService.createUser(createRequest).flatMap { user =>
          Created(user)
        }
      }.handleErrorWith {
        case _: InvalidMessageBodyFailure =>
          BadRequest(ApiError("Invalid JSON"))
        case ex =>
          InternalServerError(ApiError(ex.getMessage))
      }

    case req @ PUT -> Root / "users" / LongVar(id) =>
      req.as[CreateUserRequest].flatMap { updateRequest =>
        userService.updateUser(id, updateRequest).flatMap {
          case Some(user) => Ok(user)
          case None => NotFound(ApiError(s"User with id $id not found"))
        }
      }.handleErrorWith {
        case _: InvalidMessageBodyFailure =>
          BadRequest(ApiError("Invalid JSON"))
        case ex =>
          InternalServerError(ApiError(ex.getMessage))
      }

    case DELETE -> Root / "users" / LongVar(id) =>
      userService.deleteUser(id).flatMap { deleted =>
        if (deleted) Ok(Map("message" -> "User deleted successfully"))
        else NotFound(ApiError(s"User with id $id not found"))
      }
  }

  private val healthRoutes = HttpRoutes.of[IO] {
    case GET -> Root / "health" =>
      Ok(Map(
        "status" -> "healthy",
        "timestamp" -> System.currentTimeMillis()
      ))
  }

  val httpApp = Router(
    "/api" -> userRoutes,
    "/" -> healthRoutes
  ).orNotFound
}

Middleware and Error Handling

// src/main/scala/middleware/ErrorHandling.scala
package middleware

import cats.effect._
import cats.data.{Kleisli, OptionT}
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.circe.CirceEntityCodec._
import models.ApiError

object ErrorHandling {

  def errorHandler[F[_]: Sync]: HttpRoutes[F] => HttpRoutes[F] = { routes =>
    Kleisli { req =>
      OptionT {
        routes.run(req).value.handleError { error =>
          val response = error match {
            case _: IllegalArgumentException =>
              Response[F](Status.BadRequest).withEntity(ApiError("Bad request"))
            case _ =>
              Response[F](Status.InternalServerError).withEntity(ApiError("Internal server error"))
          }
          Some(response)
        }
      }
    }
  }
}

// src/main/scala/middleware/Logging.scala
package middleware

import cats.effect._
import cats.data.{Kleisli, OptionT}
import org.http4s._
import org.typelevel.log4cats.Logger
import org.typelevel.log4cats.slf4j.Slf4jLogger

object Logging {

  def requestLogger[F[_]: Async]: HttpRoutes[F] => HttpRoutes[F] = { routes =>
    implicit val logger: Logger[F] = Slf4jLogger.getLogger[F]

    Kleisli { req =>
      OptionT {
        logger.info(s"${req.method} ${req.uri}") *>
        routes.run(req).value.flatTap {
          case Some(response) =>
            logger.info(s"${req.method} ${req.uri} -> ${response.status}")
          case None =>
            logger.info(s"${req.method} ${req.uri} -> 404")
        }
      }
    }
  }
}

Authentication with Http4s

// src/main/scala/auth/AuthMiddleware.scala
package auth

import cats.effect._
import cats.data.{Kleisli, OptionT}
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.circe.CirceEntityCodec._
import org.http4s.server.AuthMiddleware
import models.{User, ApiError}
import pdi.jwt.{JwtClaim, JwtCirce, JwtAlgorithm}
import io.circe.parser._
import scala.util.{Success, Failure}

case class AuthUser(id: Long, email: String)

object AuthMiddleware {

  private val secretKey = "your-secret-key" // Use configuration in real apps

  private def extractUser(token: String): Either[String, AuthUser] = {
    JwtCirce.decode(token, secretKey, Seq(JwtAlgorithm.HS256)) match {
      case Success(claim) =>
        parse(claim.content) match {
          case Right(json) =>
            for {
              id <- json.hcursor.get[Long]("userId").left.map(_.getMessage)
              email <- json.hcursor.get[String]("email").left.map(_.getMessage)
            } yield AuthUser(id, email)
          case Left(error) => Left(error.getMessage)
        }
      case Failure(error) => Left(error.getMessage)
    }
  }

  private val authUser: Kleisli[IO, Request[IO], Either[String, AuthUser]] =
    Kleisli { request =>
      IO.pure {
        request.headers.get(ci"Authorization") match {
          case Some(authHeader) if authHeader.head.value.startsWith("Bearer ") =>
            val token = authHeader.head.value.substring(7)
            extractUser(token)
          case _ =>
            Left("Missing or invalid authorization header")
        }
      }
    }

  private val onFailure: AuthedRoutes[String, IO] = Kleisli { _ =>
    OptionT.liftF(Unauthorized(ApiError("Authentication required")))
  }

  val middleware: AuthMiddleware[IO, AuthUser] = 
    AuthMiddleware(authUser, onFailure)

  // Protected routes
  val protectedRoutes: AuthedRoutes[AuthUser, IO] = AuthedRoutes.of {
    case GET -> Root / "profile" as user =>
      Ok(Map(
        "id" -> user.id,
        "email" -> user.email,
        "message" -> "This is a protected endpoint"
      ))
  }
}

Testing Http4s Applications

// src/test/scala/RoutesSpec.scala
import cats.effect.IO
import org.http4s._
import org.http4s.circe.CirceEntityCodec._
import org.http4s.implicits._
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
import models.{User, CreateUserRequest}

class RoutesSpec extends AnyWordSpec with Matchers {

  "User routes" should {

    "return empty list initially" in {
      val request = Request[IO](Method.GET, uri"/api/users")
      val response = Routes.httpApp.run(request).unsafeRunSync()

      response.status shouldBe Status.Ok
      response.as[List[User]].unsafeRunSync() shouldBe List.empty
    }

    "create a new user" in {
      val createRequest = CreateUserRequest("John Doe", "john@example.com", 30)
      val request = Request[IO](Method.POST, uri"/api/users")
        .withEntity(createRequest)

      val response = Routes.httpApp.run(request).unsafeRunSync()

      response.status shouldBe Status.Created
      val user = response.as[User].unsafeRunSync()
      user.name shouldBe "John Doe"
      user.email shouldBe "john@example.com"
      user.age shouldBe 30
      user.id shouldBe defined
    }

    "return 404 for non-existent user" in {
      val request = Request[IO](Method.GET, uri"/api/users/999")
      val response = Routes.httpApp.run(request).unsafeRunSync()

      response.status shouldBe Status.NotFound
    }
  }
}

WebSocket Support

Play Framework WebSockets

// app/controllers/WebSocketController.scala
package controllers

import javax.inject._
import play.api.mvc._
import play.api.libs.streams.ActorFlow
import akka.actor._
import akka.stream.Materializer
import play.api.libs.json._

@Singleton
class WebSocketController @Inject()(
  cc: ControllerComponents
)(implicit system: ActorSystem, mat: Materializer) extends AbstractController(cc) {

  def socket = WebSocket.accept[String, String] { request =>
    ActorFlow.actorRef { out =>
      ChatActor.props(out)
    }
  }
}

object ChatActor {
  def props(out: ActorRef) = Props(new ChatActor(out))
}

class ChatActor(out: ActorRef) extends Actor {
  def receive = {
    case msg: String =>
      // Echo back with timestamp
      val response = s"Echo: $msg (${System.currentTimeMillis()})"
      out ! response
  }
}

Http4s WebSockets

import org.http4s._
import org.http4s.dsl.io._
import org.http4s.websocket.WebSocketFrame
import org.http4s.server.websocket._
import fs2._
import fs2.concurrent.Queue
import cats.effect._

val webSocketRoutes = HttpRoutes.of[IO] {
  case GET -> Root / "ws" =>
    Queue.unbounded[IO, WebSocketFrame].flatMap { queue =>
      val send = queue.dequeue.map(identity)
      val receive = queue.enqueue

      WebSocketBuilder[IO].build(send, receive.compose(_.map {
        case WebSocketFrame.Text(text, _) =>
          WebSocketFrame.Text(s"Echo: $text")
        case frame => frame
      }))
    }
}

Performance Optimization

Caching Strategies

// Play Framework with Caffeine Cache
import com.github.benmanes.caffeine.cache.{Caffeine, Cache}
import scala.concurrent.duration._

@Singleton
class CachedUserService @Inject()(
  userRepository: UserRepository
)(implicit ec: ExecutionContext) {

  private val cache: Cache[Long, User] = Caffeine.newBuilder()
    .maximumSize(1000)
    .expireAfterWrite(10.minutes.toJava)
    .build()

  def findById(id: Long): Future[Option[User]] = {
    Option(cache.getIfPresent(id)) match {
      case Some(user) => Future.successful(Some(user))
      case None =>
        userRepository.findById(id).map { userOpt =>
          userOpt.foreach(user => cache.put(id, user))
          userOpt
        }
    }
  }
}

// Http4s with Redis
import dev.profunktor.redis4cats._
import dev.profunktor.redis4cats.effect.Log.Stdout._

val redis: Resource[IO, RedisCommands[IO, String, String]] = 
  Redis[IO].utf8("redis://localhost")

def cachedUserService(redis: RedisCommands[IO, String, String]): UserService[IO] = 
  new UserService[IO] {
    def getUser(id: Long): IO[Option[User]] = {
      val key = s"user:$id"
      redis.get(key).flatMap {
        case Some(json) => 
          IO.fromEither(decode[User](json)).map(Some(_))
        case None =>
          // Fetch from database and cache
          fetchFromDatabase(id).flatTap {
            case Some(user) => 
              redis.setEx(key, user.asJson.noSpaces, 10.minutes)
            case None => IO.unit
          }
      }
    }
  }

Conclusion

Web development in Scala offers powerful options for different use cases:

  • Play Framework: Choose for traditional MVC web applications, rapid prototyping, or when you need a full-stack solution with integrated templating, asset pipeline, and extensive ecosystem
  • Http4s: Choose for microservices, API-only backends, or when you want purely functional programming with excellent composability and type safety

Both frameworks provide:

  • Type-safe routing and request handling
  • JSON serialization/deserialization
  • Authentication and authorization
  • Testing frameworks
  • Production-ready features

Key considerations:

  • Learning curve: Play is more familiar to traditional web developers, Http4s requires functional programming knowledge
  • Performance: Http4s generally has lower resource usage; Play provides more out-of-the-box optimizations
  • Ecosystem: Play has a larger ecosystem and more third-party integrations
  • Flexibility: Http4s is more modular and composable; Play provides more structure and conventions

Choose based on your team's expertise, project requirements, and architectural preferences.