Aller au contenu

Formation complète : VAVR & Either en Java

Public cible : Développeurs Java intermédiaires/avancés
Prérequis : Java 11+, Maven, notions de Spring Boot


Table des matières

  1. Introduction — Pourquoi VAVR ?
  2. Les fondements : la programmation fonctionnelle en Java
  3. Option — Dire adieu au NullPointerException
  4. Try — Gérer les exceptions fonctionnellement
  5. Either — La vedette du cours
  6. Validation — Accumuler les erreurs
  7. Les Collections VAVR
  8. VAVR avec Spring Boot
  9. Bonnes pratiques et patterns avancés
  10. TP Final — Banque Fonctionnelle

1. Introduction — Pourquoi VAVR ?

1.1 Le problème avec Java “classique”

Imaginez ce scénario courant dans une application Java traditionnelle :

// Code Java "classique" — plein de pièges !
public User findUserById(Long id) {
    return userRepository.findById(id); // Peut retourner null !
}

public String getCity(Long userId) {
    User user = findUserById(userId);   // Peut être null
    if (user == null) return "Inconnu";
    
    Address address = user.getAddress(); // Peut être null
    if (address == null) return "Inconnu";
    
    City city = address.getCity();       // Peut être null
    if (city == null) return "Inconnu";
    
    return city.getName();
}

Ce code souffre de plusieurs problèmes :

  • Il est verbeux : beaucoup de vérifications null
  • Il est fragile : oublier un null check = NullPointerException en production
  • Il cache l’intention : le code métier est noyé dans la gestion d’erreurs
  • Il est difficile à composer : on ne peut pas chaîner facilement les opérations

1.2 Qu’est-ce que VAVR ?

VAVR (anciennement Javaslang) est une bibliothèque Java qui apporte les concepts de la programmation fonctionnelle directement dans vos projets Java.

Pensez à VAVR comme un “kit de mise à niveau fonctionnel” pour Java. Il s’inspire de langages comme Scala et Haskell.

1.3 Ce que VAVR apporte

Problème Java classique Solution VAVR
nullNullPointerException Option<T>
try/catch imbriqués Try<T>
Pas de représentation d’erreur typée Either<L, R>
Accumuler des erreurs de validation Validation<E, T>
Collections mutables Collections immuables
Lambdas avec exceptions vérifiées Fonctions VAVR

1.4 Installation

Ajoutez la dépendance Maven dans votre pom.xml :

<dependency>
    <groupId>io.vavr</groupId>
    <artifactId>vavr</artifactId>
    <version>0.10.4</version>
</dependency>

Note : VAVR 0.10.x est compatible Java 8+. Pour Java 21+, cette version fonctionne toujours très bien.


2. Les fondements : la programmation fonctionnelle en Java

Avant de plonger dans VAVR, il faut comprendre les concepts sur lesquels il repose.

2.1 Fonctions pures

Une fonction pure est une fonction qui :

  1. Retourne toujours le même résultat pour les mêmes entrées
  2. N’a aucun effet de bord (pas d’écriture en base, pas de log, pas de modification d’état global)
//  Fonction pure — prévisible, testable
public int additionner(int a, int b) {
    return a + b;
}

//  Fonction impure — effet de bord (log), résultat potentiellement différent
public int additionnerAvecLog(int a, int b) {
    System.out.println("Calcul en cours..."); // Effet de bord !
    return a + b;
}

Pourquoi c’est important ? Les fonctions pures sont facilement testables, composables, et parallélisables.

2.2 Immutabilité

Un objet immuable ne peut pas être modifié après sa création. En Java, String est immuable. VAVR étend ce concept à toutes ses structures de données.

// Objet mutable — DANGEREUX dans un contexte concurrent
List<String> listeMutable = new ArrayList<>();
listeMutable.add("a"); // Modifie l'état !

// Objet immuable VAVR — SÛRE
io.vavr.collection.List<String> listeImmuable = List.of("a", "b", "c");
io.vavr.collection.List<String> nouvelleListe = listeImmuable.prepend("z");
// listeImmuable est INCHANGÉE, nouvelleListe est une nouvelle liste

2.3 Les types algébriques (Sum Types)

C’est un concept fondamental pour comprendre Either et Option.

Un type somme (ou sum type) représente une valeur parmi plusieurs possibilités mutuellement exclusives.

