A small HTTP service with http4s or Play

Define routes, encode JSON with circe, wire configuration, add middleware and errors, and package the service to deploy.

Routes and pattern matching

import cats.effect.{IO, IOApp}
import org.http4s.*
import org.http4s.dsl.io.*
import org.http4s.ember.server.EmberServerBuilder
import com.comcast.ip4s.*

final case class Order(id: String, sku: String, quantity: Int)

val service: HttpRoutes[IO] = HttpRoutes.of[IO] {
  case GET -> Root / "health" =>
    Ok("ok")

  case GET -> Root / "orders" / id =>
    Lookup.find(id) match
      case Some(o) => Ok(o)
      case None    => NotFound(s"order $id not found")

  case req @ POST -> Root / "orders" =>
    for
      body   <- req.as[OrderRequest]          // decode with the given EntityDecoder
      order  <- Create.order(body)
      resp   <- Created(order, Location(Uri.unsafeFromString(s"/orders/${order.id}")))
    yield resp

  case req @ PUT -> Root / "orders" / id =>
    req.as[OrderRequest].flatMap(body => Update.apply(id, body)).flatMap(Ok(_))

  case _ => NotFound()
}

object Main extends IOApp.Simple:
  def run: IO[Unit] =
    EmberServerBuilder
      .default[IO]
      .withHost(ipv4"0.0.0.0")
      .withPort(port"8080")
      .withHttpApp(service.orNotFound)          // turn routes into an application
      .build
      .useForever
  • orNotFound wraps the partial function into a total HttpApp that returns 404 for unmatched requests. Without it the type is HttpRoutes, not an application.
  • Path segments are matched by pattern, so GET -> Root / "orders" / id binds id as a string. Type-directed extraction can parse it as a UUID.
  • A failure inside the IO does not return a 500 by itself; it is an unhandled effect error. Add middleware that maps known errors to responses.
  • Use req.as[T] with a given decoder instead of reading and parsing the body by hand, so a malformed body becomes a 400 with a decode message.

JSON with circe

import io.circe.{Decoder, Encoder, Json}
import io.circe.generic.semiauto.deriveEncoder
import io.circe.generic.semiauto.deriveDecoder
import io.circe.syntax.*
import org.http4s.circe.*

final case class OrderRequest(sku: String, quantity: Int)
final case class OrderResponse(id: String, sku: String, quantity: Int, total: BigDecimal)

object OrderJson:
  given Encoder[OrderResponse] = deriveEncoder
  given Decoder[OrderRequest]  = deriveDecoder

  // a hand-written decoder when you need validation in the decoder
  given Decoder[BigDecimal] = Decoder.decodeBigDecimal

  // custom error mapping: parse failures become a typed error, not a stack trace
  def parse(json: Json): Either[String, OrderRequest] =
    json.as[OrderRequest].left.map(_.getMessage)

// make the codecs available to http4s entity decoding
import org.http4s.circe.CirceEntityDecoder.*
import org.http4s.circe.CirceEntityEncoder.*
ConcernOption A: http4sOption B: Play
StylePure, IO-based, no frameworkConvention-based, framework-driven
RoutesHttpRoutes[F] as a functionroutes file with controllers
JSONcirce or jsoniter with givensPlay JSON with Reads and Writes
ServerEmber, Blaze, Netty backendsNetty via the Play server
Best forServices where you want to choose every pieceTeams that want the batteries included

Both are productive. http4s composes with the effect system you already use and keeps every effect explicit; Play gives you templating, an admin console and a long-standing set of conventions out of the box.

Configuration, middleware and packaging

import cats.effect.{IO, Resource}
import org.http4s.server.middleware.{GZip, RequestLogger, Timeout, CORS}
import org.typelevel.log4cats.slf4j.Slf4jFactory

final case class AppConfig(port: Int, databaseUrl: String, timeoutSeconds: Int)

object AppConfig:
  // configuration from the environment: containers and CI both set it
  def load: IO[AppConfig] = IO {
    def env(name: String, default: String) = sys.env.getOrElse(name, default)
    AppConfig(
      port           = env("PORT", "8080").toInt,
      databaseUrl    = sys.env.getOrElse("DATABASE_URL", sys.error("DATABASE_URL is required")),
      timeoutSeconds = env("TIMEOUT_SECONDS", "10").toInt
    )
  }

object Http:
  def app(cfg: AppConfig, routes: HttpRoutes[IO]): HttpApp[IO] =
    val withTimeout = Timeout(cfg.timeoutSeconds.seconds)(routes.orNotFound)
    val logged      = RequestLogger.httpApp(logHeaders = true, logBody = false)(withTimeout)
    GZip.httpApp(CORS.policy.withAllowOriginAll(logged))

// packaging: an assembly jar and a Dockerfile on top of a JRE image
//   sbt assembly        -> target/scala-3.5.0/shop-assembly.jar
//   java -jar shop-assembly.jar
// or a native image with GraalVM once reflection is eliminated
//   sbt nativeImage
⚠️
Do not read configuration inside a request handler or a route body. Load it once at startup into a case class, fail fast when a required value is missing, and pass it where it is needed. A missing environment variable should stop the process, not surface as a 500 on the first request that needs it.

FAQ

http4s or Play?
http4s for a service you will own for years and want to compose precisely; the explicit effects repay the learning curve. Play when the team wants a full framework with templating, sessions and an established upgrade path.
How do I test the routes?
Build the HttpRoutes value directly and pass a Request to it. Because routes are a plain function, no server needs to start, and the test runs in milliseconds.

Concurrency: Futures, actors and effect systems Givens, implicits and type classes

Last refreshed 2026-09-18.