Scala Programming Guidesscala-ecosystemscala-best-practices

Web Development with Play and HTTP4s | Scala Programming Guide

By Dmitri Meshin
Picture of the author
Published on
Web Development with Play and HTTP4s - Scala Programming Guide

Play Framework: Traditional MVC

Play is the most mature Scala web framework, following MVC patterns with convention over configuration.

Project Structure and Configuration

# conf/application.conf
play.i18n.langs = [ "en" ]
play.http.secret.key = "change-me"

# Database configuration
db {
  default {
    driver = "org.postgresql.Driver"
    url = "jdbc:postgresql://localhost:5432/myapp"
    username = "postgres"
    password = "postgres"
  }
}

# Logging
logger.application = DEBUG
logger.play = WARN

# Custom application settings
app {
  maxUploadSize = 100MB
  invoiceApi {
    timeout = 30s
    retries = 3
  }
}

Controllers and Actions

Controllers handle HTTP requests and return responses:

// app/controllers/InvoiceController.scala
package controllers

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

@Singleton
class InvoiceController @Inject() (
  invoiceService: InvoiceService,
  cc: ControllerComponents
)(implicit ec: ExecutionContext) extends AbstractController(cc) {

  // GET /invoices/:id
  // Returns single invoice as JSON
  def getInvoice(id: String): Action[AnyContent] = Action.async { implicit request =>
    invoiceService.findById(id).map {
      case Some(invoice) =>
        Ok(Json.toJson(invoice))
          .withHeaders("Cache-Control" -> "max-age=300")  // Cache 5 minutes
      case None =>
        NotFound(Json.obj("error" -> "Invoice not found"))
    }
  }

  // GET /invoices?page=1&pageSize=20
  // Returns paginated list
  def listInvoices(page: Int = 1, pageSize: Int = 20): Action[AnyContent] =
    Action.async { implicit request =>
      // Validate pagination params
      if (page < 1 || pageSize < 1 || pageSize > 100) {
        Future.successful(
          BadRequest(Json.obj("error" -> "Invalid pagination parameters"))
        )
      } else {
        invoiceService.listPaginated(page, pageSize).map { invoices =>
          Ok(Json.toJson(invoices))
        }
      }
    }

  // POST /invoices
  // Body should be JSON invoice
  def createInvoice(): Action[JsValue] = Action.async(parse.json) { request =>
    // Validate JSON against Invoice model
    request.body.validate[Invoice] match {
      case JsSuccess(invoice, _) =>
        invoiceService.create(invoice).map { created =>
          Created(Json.toJson(created))
            .withHeaders("Location" -> routes.InvoiceController.getInvoice(created.id).url)
        }.recover {
          case e: DuplicateKeyException =>
            Conflict(Json.obj("error" -> s"Invoice ${invoice.id} already exists"))
          case e: ValidationException =>
            BadRequest(Json.obj("error" -> e.message))
        }

      case JsError(errors) =>
        // errors is a Seq[(JsPath, Seq[JsonValidationError])]
        val errorMessages = errors.map { case (path, validationErrors) =>
          s"${path.toString()}: ${validationErrors.map(_.message).mkString(", ")}"
        }
        Future.successful(
          BadRequest(Json.obj("errors" -> errorMessages))
        )
    }
  }

  // PUT /invoices/:id
  // Update existing invoice
  def updateInvoice(id: String): Action[JsValue] = Action.async(parse.json) { request =>
    request.body.validate[Invoice] match {
      case JsSuccess(updates, _) =>
        invoiceService.update(id, updates).map {
          case Some(updated) => Ok(Json.toJson(updated))
          case None => NotFound(Json.obj("error" -> "Invoice not found"))
        }

      case JsError(errors) =>
        Future.successful(BadRequest(Json.obj("errors" -> errors)))
    }
  }

  // DELETE /invoices/:id
  def deleteInvoice(id: String): Action[AnyContent] = Action.async { implicit request =>
    invoiceService.delete(id).map { deleted =>
      if (deleted) NoContent else NotFound
    }
  }

  // POST /invoices/:id/send
  // Send invoice via email
  def sendInvoice(id: String): Action[AnyContent] = Action.async { implicit request =>
    val recipientEmail = request.getQueryString("to").getOrElse {
      return Future.successful(
        BadRequest(Json.obj("error" -> "Missing 'to' parameter"))
      )
    }

    invoiceService.sendInvoice(id, recipientEmail).map { _ =>
      Ok(Json.obj("message" -> "Invoice sent successfully"))
    }.recover {
      case e: NotFoundException =>
        NotFound(Json.obj("error" -> "Invoice not found"))
      case e: EmailException =>
        ServiceUnavailable(Json.obj("error" -> "Email service unavailable"))
    }
  }
}