Option<T> = Some(T)  |  None
Either<L,R> = Left(L)  |  Right(R)
Try<T>     = Success(T) |  Failure(Exception)

Analogie avec le tennis de table : Lors d’un échange, soit la balle passe (succès), soit elle ne passe pas (échec). Il n’y a pas de troisième option — c’est exactement ce que modélise Either.

2.4 Le pattern Monade (simplifié)

Une monade est un conteneur qui permet de chaîner des opérations de manière sécurisée.

// Concept de base : map et flatMap
// map   : transforme la valeur à l'intérieur sans changer le conteneur
// flatMap: transforme la valeur ET peut changer le conteneur

Option<Integer> nombre = Option.of(5);
Option<Integer> double_ = nombre.map(n -> n * 2);        // Option.of(10)
Option<String>  texte   = nombre.map(n -> "Résultat: " + n); // Option.of("Résultat: 5")

3. Option — Dire adieu au NullPointerException

3.1 Le concept

Option<T> est un conteneur qui représente soit une valeur présente (Some<T>), soit une absence de valeur (None).

C’est l’équivalent VAVR du Optional<T> de Java 8, mais avec beaucoup plus de méthodes utilitaires.

Option<T>
├── Some<T>  — contient une valeur de type T
└── None     — ne contient rien (jamais null !)

3.2 Créer une Option

import io.vavr.control.Option;

// Depuis une valeur
Option<String> avecValeur = Option.of("Bonjour");    // Some("Bonjour")
Option<String> sansValeur = Option.of(null);         // None  (jamais NullPointerException !)
Option<String> none       = Option.none();           // None  (explicite)
Option<String> some       = Option.some("Monde");    // Some("Monde")

// Vérification
System.out.println(avecValeur.isDefined()); // true
System.out.println(sansValeur.isDefined()); // false
System.out.println(sansValeur.isEmpty());   // true

3.3 Récupérer la valeur

Option<String> option = Option.of("VAVR");

// Méthode 1 : getOrElse — valeur par défaut si None
String valeur1 = option.getOrElse("Défaut");          // "VAVR"
String valeur2 = Option.none().getOrElse("Défaut");   // "Défaut"

// Méthode 2 : getOrElseGet — valeur calculée paresseusement
String valeur3 = Option.none().getOrElseGet(() -> calculerValeurParDefaut());

// Méthode 3 : getOrElseThrow — lancer une exception si None
String valeur4 = option.getOrElseThrow(() -> new RuntimeException("Valeur absente !"));

// Méthode 4 : get() — ATTENTION ! Lance NoSuchElementException si None
// À utiliser seulement si vous êtes sûr que la valeur est présente
String valeur5 = option.get(); // OK car option est Some

3.4 Transformer une Option

Option<String> nom = Option.of("marie");

// map : transforme la valeur si présente
Option<String> nomMajuscule = nom.map(String::toUpperCase);  // Some("MARIE")
Option<Integer> longueur    = nom.map(String::length);       // Some(5)

// flatMap : pour les transformations qui retournent déjà une Option
Option<String> nomValide = nom.flatMap(n ->
    n.length() > 2 ? Option.of(n) : Option.none()
); // Some("marie") car longueur > 2

// filter : garde la valeur seulement si condition vraie
Option<String> nomLong = nom.filter(n -> n.length() > 10); // None (5 <= 10)

// peek : effectuer un effet de bord sans modifier (utile pour les logs)
nom.peek(n -> System.out.println("Nom trouvé : " + n));

Remarque sur peek() : Utilisez cette méthode pour du logging ou du débogage, on l’utilise pour des effets de bords (affichage d’info intermédiaires sans forcément de rapports avec le résultat ou la finalité d’un Either, Option ou Try).

Il ne modifie pas la valeur du conteneur : il n’a aucun effet sur la valeur du conteneur. Il permet uniquement d’exécuter une action (comme du logging) sur la valeur.

Si le conteneur est vide (Option.none()) : peek() ne fait rien, (None, Failure, Left), la fonction passée à peek() n’est pas exécutée !

On peut utiliser peek() avec n’importe quel type de Vavr :

3.5 Exemple concret : Recherche en base de données

// sans VAVR
public String getTelephoneUtilisateur(Long id) {
    User user = userRepository.findById(id);
    if (user == null) return "Non renseigné";
    
    Contact contact = user.getContact();
    if (contact == null) return "Non renseigné";
    
    return contact.getTelephone() != null ? contact.getTelephone() : "Non renseigné";
}

