Menu
Scala REST API en 2026 : direct-style, virtual threads et l'arrivée de Caprese

Scala REST API en 2026 : direct-style, virtual threads et l'arrivée de Caprese

Par Loïc DESCOTTE

05.06.2026

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 HTTPApache 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 retourDans la clause throws
Composition  for-comprehensionSéquentielle (direct-style)
Coût runtime  Allocation de Right/LeftZéro (effacé)
Exhaustivité  Pattern match sur EitherCompilateur 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éoccupationSolution Bénéfice clé
 I/O non bloquanteVirtual threads JDK 21 Plus de wrapper async
 Client HTTPsttp DefaultSyncBackend Appels synchrones simples
 Gestion d'erreurEither[AppError, A] Typé, composable, stable
 DIMacWire 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

C'est à lire...