A Scala REST API in 2026, and beyond

March 11, 2026

Direct-Style REST in Scala 3: Virtual Threads, sttp, and the Road to Caprese

The conventional wisdom in Scala has long been: if you do I/O, you need Future (or an effect type like IO). Your return types get wrapped, your signatures are impacted, and every layer of the stack has to “speak async”. Project Loom changes that equation. This post walks through a REST service that drops Future from the domain layer entirely — and closes with a look at where Caprese / saferExceptions takes things next.

Disclaimer: IO monads have other benefits such as referential transparency that are not discussed in this post.


The Stack

Layer Technology
HTTP server Apache Pekko HTTP 1.3.0
HTTP client sttp client4 DefaultSyncBackend
Error handling Either[AppError, A]
DI MacWire (compile-time)
Runtime JDK 21 virtual threads

Why Virtual Threads Change the Signature

With Future, the effect type is viral: every layer that calls async code must return Future[_], carry an implicit ExecutionContext, and use map/flatMap for composition.

// The "classic" async style — effect type spreads everywhere
trait UserApiClient:
  def fetchUser(id: Int)(using ExecutionContext): Future[Either[AppError, User]]

trait UserService:
  def getUserWithPosts(id: Int)(using ExecutionContext): Future[Either[AppError, UserWithPosts]]

Virtual threads (JEP 444, JDK 21) break the assumption that drove this pattern. When a virtual thread blocks on I/O, the JVM simply parks it and reassigns the underlying OS thread to another task. Blocking is free. A synchronous call that waits for a network response no longer monopolises an OS thread.

sttp’s DefaultSyncBackend is built on java.net.HttpClient and blocks the calling thread. On a virtual thread, that cost disappears:

// Direct-style: no Future, no ExecutionContext
trait UserApiClient:
  def fetchUser(id: Int): Either[AppError, User]

trait UserService:
  def getUserWithPosts(id: Int): Either[AppError, UserWithPosts]

The I/O still happens; it just doesn’t need to be announced in the type signature.


Architecture

HTTP Request
    │
    ▼
Pekko HTTP (NIO event-loop)         ← OS threads, non-blocking I/O
    │
    │   Future { ... }              ← hand off to loom Execution Context
    ▼
Virtual Thread (Loom)               ← one per request, ~1 KB heap
    │
    ├─► UserService.getUserWithPosts  ← plain synchronous call
    │       │
    │       ├─► sttp DefaultSyncBackend.send(request)
    │       │       VT parked during network wait ✓
    │       │       OS thread freed ✓
    │       │
    │       └─► Either[AppError, UserWithPosts]
    │
    ▼
Pekko HTTP completes the Future and sends the HTTP response

Pekko HTTP is a reactive framework built on Pekko Streams — it cannot natively dispatch handlers on virtual threads. The solution is a thin boundary in the route layer:

// UserRoutes.scala — the ONLY place Future appears
complete:
  Future:                                   // dispatched on loom Execution Context
    userService.getUserWithPosts(userId) match
      case Right(result) => OK  -> result.asJson.noSpaces
      case Left(e: AppError.NotFound) => NotFound -> e.asJson.noSpaces
      case Left(e) => InternalServerError -> e.asJson.noSpaces

Everything inside the Future { } block is synchronous, direct-style code.


Error Handling with Either

The service layer uses a sealed AppError ADT and Either for typed, composable error propagation. The for-comprehension on Either short-circuits on the first Left:

def getUserWithPosts(id: Int): Either[AppError, UserWithPosts] =
  for
    user  <- getUser(id)                        // Left here → stops immediately
    posts <- userApiClient.fetchPostsForUser(id) // only runs on Right
  yield UserWithPosts(user, posts.map(postToDomain(user.id)))

Compared to Future[Either[_, _]], this reads like ordinary sequential code. The compiler still enforces exhaustive error handling at every call site.


MacWire: Zero-Reflection DI

MacWire resolves the dependency graph at compile time via macros:

trait AppModule:
  lazy val userApiClient: UserApiClient = wire[UserApiClientImpl]
  lazy val userService: UserService     = wire[UserServiceImpl]
  lazy val userRoutes: UserRoutes       = wire[UserRoutes]

A missing dependency or type mismatch is a compile error, not a runtime crash. There is no reflection, no classpath scanning, no startup overhead.


Testing

Because the service layer is purely synchronous, tests need no async machinery at all:

// No ScalaFutures, no PatienceConfig, no .futureValue
"UserService" should "short-circuit Left when user not found" in:
  val svc = makeService(userStub = _ => Left(AppError.NotFound("not found")))
  svc.getUserWithPosts(99) shouldBe Left(AppError.NotFound("not found"))