// avec VAVR — même logique, code plus expressif
public String getTelephoneUtilisateur(Long id) {
    return Option.of(userRepository.findById(id))    // Option<User>
        .flatMap(user -> Option.of(user.getContact())) // Option<Contact>
        .flatMap(contact -> Option.of(contact.getTelephone())) // Option<String>
        .getOrElse("Non renseigné");
}

Ce qu’il faut retenir : Option rend l’absence de valeur explicite dans le type. Le compilateur vous force à gérer les deux cas.


4. Try — Gérer les exceptions fonctionnellement

4.1 Le problème des exceptions en Java

Les exceptions Java brisent le flux fonctionnel. On ne peut pas faire :

//  Ceci ne compile pas — parseInt peut lancer NumberFormatException
List<Integer> nombres = List.of("1", "deux", "3")
    .stream()
    .map(Integer::parseInt)  // ERREUR : exception vérifiée/non vérifiée
    .collect(Collectors.toList());

4.2 Le concept Try

Try<T> est un conteneur qui représente soit un succès (Success<T>) soit un échec (Failure<Throwable>).

Try<T>
├── Success<T>   — contient le résultat de type T
└── Failure<T>   — contient l'exception qui a été levée

4.3 Créer et utiliser Try

import io.vavr.control.Try;

// Créer un Try depuis une opération qui peut échouer
Try<Integer> resultat1 = Try.of(() -> Integer.parseInt("42"));    // Success(42)
Try<Integer> resultat2 = Try.of(() -> Integer.parseInt("abc"));   // Failure(NumberFormatException)

// Vérification
System.out.println(resultat1.isSuccess()); // true
System.out.println(resultat2.isFailure()); // true

// Récupérer la valeur
int valeur = resultat1.getOrElse(0);        // 42
int defaut = resultat2.getOrElse(0);        // 0

// Mapper le résultat si succès
Try<String> texte = resultat1.map(n -> "Nombre: " + n); // Success("Nombre: 42")

// Récupérer l'exception si échec
Throwable exception = resultat2.getCause(); // NumberFormatException

4.4 Chaîner des opérations risquées

// Lecture d'un fichier, parsing JSON, accès à un champ
Try<String> contenuFichier = Try.of(() -> Files.readString(Path.of("config.json")))
    .flatMap(contenu -> Try.of(() -> parseJson(contenu)))
    .map(json -> json.getString("host"))
    .recover(IOException.class, e -> "localhost")
    .recover(ParseException.class, e -> "localhost");

String host = contenuFichier.getOrElse("localhost");

4.5 Transformer Try en Either

// Try et Either sont liés — on peut convertir
Try<Integer> tryResult = Try.of(() -> Integer.parseInt("abc"));

Either<Throwable, Integer> either = tryResult.toEither();
// Left(NumberFormatException) si échec
// Right(42) si succès

5. Either — La vedette du cours

5.1 Qu’est-ce que Either ?

Either<L, R> est probablement le type le plus puissant de VAVR pour la gestion d’erreurs métier.

Contrairement à Try qui représente succès/exception, Either représente deux cas quelconques :

Mnémotechnique : “Right is right” (correct/succès). Pensez à “avoir raison” = Right.

Either<L, R>
├── Left<L>   — erreur, cas alternatif (Left = "Mauvais")
└── Right<R>  — succès, résultat attendu (Right = "Correct")

5.2 Pourquoi Either plutôt que des exceptions ?

Critère Exception Either
Visibilité Cachée (non indiquée dans le type) Visible dans le type de retour
Composition Difficile Facile avec map, flatMap
Erreurs métier Inadapté Parfait
Performance Coûteuse (stack trace) Légère
Testabilité Difficile Simple

Exemple : Si une méthode retourne Either<String, User>, vous savez immédiatement à la lecture de la signature que ça peut échouer avec un message String.

5.3 Créer un Either

import io.vavr.control.Either;

// Créer un Right (succès)
Either<String, Integer> succes = Either.right(42);

// Créer un Left (erreur)
Either<String, Integer> erreur = Either.left("Le nombre doit être positif");

// Vérification
System.out.println(succes.isRight()); // true
System.out.println(erreur.isLeft());  // true
System.out.println(succes.get());     // 42
System.out.println(erreur.getLeft()); // "Le nombre doit être positif"

