Par le biais de cet article, je vous propose d’explorer différentes solutions à la gestion d’erreur en Java.
Vavr en deux mots
Vavr est une librairie rajoutant à Java des structures de contrôle fonctionnel qui manque pour le moment au langage (comme le filtrage par motif mais aussi les Either
, qui existent notamment dans Scala).
Je vais par moment utiliser certaines classes apportées par cette librairie.
Présentation du problème
Scénarios
Pour ce faire, je vais présenter différents exemples basés sur la problématique du retrait d’argent d’un compte bancaire, respectant les scénarios Gherkin suivant :
[code lang=gherkin]
Given a client bank account with a balance of « 150 »
When the client withdraw « 50 »
Then the client see the message « New balance: 100 »
[/code]
[code lang=gherkin]
Given a client bank account with a balance of « 20 »
When the client withdraw « 100 »
Then the client see the message « Withdrawal refused »
[/code]
Classes
Amount
Il s’agit simplement d’un objet valeur représentant un montant, possédant une méthode subtract
pour soustraire le montant d’un autre :
[code lang=java]
public record Amount(int value) {
public Amount subtract(Amount amount) {
return new Amount(value – amount.value());
}
}
[/code]
BankAccount
Cette classe contient un Amount
servant de balance
.
[code lang=java]
private Amount balance;
public BankAccount(Amount balance) {
this.balance = balance;
}
…
[/code]
BankAccount
contient également une méthode public
:
[code lang=java]
withdraw(Amount amount)
[/code]
C’est cette méthode qui nous intéresse : elle contiendra ma logique métier et c’est sa signature qui va changer suivant la gestion des erreurs dans le cas où un retrait est impossible.
ATM
C’est pour le moment une simple application console avec une méthode main
, mais on pourrait très bien imaginer dans le futur une API REST qui me retournerait un code HTTP différent suivant le scénario :
[code lang=java]
public class ATM {
public static void main(String[] args) {
var scanner = new Scanner(System.in);
System.out.print(« Initial amount: « );
var bankAccount = createBankAccount(scanner.nextInt());
…
}
private static BankAccount createBankAccount(int amount) {
return new BankAccount(new Amount(amount));
}
}
[/code]
Les solutions
Avec une Exception
La première solution envisageable est celle des exceptions.
Je commence donc par créer une nouvelle Exception
:
[code lang=java]
public class WithdrawalRefusedException extends Exception {
}
[/code]
L’implémentation de BankAccount#withdraw
est la suivante :
[code lang=java]
public Amount withdraw(Amount amount) throws WithdrawalRefusedException {
if (hasInsufficientBalance(amount)) {
throw new WithdrawalRefusedException();
}
balance = balance.subtract(amount);
return balance;
}
[/code]
Ainsi, côté ATM
j’ai au choix, un bon vieux try
/catch
:
[code lang=java]
try {
var balance = bankAccount.withdraw(amount);
System.out.println(« New balance: » + balance);
} catch (WithdrawalRefusedException e) {
System.err.println(« Withdrawal refused »);
}
[/code]
Ou alors solution un peu plus fonctionnelle et plus propre grâce au Try
:
[code lang=java]
Try.of(() -> bankAccount.withdraw(amount))
.onSuccess(balance -> System.out.println(« New balance: » + balance))
.onFailure(WithdrawalRefusedException.class, withdrawalRefusedException -> System.err.println(« Withdrawal refused »));
[/code]
Avec un Either
Les Either
apportés par Vavr représente une valeur avec deux types possibles : soit Left
soit Right
.
Right
représente généralement un cas succès tandis que Left
représente une erreur.
BankAccount#withdraw
ressemble donc à :
[code lang=java]
public Either<WithdrawalRefused, Amount> withdraw(Amount amount) {
if (hasInsufficientBalance(amount)) {
return Either.left(new WithdrawalRefused());
}
balance = balance.subtract(amount);
return Either.right(balance);
}
[/code]
Avec :
[code lang=java]
public record WithdrawalRefused() {
}
[/code]
Au niveau ATM
, le Either
peut se gérer ainsi :
[code lang=java]
bankAccount.withdraw(amount)
.peek(balance -> System.out.println(« New balance: » + balance))
.orElseRun(withdrawalRefused -> System.err.println(« Withdrawal refused »));
[/code]
Avec une classe
Je débute avec sealed interface
avec deux implémentations :
[code lang=java]
public sealed interface WithdrawalResult {
record WithdrawalRefused() implements WithdrawalResult {
}
record WithdrawalSuccess(Amount newBalance) implements WithdrawalResult {
}
}
[/code]
Désormais, ma méthode withdraw
est :
[code lang=java]
public WithdrawalResult withdraw(Amount amount) {
if (hasInsufficientBalance(amount)) {
return new WithdrawalRefused();
}
balance = balance.subtract(amount);
return new WithdrawalSuccess(balance);
}
[/code]
Maintenant, comment gérer ce cas-là au niveau de l’appelant de BankAccount#withdraw
?
Eh bien j’ai le choix :
- Avec un
instanceof
[code lang=java]
var result = bankAccount.withdraw(amount);
if (result instanceof WithdrawalSuccess success) {
System.out.println(« New balance: » + success.newBalance());
} else {
System.err.println(« Withdrawal refused »);
}
[/code]
- Avec du filtrage par motif
- Avec Vavr
[code lang=java]
var result = bankAccount.withdraw(amount);Match(result).of(
Case($(instanceOf(WithdrawalSuccess.class)), success -> run(() -> System.out.println(« New balance: » + success.newBalance()))),
Case($(instanceOf(WithdrawalRefused.class)), ignored -> run(() -> System.err.println(« Withdrawal refused »)))
);
[/code]- En Java (nécessite
--enable-preview
)
[code lang=java]
var result = bankAccount.withdraw(amount);switch (result) {
case WithdrawalSuccess success -> System.out.println(« New balance: » + success.newBalance());
case WithdrawalRefused ignored -> System.err.println(« Withdrawal refused »);
}
[/code]
Conclusion
Les exceptions sont le mécanisme de base des erreurs à Java. Il en existe deux types, les vérifiées (héritant de Exception
) et les non vérifiées (héritant de RuntimeException
). Les exceptions vérifiées ont l’avantage d’être explicite et doivent donc être gérée par l’appelant tandis que les non vérifiées peuvent survenir par surprise. Or une erreur comme celle présentée ci-dessus ne doit pas être une surprise.
A mon avis, les exceptions doivent, comme leur nom l’indique, rester exceptionnelles. En effet, lever une exception est énormément coûteux en ressource (plus coûteux qu’un new
). Autre inconvénient : les méthodes levant des exceptions vérifiées ne sont pas utilisables dans des lambdas sans un try
/catch
, ou sans l’aide de Vavr.
Tout comme une Exception
, le Either
force l’appelant à gérer le cas d’un Either#Left
, mais de manière bien plus propre et efficace d’une exception, tout en étant relativement explicite.
Vavr est-il la solution idéale à la gestion d’erreur en Java ? Le seul inconvénient est l’utilisation d’une librairie tierce, qui peut s’avérér intrusive, et qui n’est à mon avis pas forcément simple d’utilisation pour un développeur non initié à la programmation fonctionnelle.
Question de gout ? La réponse est oui, mais aussi de contexte !
Je pense que tout dépend de la version de Java utilisée. En attendant l’arrivée de la version finale du filtrage par motif dans Java, Vavr peut être une excellente solution. Mais à terme, je suis persuadé que l’utilisation de sealed class
avec filtrage par motif sera la meilleure solution.
Et vous, quelle solution à la gestion d’erreur préférez-vous ?
Retrouvez le code d’exemple ici.