The sttp stub also runs synchronously:

val stub = SyncBackendStub
  .whenRequestMatches(_.uri.toString.contains("/users/1"))
  .thenRespondAdjust(sampleUser.asJson.noSpaces)

val client  = UserApiClientImpl("https://api.example.com", stub)
val service = UserServiceImpl(client)
service.getUser(1) shouldBe Right(User(UserId("1"), sampleUser.name, sampleUser.email))

Identity is the effect type for synchronous backends — no wrapper, no monad, just a plain value.


The Road Ahead: Caprese and saferExceptions

Caprese is Martin Odersky’s research program on capabilities and effects for Scala. Its first concrete output in the compiler is language.experimental.saferExceptions (available since Scala 3.1, stabilising toward 3.9 LTS), along with language.experimental.captureChecking (Scala 3.4+, improved in 3.8).

The core idea: instead of wrapping errors in a container type (Either, Option, Try), you declare the capability to throw as a compile-time-only implicit parameter. The CanThrow[E] capability is erased before code generation — zero runtime cost.

What the current service looks like with Either

// Current approach — Either monad
def fetchUser(id: Int): Either[AppError, User] =
  try
    val response = request.send(backend)
    response.body match
      case Right(user) => Right(toDomain(user))
      case Left(_)     => Left(AppError.NotFound(s"User $id not found"))
  catch
    case ex: Exception => Left(AppError.Unexpected(ex.getMessage, ex))

Error types live in the return value. The caller must unwrap Either explicitly.

The same code with saferExceptions (Scala 3.x experimental)

import scala.language.experimental.saferExceptions

// Typed exception classes — must extend Exception, not RuntimeException
class NotFoundException(msg: String)         extends Exception(msg)
class ExternalServiceException(msg: String)  extends Exception(msg)

// The `throws` clause is syntactic sugar for (using CanThrow[E])
def fetchUser(id: Int): User throws NotFoundException | ExternalServiceException =
  val response = request.send(backend)
  response.body match
    case Right(user) => toDomain(user)
    case Left(_) if response.code.code == 404 =>
      throw NotFoundException(s"User $id not found")
    case Left(err) =>
      throw ExternalServiceException(err.getMessage)

And the service layer composes naturally:

def getUserWithPosts(id: Int): UserWithPosts
    throws NotFoundException | ExternalServiceException =
  val user  = fetchUser(id)        // throws propagate automatically
  val posts = fetchPostsForUser(id)
  UserWithPosts(user, posts.map(postToDomain(user.id)))

The throws clause desugars to (using CanThrow[NotFoundException | ExternalServiceException]). This capability is erased at runtime — it exists only to make the compiler verify that every call site either handles the exception or declares it in its own throws clause.

The route layer becomes the explicit error boundary:

complete:
  Future:
    try
      val result = userService.getUserWithPosts(userId)
      OK -> result.asJson.noSpaces
    catch
      case _: NotFoundException        => NotFound      -> ...
      case _: ExternalServiceException => ServiceUnavailable -> ...

Either vs saferExceptions — a practical comparison

  Either[AppError, A] saferExceptions (throws)
Error visibility In return type In throws clause
Composition for-comprehension Sequential (direct-style)
Runtime cost Allocation of Right/Left Zero (erased)
Exhaustiveness Pattern match on Either Compiler checks CanThrow at call sites
Maturity Stable, production-ready Experimental

Readers familiar with Java’s checked exceptions will notice the surface similarity: both systems force the compiler to track which errors a method can produce. Java checked exceptions issues that Caprese aims to avoid are discussed here.

The bigger picture: captureChecking

saferExceptions is just one application of Caprese’s broader capability model — and a limited one: it cannot track exceptions thrown inside lambdas passed to higher-order functions. captureChecking lifts this restriction and generalises beyond exceptions: with language.experimental.captureChecking, the compiler can verify that any capability — as an example, a database connection — does not escape the scope where it was introduced. See this page for more details.


Summary

Concern Solution Key benefit
Non-blocking I/O JDK 21 virtual threads No async wrapper needed
HTTP client sttp DefaultSyncBackend Plain synchronous calls
Error handling Either[AppError, A] Typed, composable, stable
DI MacWire Compile-time safety
Future direction Caprese saferExceptions Zero-cost, direct-style typed errors

The architecture makes a deliberate bet: Loom removes the need for async types in the domain layer. The Future boundary is pushed to the very edge — where Pekko HTTP hands off to the application — and the rest of the code is plain, readable, synchronous Scala. As Caprese matures, Either can gradually give way to throws, further simplifying return types while keeping the same compile-time safety guarantees.

comments powered by Disqus