5.4 Exemple fondateur : Validation d’âge

public Either<String, Integer> validerAge(int age) {
    if (age < 0) {
        return Either.left("L'âge ne peut pas être négatif");
    }
    if (age > 150) {
        return Either.left("L'âge semble irréaliste");
    }
    return Either.right(age);
}

// Utilisation
Either<String, Integer> resultat = validerAge(25);
if (resultat.isRight()) {
    System.out.println("Âge valide : " + resultat.get());
} else {
    System.out.println("Erreur : " + resultat.getLeft());
}

5.5 Transformer un Either avec map

map s’applique uniquement sur le Right (le succès). Si c’est un Left, la valeur d’erreur est propagée sans modification.

Either<String, Integer> age = validerAge(25);

// map transforme le Right
Either<String, String> message = age.map(a -> "Vous avez " + a + " ans");
// Right("Vous avez 25 ans")

Either<String, Integer> ageErreur = validerAge(-5);
Either<String, String> messageErreur = ageErreur.map(a -> "Vous avez " + a + " ans");
// Left("L'âge ne peut pas être négatif") — le Left est propagé !

Règle fondamentale : Quand vous appelez map sur un Left, le Left est automatiquement propagé. C’est ce qu’on appelle le court-circuit — comme dans un circuit électrique : si un composant est en panne, le courant ne passe pas.

5.6 Transformer avec mapLeft

mapLeft fait la même chose mais pour le cas Left :

Either<String, Integer> erreur = Either.left("ERREUR_AGE");

// mapLeft transforme le Left
Either<ErrorCode, Integer> avecCode = erreur.mapLeft(msg -> ErrorCode.of(msg));
// Left(ErrorCode.ERREUR_AGE)

Either<String, Integer> succes = Either.right(42);
Either<ErrorCode, Integer> succesInchange = succes.mapLeft(msg -> ErrorCode.of(msg));
// Right(42) — le Right est propagé sans changement

5.7 Chaîner avec flatMap

flatMap permet de chaîner plusieurs opérations qui peuvent chacune échouer. C’est le cœur de la puissance de Either.

public Either<String, User> trouverUtilisateur(Long id) {
    return Option.of(userRepository.findById(id))
        .toEither("Utilisateur introuvable pour l'id " + id);
}

public Either<String, User> validerUtilisateurActif(User user) {
    if (!user.isActif()) {
        return Either.left("L'utilisateur " + user.getEmail() + " est désactivé");
    }
    return Either.right(user);
}

public Either<String, String> getEmailUtilisateurActif(Long id) {
    return trouverUtilisateur(id)          // Either<String, User>
        .flatMap(this::validerUtilisateurActif) // Either<String, User>
        .map(User::getEmail);              // Either<String, String>
}

Ce qui se passe étape par étape :

1. trouverUtilisateur(1L)
   → Right(User{id=1, email="alice@ex.com", actif=true})

2. .flatMap(validerUtilisateurActif)
   → Right(User{id=1, email="alice@ex.com", actif=true})  (user est actif)

3. .map(User::getEmail)
   → Right("alice@ex.com")

--- Si l'utilisateur n'existe pas ---

1. trouverUtilisateur(999L)
   → Left("Utilisateur introuvable pour l'id 999")

