Temps de lecture estimé : 9 minutes
En bref : depuis JDK 21, les virtual threads rendent obsolète la propagation systématique de Future dans les API REST Scala. Cet article montre comment construire une API REST en Scala 3 direct-style, sans monade d'effet (ou presque), en s'appuyant sur Pekko HTTP, sttp DefaultSyncBackend et Either[AppError, A]. Nous concluons sur l'évolution attendue avec Caprese et saferExceptions, qui devraient à terme remplacer Either par des exceptions typées à coût zéro.
Note : les monades d'effet (IO, ZIO, cats-effect) conservent d'autres bénéfices, notamment la transparence référentielle, qui ne sont pas l'objet de cet article.
Pourquoi remettre en cause Future En 2026 ?
Pendant dix ans, la règle implicite en Scala a été : si tu fais de l'I/O, tu retournes Future (ou IO). Les signatures se contaminent, chaque couche doit "parler async", et ExecutionContext se propage partout.
Le projet Loom change cette équation. Avec les virtual threads (JEP 444, livrés en stable dans JDK 21), un thread qui bloque sur un appel I/O ne monopolise plus un thread OS : la JVM le parke et réassigne le thread porteur à une autre tâche. Bloquer redevient gratuit.
La stack
| Couche | Technologie |
| Serveur HTTP | Apache Pekko HTTP 1.3.0 |
| Client HTTP | sttp client4 DefaultSyncBackend |
| Gestion d'erreur | Either[AppError, A] |
| Injection de dépendances | MacWire (compile-time) |
| Runtime | JDK 21 virtual threads |
De la signature virale à la signature directe
Avec
Future, l'effet contamine chaque couche :
// Style asynchrone classique — l'effet se propage partout
trait UserApiClient:
def fetchUser(id: Int)(using ExecutionContext): Future[Either[AppError, User]]
trait UserService:
def getUserWithPosts(id: Int)(using ExecutionContext): Future[Either[AppError, UserWithPosts]]
DefaultSyncBackend de sttp s'appuie sur
java.net.HttpClient et bloque le thread appelant. Sur un virtual thread, ce coût disparaît :
// Direct-style : ni Future, ni ExecutionContext
trait UserApiClient:
def fetchUser(id: Int): Either[AppError, User]
trait UserService:
def getUserWithPosts(id: Int): Either[AppError, UserWithPosts]
L'I/O a toujours lieu. Elle n'a simplement plus besoin d'être annoncée dans la signature.
Architecture en un coup d'œil
Requête HTTP
│
▼
Pekko HTTP (event-loop NIO) ← threads OS, I/O non bloquante
│
│ Future { ... } ← hand-off vers l'EC Loom
▼
Virtual Thread (Loom) ← un par requête, ~1 KB heap
│
├─► UserService.getUserWithPosts ← appel synchrone simple
│ │
│ ├─► sttp DefaultSyncBackend.send(request)
│ │ VT parké pendant l'attente réseau ✓
│ │ Thread OS libéré ✓
│ │
│ └─► Either[AppError, UserWithPosts]
│
▼
Pekko HTTP complète la Future et renvoie la réponse HTTP
Pekko HTTP est un framework réactif bâti sur Pekko Streams : il ne peut pas dispatcher nativement ses handlers sur des virtual threads. La solution est une fine frontière dans la couche route :
// UserRoutes.scala — le SEUL endroit où Future apparaît
complete:
Future: // dispatché sur l'EC Loom
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
Tout ce qui est à l'intérieur du bloc Future { } est du code synchrone direct-style.
Gestion d'erreur avec Either
La couche service utilise un ADT scellé
AppError et
Either pour une propagation d'erreur typée et composable. La for-comprehension court-circuite au premier
Left :
def getUserWithPosts(id: Int): Either[AppError, UserWithPosts] =
for
user <- getUser(id) // Left ici → stoppe immédiatement
posts <- userApiClient.fetchPostsForUser(id) // ne s'exécute que sur Right
yield UserWithPosts(user, posts.map(postToDomain(user.id)))
Comparé à Future[Either[_, _]], cela se lit comme du code séquentiel ordinaire — tout en gardant la vérification exhaustive d'erreur à chaque site d'appel.
MacWire : DI sans réflexion
MacWire résout le graphe de dépendances à la compilation via des macros :
trait AppModule:
lazy val userApiClient: UserApiClient = wire[UserApiClientImpl]
lazy val userService: UserService = wire[UserServiceImpl]
lazy val userRoutes: UserRoutes = wire[UserRoutes]
Une dépendance manquante ou un type incompatible devient une erreur de compilation, pas un crash runtime. Aucune réflexion, aucun scan de classpath, aucun overhead de démarrage.
Tests : la synchronie comme cadeau
La couche service étant purement synchrone, les tests n'ont besoin d'aucune machinerie async :
// Ni ScalaFutures, ni PatienceConfig, ni .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"))
Le stub sttp tourne lui aussi de façon synchrone :
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 est le type d'effet pour les backends synchrones : pas de wrapper, pas de monade, juste une valeur.
L'horizon : Caprese et saferExceptions
Caprese est le programme de recherche de Martin Odersky sur les capabilities et les effets en Scala. Sa première sortie concrète dans le compilateur est language.experimental.saferExceptions (disponible depuis Scala 3.1, en stabilisation vers 3.9 LTS), accompagnée de language.experimental.captureChecking (Scala 3.4+, amélioré en 3.8).
L'idée centrale : au lieu d'encapsuler les erreurs dans un container (Either, Option, Try), on déclare la capability de lever via un paramètre implicite de compile-time uniquement. La capability CanThrow[E] est effacée avant la génération de code -> coût runtime nul.
Ce que donne le service actuel avec Either
// Approche actuelle — 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))
Le même code avec saferExceptions
import scala.language.experimental.saferExceptions
class NotFoundException(msg: String) extends Exception(msg)
class ExternalServiceException(msg: String) extends Exception(msg)
// La clause `throws` est du sucre syntaxique pour (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)
La composition redevient triviale :
def getUserWithPosts(id: Int): UserWithPosts
throws NotFoundException | ExternalServiceException =
val user = fetchUser(id)
val posts = fetchPostsForUser(id)
UserWithPosts(user, posts.map(postToDomain(user.id)))
Either Vs saferExceptions : comparaison pratique
| Either[AppError, A] | SaferExceptions (throws) |
| Visibilité de l'erreur | Dans le type de retour | Dans la clause throws |
| Composition | for-comprehension | Séquentielle (direct-style) |
| Coût runtime | Allocation de Right/Left | Zéro (effacé) |
| Exhaustivité | Pattern match sur Either | Compilateur vérifie CanThrow |
| Maturité | Stable, prod-ready | Expérimental |
Les développeurs Java reconnaîtront une parenté avec les checked exceptions. Les écueils historiques que Caprese cherche à éviter sont détaillés ici.
Au-delà : captureChecking
saferExceptions n'est qu'une application limitée du modèle de capability plus large de Caprese : elle ne peut pas tracer les exceptions levées dans les lambdas passées à des fonctions d'ordre supérieur. captureChecking lève cette restriction et généralise au-delà des exceptions : le compilateur peut vérifier qu'une capability quelconque (par exemple une connexion à une base) ne s'échappe pas du scope où elle a été introduite.
Synthèse
| Préoccupation | Solution | Bénéfice clé |
| I/O non bloquante | Virtual threads JDK 21 | Plus de wrapper async |
| Client HTTP | sttp DefaultSyncBackend | Appels synchrones simples |
| Gestion d'erreur | Either[AppError, A] | Typé, composable, stable |
| DI | MacWire | Sûreté à la compilation |
| Direction future | Caprese saferExceptions | Erreurs typées coût zéro |
Le pari architectural est explicite : Loom retire le besoin de types asynchrones dans presque toutes les couches de l’application.. La frontière Future est repoussée à l'extrême périphérie, là où Pekko HTTP fait le hand-off vers l'application, et tout le reste est du Scala lisible, séquentiel, synchrone. À mesure que Caprese mûrit, Either pourra céder progressivement la place à throws, sans perdre les garanties de sûreté à la compilation.
Article original (en anglais) sur le blog de Loïc : https://loicdescotte.github.io/posts/scala-rest-2026/
Références
FAQ
Les virtual threads remplacent-ils complètement Future En Scala ?
Non. Ils retirent le besoin de Future pour la gestion de l'I/O bloquante, mais Future reste utile pour la concurrence structurée légère et l'interopérabilité avec les frameworks réactifs comme Pekko.
Peut-on utiliser cette approche avec Akka HTTP plutôt que Pekko HTTP ?
Oui, le même pattern s'applique : la frontière Future reste dans la couche route, le reste est direct-style. Akka HTTP impose toutefois sa licence BSL depuis 2022.
saferExceptions est-il prêt pour la production ?
Non, c'est un flag expérimental. Il est utilisable pour du prototypage et de la R&D interne, mais une migration de codebase production attendra la stabilisation visée pour Scala 3.9 LTS.
Quel est l'impact mémoire des virtual threads ?
Environ 1 KB de heap par thread virtuel au repos, contre plusieurs MB pour un thread OS. Un service traitant 100 000 requêtes concurrentes devient envisageable sur des machines modestes.
Que vaut sttp DefaultSyncBackend Face à HttpClientBackend A sync ?
En contexte virtual threads, le sync backend est plus simple, sans surcoût mesurable et avec une stack-trace claire. L'async backend reste pertinent pour les runtimes pré-JDK 21 ou pour du streaming.
A propos de l'auteur
Loïc Descotte est Software Architect et Manager chez Kaizen Solutions depuis 2018, où il dirige le Competence Center Data, Algo & AI. Développeur Scala depuis plus de quinze ans, il a piloté la migration Java → Scala des équipes de Kelkoo Group et conçoit aujourd'hui des architectures Big Data autour de Spark et Databricks. Formateur Scala et programmation fonctionnelle (en interne, en clientèle et à l'Université Grenoble Alpes entre 2020 et 2021), il contribue à l'écosystème open source via
scala-hamsters, une micro-bibliothèque utilitaire Scala qu'il a créée.
LinkedIn ·
GitHub ·
Autres articles de Loïc ·
Blog de Loïc
Retour aux articles