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
.useForeverorNotFoundwraps the partial function into a totalHttpAppthat returns 404 for unmatched requests. Without it the type isHttpRoutes, not an application.- Path segments are matched by pattern, so
GET -> Root / "orders" / idbindsidas a string. Type-directed extraction can parse it as aUUID. - A failure inside the
IOdoes 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.*| Concern | Option A: http4s | Option B: Play |
|---|---|---|
| Style | Pure, IO-based, no framework | Convention-based, framework-driven |
| Routes | HttpRoutes[F] as a function | routes file with controllers |
| JSON | circe or jsoniter with givens | Play JSON with Reads and Writes |
| Server | Ember, Blaze, Netty backends | Netty via the Play server |
| Best for | Services where you want to choose every piece | Teams 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.Related
Concurrency: Futures, actors and effect systems Givens, implicits and type classes
Last refreshed 2026-09-18.