2. .flatMap(validerUtilisateurActif)
   → Left("Utilisateur introuvable pour l'id 999")  ← COURT-CIRCUIT !
   (validerUtilisateurActif n'est JAMAIS appelée)

3. .map(User::getEmail)
   → Left("Utilisateur introuvable pour l'id 999")  ← COURT-CIRCUIT encore !

Point clé : Le court-circuit automatique évite les cascades de if imbriqués. Si une étape échoue, les suivantes sont ignorées.

5.8 Pattern matching avec fold

fold permet de gérer les deux cas en une seule expression :

Either<String, Integer> resultat = validerAge(25);

// fold : premier argument = fonction pour Left, second = fonction pour Right
String message = resultat.fold(
    erreur  -> " Erreur : " + erreur,
    valeur  -> " Valeur valide : " + valeur
);
System.out.println(message); // " Valeur valide : 25"

5.9 Pattern matching avec match

import io.vavr.API.*;
import io.vavr.Predicates.*;

Either<String, Integer> either = validerAge(-1);

String resultat = Match(either).of(
    Case($(Either::isLeft),  e -> "Erreur : " + e.getLeft()),
    Case($(Either::isRight), e -> "OK : " + e.get())
);

5.10 Gérer le Right avec orElse

Either<String, Integer> result = Either.left("Erreur");

// orElse : retourne un autre Either si c'est un Left
Either<String, Integer> fallback = result.orElse(Either.right(0));
// Right(0)

// orElseGet : calcule le fallback paresseusement
Either<String, Integer> fallback2 = result.orElseGet(() -> Either.right(calculerValeurDefaut()));

5.11 Convertir Either vers d’autres types

Either<String, Integer> either = Either.right(42);

// Vers Option
Option<Integer> option = either.toOption();  // Some(42)

// Vers Try
Try<Integer> tryResult = either.toTry();     // Success(42)

// Vers Optional Java standard
Optional<Integer> optional = either.toJavaOptional(); // Optional.of(42)

// Vers List (utile pour flatMap sur des streams)
io.vavr.collection.List<Integer> list = either.toList(); // List(42)

5.12 Exemple complet : Service de paiement

Voici un exemple réaliste qui illustre la puissance de Either :

public class ServicePaiement {

    // Types d'erreurs métier — bien typés !
    public sealed interface ErreurPaiement permits
        ErreurPaiement.CompteInexistant,
        ErreurPaiement.SoldeInsuffisant,
        ErreurPaiement.MontantInvalide,
        ErreurPaiement.CompteBloque {

        record CompteInexistant(Long compteId) implements ErreurPaiement {}
        record SoldeInsuffisant(BigDecimal solde, BigDecimal montant) implements ErreurPaiement {}
        record MontantInvalide(String raison) implements ErreurPaiement {}
        record CompteBloque(String raison) implements ErreurPaiement {}
    }

    public Either<ErreurPaiement, Paiement> effectuerPaiement(
            Long compteId, BigDecimal montant) {

        return validerMontant(montant)
            .flatMap(m -> trouverCompte(compteId))
            .flatMap(this::verifierCompteActif)
            .flatMap(compte -> verifierSolde(compte, montant))
            .flatMap(compte -> executerDebit(compte, montant));
    }

    private Either<ErreurPaiement, BigDecimal> validerMontant(BigDecimal montant) {
        if (montant == null || montant.compareTo(BigDecimal.ZERO) <= 0) {
            return Either.left(new ErreurPaiement.MontantInvalide(
                "Le montant doit être strictement positif"
            ));
        }
        return Either.right(montant);
    }

    private Either<ErreurPaiement, Compte> trouverCompte(Long id) {
        return Option.of(compteRepository.findById(id))
            .toEither(new ErreurPaiement.CompteInexistant(id));
    }

    private Either<ErreurPaiement, Compte> verifierCompteActif(Compte compte) {
        if (compte.isBloque()) {
            return Either.left(new ErreurPaiement.CompteBloque(
                "Compte bloqué depuis le " + compte.getDateBlocage()
            ));
        }
        return Either.right(compte);
    }

    private Either<ErreurPaiement, Compte> verifierSolde(Compte compte, BigDecimal montant) {
        if (compte.getSolde().compareTo(montant) < 0) {
            return Either.left(new ErreurPaiement.SoldeInsuffisant(
                compte.getSolde(), montant
            ));
        }
        return Either.right(compte);
    }

    private Either<ErreurPaiement, Paiement> executerDebit(Compte compte, BigDecimal montant) {
        return Try.of(() -> {
            compte.debiter(montant);
            return compteRepository.save(compte);
            // ... retourner un objet Paiement
        })
        .toEither()
        .mapLeft(ex -> new ErreurPaiement.MontantInvalide("Erreur technique: " + ex.getMessage()));
    }
}

// Utilisation dans le contrôleur
public ResponseEntity<?> payer(Long compteId, BigDecimal montant) {
    return servicePaiement.effectuerPaiement(compteId, montant)
        .fold(
            erreur -> switch (erreur) {
                case ErreurPaiement.CompteInexistant e ->
                    ResponseEntity.notFound().build();
                case ErreurPaiement.SoldeInsuffisant e ->
                    ResponseEntity.badRequest().body("Solde insuffisant: " + e.solde());
                case ErreurPaiement.CompteBloque e ->
                    ResponseEntity.status(403).body(e.raison());
                case ErreurPaiement.MontantInvalide e ->
                    ResponseEntity.badRequest().body(e.raison());
            },
            paiement -> ResponseEntity.ok(paiement)
        );
}

5.13 Either avec des collections

// Transformer une liste d'éléments, en collectant les erreurs
List<String> inputs = List.of("1", "deux", "3", "quatre", "5");

// Séparer succès et échecs
List<Either<String, Integer>> resultats = inputs.stream()
    .map(s -> {
        try {
            return Either.<String, Integer>right(Integer.parseInt(s));
        } catch (NumberFormatException e) {
            return Either.<String, Integer>left("'" + s + "' n'est pas un nombre");
        }
    })
    .toList();

List<Integer> succes = resultats.stream()
    .filter(Either::isRight)
    .map(Either::get)
    .toList(); // [1, 3, 5]

List<String> erreurs = resultats.stream()
    .filter(Either::isLeft)
    .map(Either::getLeft)
    .toList(); // ["'deux' n'est pas un nombre", "'quatre' n'est pas un nombre"]

6. Validation — Accumuler les erreurs

6.1 La limite de Either pour la validation

Avec Either, dès qu’une erreur survient, on s’arrête (court-circuit). Cela pose problème pour la validation de formulaires : l’utilisateur veut voir toutes les erreurs en même temps, pas une par une.

// Problème : avec Either, on n'a qu'une erreur à la fois
public Either<String, User> validerUser(String nom, String email, int age) {
    if (nom.isBlank()) return Either.left("Nom requis");
    if (!email.contains("@")) return Either.left("Email invalide");  // Jamais vu si nom est vide
    if (age < 0) return Either.left("Âge invalide");                 // Jamais vu si email invalide
    return Either.right(new User(nom, email, age));
}

6.2 Validation VAVR

Validation<E, T> accumule les erreurs au lieu de s’arrêter à la première.

Validation<E, T>
├── Valid<T>     — succès (comme Right)
└── Invalid<E>  — erreur(s) accumulées (comme Left, mais peut en contenir plusieurs)

6.3 Utilisation de Validation

import io.vavr.control.Validation;
import io.vavr.collection.Seq;

public class ValidateurUser {

    public Validation<String, String> validerNom(String nom) {
        return (nom != null && !nom.isBlank())
            ? Validation.valid(nom)
            : Validation.invalid("Le nom est requis");
    }

    public Validation<String, String> validerEmail(String email) {
        return (email != null && email.contains("@"))
            ? Validation.valid(email)
            : Validation.invalid("L'email doit contenir '@'");
    }

    public Validation<String, Integer> validerAge(int age) {
        return (age >= 0 && age <= 150)
            ? Validation.valid(age)
            : Validation.invalid("L'âge doit être entre 0 et 150");
    }

    // combineAll — combine plusieurs Validation en accumulant les erreurs
    public Validation<Seq<String>, User> validerUser(String nom, String email, int age) {
        return Validation.combine(
            validerNom(nom),
            validerEmail(email),
            validerAge(age)
        ).ap(User::new);
        // Seq<String> = liste de toutes les erreurs accumulées
    }
}

// Utilisation
ValidateurUser validateur = new ValidateurUser();

Validation<Seq<String>, User> resultat = validateur.validerUser("", "email_sans_arobase", -5);

if (resultat.isInvalid()) {
    resultat.getError().forEach(System.out::println);
    // "Le nom est requis"
    // "L'email doit contenir '@'"
    // "L'âge doit être entre 0 et 150"
    // Toutes les erreurs en une fois !
}

6.4 Convertir Validation en Either

Validation<Seq<String>, User> validation = validateur.validerUser("Alice", "alice@test.com", 30);

// Valid → Right, Invalid → Left
Either<Seq<String>, User> either = validation.toEither();

7. Les Collections VAVR

7.1 Pourquoi des collections VAVR ?

Les collections Java standard sont mutables par défaut. Les collections VAVR sont immuables : toute “modification” crée une nouvelle collection.

// Collections VAVR immuables
import io.vavr.collection.*;

List<Integer> liste = List.of(1, 2, 3, 4, 5);
List<Integer> nouvelle = liste.prepend(0);  // List(0, 1, 2, 3, 4, 5)
// liste est INCHANGÉE : List(1, 2, 3, 4, 5)

Map<String, Integer> map = HashMap.of("a", 1, "b", 2);
Map<String, Integer> nouvelleMap = map.put("c", 3);
// map est INCHANGÉE

7.2 Opérations sur les collections VAVR

List<String> noms = List.of("Alice", "Bob", "Charlie", "Diana", "Eve");

// Filtrer
List<String> nomsCourts = noms.filter(n -> n.length() <= 3);
// List("Bob", "Eve")

// Transformer
List<Integer> longueurs = noms.map(String::length);
// List(5, 3, 7, 5, 3)

// Regrouper
Map<Integer, List<String>> parLongueur = noms.groupBy(String::length);
// HashMap(3 -> List(Bob, Eve), 5 -> List(Alice, Diana), 7 -> List(Charlie))

// Réduire
int totalLongueur = noms.foldLeft(0, (acc, n) -> acc + n.length());
// 23

// Partitionner en succes/echecs (utile avec Either !)
List<Either<String, Integer>> resultats = List.of(
    Either.right(1), Either.left("err"), Either.right(3)
);
Map<Boolean, List<Either<String, Integer>>> partitioned = resultats.groupBy(Either::isRight);

8. VAVR avec Spring Boot

8.1 Configuration

Pour utiliser VAVR avec Spring Boot et les sérialisations JSON (Jackson), ajoutez :

<dependency>
    <groupId>io.vavr</groupId>
    <artifactId>vavr</artifactId>
    <version>0.10.4</version>
</dependency>
<dependency>
    <groupId>io.vavr</groupId>
    <artifactId>vavr-jackson</artifactId>
    <version>0.10.4</version>
</dependency>

Configuration Jackson :

@Configuration
public class JacksonConfig {
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer vavrCustomizer() {
        return builder -> builder.modulesToInstall(new VavrModule());
    }
}

8.2 Couche Service avec Either

@Service
public class UtilisateurService {

    private final UtilisateurRepository repository;

    // Le type de retour dit TOUT : peut échouer avec une String d'erreur
    public Either<String, Utilisateur> creerUtilisateur(CreateUserDTO dto) {
        return validerDTO(dto)
            .flatMap(this::verifierEmailUnique)
            .flatMap(this::sauvegarder);
    }

    private Either<String, CreateUserDTO> validerDTO(CreateUserDTO dto) {
        if (dto.email() == null || !dto.email().contains("@")) {
            return Either.left("Email invalide");
        }
        if (dto.nom() == null || dto.nom().isBlank()) {
            return Either.left("Nom requis");
        }
        return Either.right(dto);
    }

    private Either<String, CreateUserDTO> verifierEmailUnique(CreateUserDTO dto) {
        boolean emailExiste = repository.existsByEmail(dto.email());
        return emailExiste
            ? Either.left("Email déjà utilisé : " + dto.email())
            : Either.right(dto);
    }

    private Either<String, Utilisateur> sauvegarder(CreateUserDTO dto) {
        return Try.of(() -> repository.save(new Utilisateur(dto)))
            .toEither()
            .mapLeft(ex -> "Erreur lors de la sauvegarde : " + ex.getMessage());
    }
}

8.3 Couche Controller avec Either

@RestController
@RequestMapping("/api/utilisateurs")
public class UtilisateurController {

    private final UtilisateurService service;

    @PostMapping
    public ResponseEntity<?> creer(@RequestBody CreateUserDTO dto) {
        return service.creerUtilisateur(dto)
            .fold(
                // Cas Left (erreur) → 400 Bad Request
                erreur -> ResponseEntity.badRequest()
                    .body(Map.of("erreur", erreur)),
                // Cas Right (succès) → 201 Created
                user -> ResponseEntity.status(201).body(user)
            );
    }

    @GetMapping("/{id}")
    public ResponseEntity<?> trouver(@PathVariable Long id) {
        return service.trouverParId(id)
            .fold(
                erreur -> ResponseEntity.notFound().build(),
                user   -> ResponseEntity.ok(user)
            );
    }
}

8.4 Gestion globale des erreurs

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Utilitaire pour convertir Either en ResponseEntity
    public static <L, R> ResponseEntity<?> eitherToResponse(
            Either<L, R> either,
            Function<L, ResponseEntity<?>> onLeft) {

        return either.fold(
            onLeft,
            body -> ResponseEntity.ok(body)
        );
    }
}

