A Scala REST API in 2026, and beyond
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.