Routes and Reverse Routing

# conf/routes

# REST API endpoints
GET     /invoices                              controllers.InvoiceController.listInvoices(page: Int ?= 1, pageSize: Int ?= 20)
GET     /invoices/:id                          controllers.InvoiceController.getInvoice(id: String)
POST    /invoices                              controllers.InvoiceController.createInvoice()
PUT     /invoices/:id                          controllers.InvoiceController.updateInvoice(id: String)
DELETE  /invoices/:id                          controllers.InvoiceController.deleteInvoice(id: String)
POST    /invoices/:id/send                     controllers.InvoiceController.sendInvoice(id: String)

# Health check
GET     /health                                controllers.HealthController.status()

# Map static assets
GET     /assets/*file                          controllers.Assets.versioned(path="/public", file: Asset)

Using reverse routing in code:

// In controller or view - generates URL at compile time
val invoiceUrl = routes.InvoiceController.getInvoice("INV-2024-001").url
// Result: /invoices/INV-2024-001

val listUrl = routes.InvoiceController.listInvoices(page = 2, pageSize = 50).url
// Result: /invoices?page=2&pageSize=50

Dependency Injection and Filters

// app/modules/AppModule.scala
package modules

import com.google.inject.AbstractModule
import com.google.inject.name.Names
import net.codingwell.scalaguice.ScalaModule
import services._
import filters._
import play.api.Configuration

class AppModule extends AbstractModule with ScalaModule {
  override def configure(): Unit = {
    // Bind interfaces to implementations
    bind[InvoiceService].to[InvoiceServiceImpl].in[javax.inject.Singleton]
    bind[EmailService].to[SendgridEmailService].in[javax.inject.Singleton]

    // Conditional binding based on environment
    if (play.api.Play.isDev) {
      bind[PaymentGateway].to[MockPaymentGateway]
    } else {
      bind[PaymentGateway].to[StripePaymentGateway]
    }
  }

  // Provider method for complex construction
  @javax.inject.Provides
  @javax.inject.Singleton
  def provideHttpClient(config: Configuration): play.api.libs.ws.WSClient = {
    val wsClient = play.api.libs.ws.ahc.AhcWSClient()

    // Configure client settings
    val timeout = config.get[scala.concurrent.duration.Duration]("http.client.timeout")
    wsClient.withTimeout(timeout)

    wsClient
  }
}

// In application.conf, enable the module:
// play.modules.enabled += "modules.AppModule"

Filter middleware for cross-cutting concerns:

// app/filters/RequestLoggingFilter.scala
package filters

import javax.inject.Inject
import play.api.Logger
import play.api.mvc._
import scala.concurrent.{ExecutionContext, Future}

class RequestLoggingFilter @Inject() (implicit ec: ExecutionContext)
  extends Filter {

  private val logger = Logger(this.getClass)

  def apply(
    nextFilter: RequestHeader => Future[Result]
  )(rh: RequestHeader): Future[Result] = {
    val startTime = System.nanoTime()

    nextFilter(rh).map { result =>
      val endTime = System.nanoTime()
      val duration = (endTime - startTime) / 1_000_000.0  // Convert to milliseconds

      logger.info(
        s"${rh.method} ${rh.uri} -> ${result.header.status} (${duration}ms)"
      )

      result.withHeaders(
        "X-Response-Time" -> s"${duration}ms"
      )
    }.recover { case e =>
      logger.error(s"Request failed: ${rh.method} ${rh.uri}", e)
      throw e
    }
  }
}

// In application.conf:
// play.filters.enabled += "filters.RequestLoggingFilter"

Templates (Twirl)

Play uses Twirl for type-safe templates:

@* views/invoices/invoice.scala.html *@
@(invoice: models.Invoice, company: models.Company)

@defining(formatter.date.format(invoice.createdAt)) { createdDate =>
  <div class="invoice">
    <h1>Invoice #@invoice.id</h1>

    <section class="company-info">
      <h2>@company.name</h2>
      <p>@company.address</p>
      <p>@company.email</p>
    </section>

    <section class="items">
      <table>
        <thead>
          <tr>
            <th>Description</th>
            <th>Quantity</th>
            <th>Unit Price</th>
            <th>Total</th>
          </tr>
        </thead>
        <tbody>
          @for(item <- invoice.items) {
            <tr>
              <td>@item.description</td>
              <td>@item.quantity</td>
              <td>@formatter.currency(item.unitPrice)</td>
              <td>@formatter.currency(item.total)</td>
            </tr>
          }
        </tbody>
      </table>
    </section>

    <section class="totals">
      <p>Subtotal: @formatter.currency(invoice.subtotal)</p>
      <p>Tax (@{invoice.taxRate}%): @formatter.currency(invoice.taxAmount)</p>
      <p class="total">Total: @formatter.currency(invoice.total)</p>
    </section>

    <footer>
      <p>Generated on @createdDate</p>
    </footer>
  </div>
}

HTTP4s: Functional HTTP

HTTP4s is lightweight, built on cats-effect, emphasizing functional composition. Unlike Play Framework's object-oriented, imperative approach, HTTP4s uses composable functions and monadic composition to build services. This results in applications that are more testable, more easily reasoned about, and naturally suited to asynchronous processing.

The HttpRoutes DSL provides intuitive syntax for matching requests and composing responses. However, the power comes from the underlying cats-effect architecture: services are just IO computations that describe effects without executing them. This testability is remarkable—you can test routes without starting a server, without network I/O, without any actual effects occurring until you explicitly run the IO. The type system ensures that all effects are tracked and that resources are properly managed.

The functional approach to middleware and authentication differs significantly from traditional frameworks. Middleware isn't a side-effect-laden hook—it's a function that transforms routes into routes, composing cleanly with applicative and monadic combinators. This leads to more predictable behavior and easier debugging. Understanding how to work with OptionT, EitherT, and other monad transformers becomes essential as your services grow in complexity.

// build.sbt
libraryDependencies ++= Seq(
  "org.http4s" %% "http4s-ember-server" % "1.0.0-M40",
  "org.http4s" %% "http4s-dsl" % "1.0.0-M40",
  "org.http4s" %% "http4s-circe" % "1.0.0-M40",
  "io.circe" %% "circe-generic" % "0.14.6"
)

// Main application setup
import cats.effect._
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.implicits._
import org.http4s.server.Router
import org.http4s.ember.server.EmberServerBuilder
import scala.concurrent.duration._

object Server extends IOApp.Simple {
  // Define routes using DSL
  val invoiceRoutes: HttpRoutes[IO] = HttpRoutes.of[IO] {
    // GET /invoices/:id
    case GET -> Root / "invoices" / id =>
      // In a real app, fetch from database
      val invoice = Invoice(
        id = id,
        amount = BigDecimal("1000.00"),
        createdAt = Instant.now()
      )
      Ok(invoice)  // Automatically converted to JSON via Circe

    // GET /invoices?status=paid&limit=10
    case GET -> Root / "invoices" :? StatusParam(status) +& LimitParam(limit) =>
      // Query parameters extracted with custom matchers
      val invoices = fetchInvoices(status, limit)
      Ok(invoices)

    // POST /invoices with JSON body
    case req @ POST -> Root / "invoices" =>
      req.as[Invoice].flatMap { invoice =>
        // Validate and persist
        val validated = validateInvoice(invoice)
        validated match {
          case Right(validInvoice) =>
            saveInvoice(validInvoice).flatMap { saved =>
              Created(saved, Location(Uri(path = Uri.Path.fromString(s"/invoices/${saved.id}"))))
            }
          case Left(errors) =>
            BadRequest(ValidationError(errors.toString))
        }
      }

    // DELETE /invoices/:id
    case DELETE -> Root / "invoices" / id =>
      deleteInvoice(id).flatMap { deleted =>
        if (deleted) NoContent else NotFound()
      }
  }

  // Custom query parameter matcher
  object StatusParam extends QueryParamDecoderMatcher[String]("status")
  object LimitParam extends OptionalQueryParamDecoderMatcher[Int]("limit")

  // Middleware for logging
  val loggingMiddleware: HttpApp[IO] => HttpApp[IO] = { app =>
    val logger = org.log4s.getLogger

    HttpRoutes.of[IO] { req =>
      val start = System.currentTimeMillis()
      app(req).map { resp =>
        val elapsed = System.currentTimeMillis() - start
        logger.info(s"${req.method.name} ${req.uri} -> ${resp.status.code} (${elapsed}ms)")
        resp
      }
    }.orNotFound
  }

  // Middleware for error handling
  val errorHandlingMiddleware: HttpApp[IO] => HttpApp[IO] = { app =>
    HttpRoutes.of[IO] { req =>
      app(req).recoverWith { case e: Throwable =>
        Response[IO](
          status = Status.InternalServerError,
          body = fs2.Stream(s"Error: ${e.getMessage}".getBytes)
        ).pure[IO]
      }
    }.orNotFound
  }

  // Combine routes with middleware
  val httpApp = Router(
    "/" -> invoiceRoutes
  ).orNotFound

  // Apply middleware (right to left: first applied = outermost)
  val withMiddleware = loggingMiddleware(errorHandlingMiddleware(httpApp))

  // Startup
  def run: IO[Unit] =
    EmberServerBuilder
      .default[IO]
      .withHost(org.http4s.Server.Host.fromString("0.0.0.0").get)
      .withPort(org.http4s.Server.Port.fromInt(8080).get)
      .withHttpApp(withMiddleware)
      .build
      .useForever
}

JSON Handling with Circe

import io.circe._
import io.circe.generic.auto._
import io.circe.parser._

// Automatic derivation for simple case classes
case class Invoice(
  id: String,
  amount: BigDecimal,
  createdAt: Instant
) derives Codec.AsObject

// Custom encoding/decoding for complex types
implicit val instantDecoder: Decoder[Instant] = Decoder.decodeLong.map(Instant.ofEpochMilli)
implicit val instantEncoder: Encoder[Instant] = Encoder.encodeLong.contramap(_.toEpochMilli)

// Manual codec for fine-grained control
implicit val customInvoiceCodec: Codec.AsObject[Invoice] =
  Codec.from(
    // Decoder
    Decoder.forProduct3("id", "amount", "created_at")(
      (id: String, amount: BigDecimal, createdAt: Instant) =>
        Invoice(id, amount, createdAt)
    ),
    // Encoder
    Encoder.AsObject.instance { invoice =>
      JsonObject(
        "id" -> Json.fromString(invoice.id),
        "amount" -> Json.fromBigDecimal(invoice.amount),
        "created_at" -> Json.fromLong(invoice.createdAt.toEpochMilli)
      )
    }
  )

// Usage
val json = """{"id":"INV-001","amount":99.99,"created_at":1234567890000}"""
val decoded: Either[Error, Invoice] = decode[Invoice](json)
val encoded: String = invoice.asJson.noSpaces