8.5 Avec Spring Data JPA

@Repository
public interface UtilisateurRepository extends JpaRepository<Utilisateur, Long> {
    boolean existsByEmail(String email);
}

@Service
public class UtilisateurService {

    public Either<String, Utilisateur> trouverParId(Long id) {
        // findById retourne Optional<T> — on le convertit en Either
        return repository.findById(id)
            .map(Either::<String, Utilisateur>right)
            .orElse(Either.left("Utilisateur non trouvé (id=" + id + ")"));
    }

    // Ou avec VAVR Option
    public Either<String, Utilisateur> trouverParIdVavr(Long id) {
        return Option.of(repository.findById(id).orElse(null))
            .toEither("Utilisateur non trouvé (id=" + id + ")");
    }
}

9. Bonnes pratiques et patterns avancés

9.1 Définir ses propres types d’erreurs

Ne jamais utiliser String comme type d’erreur dans du vrai code ! Utilisez des types dédiés :

//  BIEN — types d'erreurs explicites et extensibles
public sealed interface AppError {
    record NotFound(String resource, Object id) implements AppError {}
    record ValidationError(String field, String message) implements AppError {}
    record Unauthorized(String reason) implements AppError {}
    record InternalError(String message, Throwable cause) implements AppError {}
}

// Usage
public Either<AppError, User> trouverUser(Long id) {
    return Option.of(repo.findById(id).orElse(null))
        .toEither(new AppError.NotFound("User", id));
}

9.2 Ne pas abuser de Either

Either n’est pas toujours la meilleure solution.

//  INUTILE — les exceptions techniques restent des exceptions que Java lancera si vous ne le faites pas !
public Either<String, Integer> diviser(int a, int b) {
    if (b == 0) throw new ArithmeticException("Division par zéro");
    return Either.right(a / b);
}

//  MIEUX et plus PROPRE — Either pour les erreurs MÉTIER
public Either<String, Integer> diviser(int a, int b) {
    if (b == 0) return Either.left("Division par zéro impossible");
    return Either.right(a / b);
}

//  MIEUX PLUSPLUS — Try pour les opérations qui peuvent lancer des exceptions sinon, celle du dessus ou pas besoin
public Try<Integer> diviserCool(int a, int b) {
    return Try.of(() -> a / b);
}

9.3 Règles d’or

  1. Utilisez Either pour les erreurs métier prévisibles (validation, règles de gestion)
  2. Utilisez Try pour les opérations techniques (I/O, parsing, appels réseau)
  3. Utilisez Option pour les valeurs qui peuvent être absentes (recherche en BDD)
  4. Utilisez Validation pour valider plusieurs champs (formulaires)
  5. Définissez des types d’erreurs explicites, pas des String
  6. Ne convertissez pas Either en exception sans raison

9.4 Combiner Either et Validation

public Either<List<String>, User> creerUser(UserDTO dto) {
    // D'abord valider (accumuler toutes les erreurs)
    Validation<Seq<String>, UserDTO> validation = Validation.combine(
        validerNom(dto.nom()),
        validerEmail(dto.email()),
        validerAge(dto.age())
    ).ap((nom, email, age) -> new UserDTO(nom, email, age));

    // puis enchaîner avec la logique métier
    return validation
        .toEither()                          // Validation → Either
        .mapLeft(seq -> seq.asJava())        // Seq → List<String>
        .flatMap(this::verifierEmailUnique)
        .flatMap(this::sauvegarder);
}

10. TP Final — Banque Fonctionnelle

Le TP complet est disponible dans un fichier à part que je vous mettrai en lien ici, si on décide de le faire.


Récapitulatif des types VAVR

Type Cas d’usage Succès Échec
Option<T> Valeur optionnelle Some(T) None
Try<T> Opération qui peut lancer une exception Success(T) Failure(Throwable)
Either<L,R> Opération avec résultat ou erreur métier Right(R) Left(L)
Validation<E,T> Validation avec accumulation d’erreurs Valid(T) Invalid(Seq<E>)

Méthodes clés à retenir

Méthode Rôle
map(f) Transforme la valeur si succès
flatMap(f) Chaîne des opérations qui peuvent échouer
fold(f, g) Gère les deux cas en une seule expression
getOrElse(v) Valeur par défaut si échec
toEither() Convertit Option/Try en Either
mapLeft(f) Transforme l’erreur (sur Either)
recover(f) Récupèration d’un échec (sur Try)

Vous avez vu les bases, il vous reste à pratiquer pour maitriser cette syntaxe particulière. Il faut du temps…