Skip to main content

Prog fonct en java - écrire sa monade

· 13 min read

Cette fois, on va aller encore un peu plus loins : on va écrire notre propre monade ! N'ayez pas peur tout est sous contrôle !

Si vous faites du webflux, avec du code métier, allez y les yeux fermés !

L'instant musical

Le use case

Si vous faites du spring réactif, ça peut vite devenir la plaie de gérer du métier.

Si on souhaite avoir des erreurs explicites, on peut utiliser un Either mais le problème, c'est qu'on se retrouve avec des Mono<Either<E, A>>, ce qui alourdit énormément le code.

Pour manipuler cette structure, il faut sans cesse manipuler 2 niveaux : le Mono puis le Either, le fameux callback hell :

Mono<Either<AppError, String>> result = ...;
Mono<Either<AppError, Integer>> result2 = result
.map(either -> either
.map(ok ->
ok.length()
)
);

En fait, il est possible de créer de nouvelles monades en stackant des monades existantes. On appelle ça des "monad transformer".

Avec notre Mono<Either<E, A>>, on peut écrire une nouvelle classe ainsi :

record IO<E, A>(Mono<Either<E, A>> underlying) {
public <B> IO<E, B> flatMap(Function<A, IO<E, B>> f) {
...
}
}

Comme on est gourmand, on va ajouter une autre capacité à notre monade, la capacité de mettre de côté des événements à publier. Comme on est encore plus gourmand, on va se passer de vavr et écrire ça en java vanilla !

Notre super monade

Tout d'abord, on commence par l'Either.

Depuis le jdk 21, on peut utiliser les sealed interface pour créer des types "union". On va pouvoir écrire l'Either ainsi :

public sealed interface Either<E, A> {

record Right<E, A>(A value) implements Either<E, A> { }
record Left<E, A>(E value) implements Either<E, A> { }

static <E, A> Either<E, A> right(A value) {
return new Right<>(value);
}

static <E, A> Either<E, A> left(E value) {
return new Left<>(value);
}
}

Pour le moment, on ne se prend pas la tête avec map et flatMap.

Il faut ensuite écrire une structure qui permette de mettre nos événements "de côté". Il existe une monade qui permet de faire ça, c'est la writer monad.

Dans notre cas de figure, comme pour l'Either, on ne va pas vraiment écrire une monade, on va seulement écrire une classe pour conserver la valeur et les événements :

record Writer<S, A>(List<S> log, A value) { }

Maintenant, on va écrire notre super monade en utilisant nos 2 structures :

public class IO<S, E, A> {
private final Mono<Either<E, Writer<S, A>>> underlying;

private IO(Mono<Either<E, Writer<S, A>>> underlying) {
this.underlying = underlying;
}
}

Pour que ça soit une monade, il faut écrire la méthode flatMap. Attention, c'est parti (ça va piquer un peu) :

public <B> IO<S, E, B> flatMap(Function<A, IO<S, E, B>> f) {

Mono<Either<E, Writer<S, B>>> newValue = this.underlying.flatMap(eitherA ->
// On teste le résultat courant :
switch (eitherA) {
// Si il est KO, pas de question a se poser : on retourne KO
case Either.Left(var err) -> Mono.just(Either.<E, Writer<S, B>>left(err));
case Either.Right(var okA) -> {
// Si le résulat est ok, on applique la fonction
IO<S, E, B> result = f.apply(okA.value());
yield result.underlying.flatMap(eitherB ->
switch (eitherB) {
// Si le résultat de la fonction est KO : on retourne KO
case Either.Left(var errB) -> Mono.just(Either.left(errB));
// Si le résultat est Ok
case Either.Right(var okB) -> {
// On combine les logs
List<S> newLog = Stream.concat(okA.log().stream(), okB.log().stream()).toList();
// On retourne OK
yield Mono.just(Either.right(new Writer<>(newLog, okB.value())));
}
}
);
}
}
);

// On a obtenue un `Mono<Either<E, Writer<S, B>>>`, mais il faut retourner un IO
return new IO<>(newValue);
}

Une monade doit aussi avoir la méthode unit. Ici, c'est le cas d'une valeur en succès, on pourra écrire :

public static <S, E, A> IO<S, E, A> succeed(A value) {
return new IO<>(Mono.just(Either.right(new Writer<>(List.of(), value))));
}

On va également écrire la version erreur :

public static <S, E, A> IO<S, E, A> error(E value) {
return new IO<>(Mono.just(Either.left(value)));
}

Si on veut pouvoir pousser des événements, il faut également une méthode pour pousser des logs en plus de la valeur :

public static <S, E, A> IO<S, E, A> create(List<S> log, A value) {
return new IO<>(Mono.just(Either.right(new Writer<>(log, value))));
}

Pour valider notre monade, il faut vérifier les lois. Pour ça on va utiliser jqwik, une lib de property based testing.

Identité à gauche

La règle à respecter est unit(x).flatMap(f) == f(x).

Cette loi doit fonctionner avec un succès, mais aussi en cas d'erreur.

Le cas nominal :

@Property
// Pour n'importe quel entier :
void leftIdentityOk(@ForAll Integer integer) {
Function<Integer, IO<String, String, String>> f = (Integer intValue) -> {
int i = intValue * 2;
return IO.create(List.of("A", "B"), String.valueOf(i));
};

IO<String, String, String> result1 = IO.<String, String, Integer>succeed(integer).flatMap(f);
IO<String, String, String> result2 = f.apply(integer);

assertThat(result1.run().block()).isEqualTo(result2.run().block());
}

Le cas ou on obtiendrait une erreur

@Property
// Pour n'importe quel entier :
void leftIdentityKo(@ForAll Integer integer) {
Function<Integer, IO<String, String, String>> fko = (Integer intValue) -> {
int i = intValue * 2;
return IO.error("Oups");
};
IO<String, String, String> result1 = IO.<String, String, Integer>succeed(integer).flatMap(fko);
IO<String, String, String> result2 = fko.apply(integer);

assertThat(result1.run().block()).isEqualTo(result2.run().block());
}

Identité à droite

La règle à respecter est m.flatMap(unit) == m. Comme pour l'identité à gauche, on va aussi tester les cas ou notre IO se termine en erreur.

@Property
void rightIdentityOk(@ForAll Integer integer) {
IO<String, String, Integer> result1 = IO.succeed(integer);

IO<String, String, Integer> result2 = result1.flatMap(i -> IO.succeed(i));

assertThat(result1.run().block()).isEqualTo(result2.run().block());
}

Le cas d'erreur :


@Property
void rightIdentityKo(@ForAll Integer integer) {
IO<String, String, Integer> result1 = IO.error("Oups");

IO<String, String, Integer> result2 = result1.flatMap(i -> IO.succeed(i));

assertThat(result1.run().block()).isEqualTo(result2.run().block());
}

Associativité

La règle à respecter est m.flatMap(f).flatMap(g) == m.flatMap(x ⇒ f(x).flatMap(g))

Le cas de succès :


@Property
void associativity(@ForAll Integer integer) {

Function<Integer, IO<String, String, String>> f = (Integer intValue) -> {
int i = intValue * 2;
return IO.create(List.of("A", "B"), String.valueOf(i));
};

Function<String, IO<String, String, String>> f2 = (String strValue) -> {
String i = strValue.substring(0, strValue.length() / 2);
return IO.create(List.of("C", "D"), i);
};

IO<String, String, Integer> m = IO.succeed(integer);

IO<String, String, String> result1 = m.flatMap(f).flatMap(f2);
IO<String, String, String> result2 = m.flatMap(i -> f.apply(i).flatMap(f2));

assertThat(result1.run().block()).isEqualTo(result2.run().block());
}

Premier cas d'erreur :

@Property
void associativityKO1(@ForAll Integer integer) {
Function<String, IO<String, String, String>> f2 = (String strValue) -> {
String i = strValue.substring(0, strValue.length() / 2);
return IO.create(List.of("C", "D"), i);
};

IO<String, String, Integer> m = IO.error("Oups");

IO<String, String, String> result1 = m.flatMap(f).flatMap(f2);
IO<String, String, String> result2 = m.flatMap(i -> f.apply(i).flatMap(f2));

assertThat(result1.run().block()).isEqualTo(result2.run().block());
}

Deuxième cas d'erreur :

@Property
void associativityKO2(@ForAll Integer integer) {
Function<String, IO<String, String, String>> f2 = (String strValue) -> {
String i = strValue.substring(0, strValue.length() / 2);
return IO.create(List.of("C", "D"), i);
};
Function<Integer, IO<String, String, String>> fko = (Integer intValue) -> {
int i = intValue * 2;
return IO.error("Oups");
};

IO<String, String, Integer> m = IO.succeed(integer);

IO<String, String, String> result1 = m.flatMap(fko).flatMap(f2);
IO<String, String, String> result2 = m.flatMap(i -> fko.apply(i).flatMap(f2));

assertThat(result1.run().block()).isEqualTo(result2.run().block());
}

Troisième cas d'erreur :

@Property
void associativityKO3(@ForAll Integer integer) {
Function<Integer, IO<String, String, String>> f = (Integer intValue) -> {
int i = intValue * 2;
return IO.create(List.of("A", "B"), String.valueOf(i));
};
Function<String, IO<String, String, String>> f2Ko = (String strValue) -> {
return IO.error("Oups");
};
IO<String, String, Integer> m = IO.succeed(integer);

IO<String, String, String> result1 = m.flatMap(f).flatMap(f2Ko);
IO<String, String, String> result2 = m.flatMap(i -> f.apply(i).flatMap(f2Ko));

assertThat(result1.run().block()).isEqualTo(result2.run().block());
}

Les tests passent, on a écrit notre première monade !

Polir un peu l'API

En l'état notre IO n'est pas très utilisable, on va ajouter quelques fonctions supplémentaires :

  • map pour appliquer une fonction de transformation sur le contenu
  • mapError pour appliquer une fonction de transformation sur les erreurs
  • product pour combiner avec un autre IO
  • Des factory pour créer un IO dans différents contextes

Transformer les succès et les erreurs

Pour map c'est facile, on s'appuie sur flatMap :

public <B> IO<S, E, B> map(Function<A, B> f) {
return this.flatMap(a -> IO.succeed(f.apply(a)));
}

Pour mapError, il faut tricoter un peu plus :

public <E1> IO<S, E1, A> mapError(Function<E, E1> f) {
return new IO<>(this.underlying.map(either ->
switch (either) {
case Either.Left(var e) -> Either.left(f.apply(e));
case Either.Right(var a) -> Either.right(a);
}
));
}

Combiner

Ici, on va combiner les succès, mais aussi les erreurs. Si ça se passe mal, on veut pouvoir récupérer tous les problèmes d'un coup :

public static <S, E, A, B, C> IO<S, List<E>, C> product(IO<S, E, A> ioA, IO<S, E, B> ioB, BiFunction<A, B, C> combinator) {

// On définit un tuple local pour le switch
record Tuple<A, B>(A a, B b) {}

return new IO<>(ioA.underlying.flatMap(mayBeA ->
ioB.underlying.map(mayBeB ->
// Petite astuce : Le switch avec le tuple permet de gérer tous les cas
switch (new Tuple<>(mayBeA, mayBeB)) {

// Les 2 en succès : on combine les logs et les succès :
case Tuple(Either.Right(var a), Either.Right(var b)) -> Either.right(
new Writer<>(
Stream.concat(a.log().stream(), b.log().stream()).toList(),
combinator.apply(a.value(), b.value())
)
);
// 1 des 2 en erreur : une liste d'une erreur
case Tuple(Either.Left(var a), Either.Right(var b)) -> Either.left(List.of(a));
// 1 des 2 en erreur : une liste d'une erreur
case Tuple(Either.Right(var a), Either.Left(var b)) -> Either.left(List.of(b));
// les 2 en erreur : une liste de 2 erreurs
case Tuple(Either.Left(var a), Either.Left(var b)) -> Either.left(List.of(a, b));
}
)
));
}

On peut ensuite écrire les méthodes de combinaison pour plus d'IO en utilisant les méthodes existantes :

public static <S, E, A, B, C, D> IO<S, List<E>, D> product(IO<S, E, A> ioA, IO<S, E, B> ioB, IO<S, E, C> ioC, Function3<A, B, C, D> combinator) {
record Tuple<A, B>(A a, B b) {}
return product(
// On stocke le résultat dans un Tuple temporairement
product(ioA, ioB, Tuple::new),
// Pour combiner 2 IOs, il faut le même côté gauche donc on passe de E à List<E> :
ioC.mapError(List::of),
// On récupère le tuple et la valeur : on applique la fonction :
(a, b) -> combinator.apply(a.a(), a.b(), b)
)
// On se retrouve avec une List<List<E>> donc on aplatit
.mapError(l -> l.stream().flatMap(e -> e.stream()).toList());
}

Ajouter un événement

Il faut quand même pouvoir ajouter des événements !

public IO<S, E , A> write(S log) {
return new IO<>(this.underlying.map(either ->
switch (either) {
case Either.Left(var e) -> Either.left(e);
case Either.Right(var a) -> Either.right(new Writer<>(
Stream.concat(
a.log().stream(),
Stream.of(log)
).toList(),
a.value()
));
}
));
}

Intégration avec le reste du monde

Voici quelques exemples de création d'IO à partir de différentes valeurs.

À partir d'un Optional, on veut donner une raison à l'absence de valeur :

public static <S, E, A> IO<S, E, A> fromOption(Optional<A> value, Supplier<E> onEmpty) {
return new IO<>(Mono.just(value
.map(v -> Either.<E, Writer<S, A>>right(new Writer<>(List.<S>of(), v)))
.orElse(Either.left(onEmpty.get()))
));
}

Même chose pour un Mono<Optional<?>> :

public static <S, E, A> IO<S, E, A> fromOptionM(Mono<Optional<A>> value, Supplier<E> onEmpty) {
return new IO<>(value.map(opt -> opt
.map(v -> Either.<E, Writer<S, A>>right(new Writer<>(List.<S>of(), v)))
.orElse(Either.left(onEmpty.get()))
));
}

On peut créer une instance à partir d'un Mono :

public static <S, E, A> IO<S, E, A> fromMono(Mono<A> value) {
return new IO<>(value.map(v -> Either.right(new Writer<>(List.of(), v))));
}

Etc ...

Le code en action !

On va reprendre le même exemple que dans l'article précédent, mais cette fois en utilisant notre nouveau jouet.

On est dans un context reactif donc le repo devient :

interface UtilisateursRepository {
Mono<Optional<Utilisateur>> rechercherParId(String id);
Mono<Utilisateur> mettreAJour(Utilisateur utilisateur);
}

Les méthodes de validation ne changent pas beaucoup :

static IO<Event, PersonErrors, Tuple0> verifierEmail(Utilisateur utilisateur) {
if (Objects.nonNull(utilisateur.email()) && !EMAIL_REGEX.matcher(utilisateur.email()).matches()) {
return IO.error(PersonErrors.create(new UtilisateurError.EmailFormatNonValide()));
} else {
return IO.succeed(Tuple());
}
}

Le service devient :

public IO<Event, PersonErrors, Utilisateur> mettreAJour(String id, Utilisateur utilisateur) {

// On commence par gérer le retour du repo qui est un Mono<Optional>
return IO.<Event, PersonErrors, Utilisateur>fromOptionM(
utilisateursRepository.rechercherParId(id),
// Si c'est vide, on retourne une erreur :
() -> PersonErrors.create(new UtilisateurError.UtilisateurInexistant())
)
.flatMap(personneExistante ->
// On passe la validation
IO.product(
verifierEmail(utilisateur),
verifierDateDePermis(utilisateur),
verifierDateNaissance(utilisateur),
(a, b, c) -> utilisateur
)
// On combine les erreurs
.mapError(errors -> errors.stream().reduce(PersonErrors::combineAvec).get())
.flatMap(u -> {
Utilisateur utilisateurAvecId = u.withId(id);
// On persiste
return IO.<Event, PersonErrors, Utilisateur>fromMono(utilisateursRepository.mettreAJour(utilisateurAvecId))
// On ajoute notre événement
.write(new UtilisateurUpdated(utilisateurAvecId));
})
);
}

Le code du controller devient :

public Mono<ServerResponse> update(ServerRequest request) {
String id = request.pathVariable("id");
return request
.bodyToMono(Utilisateur.class)
.flatMap(utilisateur ->
utilisateurs.mettreAJour(id, utilisateur)
.run()
.flatMap(either ->
switch (either) {
case Either.Left(var errors) -> ServerResponse.badRequest().bodyValue(errors);
case Either.Right(var ok) ->
publishEvent(ok.log())
.then(ServerResponse.ok().bodyValue(ok.value()));
}
)
);
}

Ici, la publication des événements se fait dans le controller ce qui n'est certainement pas une bonne idée. Bien évidemment, on fera mieux dans notre projet.

Avec ce genre de structure, on peut facilement orchestrer plusieurs traitements dans un contexte transactionnel. À la fin, on aura soit un succès avec un état et des événements, soit une erreur.

  • en cas de succès, on commit et on publie les événements
  • en cas d'erreur, on rollback

On aurait du code avec cette forme :

Mono<Utilisateur> result = inTransaction(tx ->
utilisateurs.mettreAJour(tx, id, utilisateur)
.flatMap(u -> comptesEnBanque.valider(u, compte))
.flatMap(u -> notifications.envoyer(u))
.run()
.flatMap(either ->
switch (either) {
case Either.Left(var errors) -> tx.rollback().then(Mono.error(new ValidationError(errors)));
case Either.Right(var ok) ->
tx.commit()
// On publie les événements de tous les traitements métier
.then(publishEvent(ok.log()))
.thenReturn(ok.value());
}
)
);

Conclusion

Si vous êtes arrivé jusqu'ici et que vous êtes à l'aise avec tous les concepts abordés, je vous donne votre ceinture noire de fonctionnel : c'est le début officiel de votre apprentissage en prog fonctionnelle.

Si vous voulez des détails, j'avais fait une conf "spring réactif et code métier" dont le code et l'explication de texte se trouve ici https://github.com/larousso/spring-reactif-code-metier

Si vous êtes vraiment fan de cette approche et que vous voulez aller plus loins, il faudra changer de langage et faire du scala ou du haskell.

Si vous trouvez la prog fonctionnelle en java overkill, vous pouvez seulement conserver certains aspects. Quelques exemples :

  • immutabilité à l'extérieur des méthodes, mais mutabilité dans le body des méthodes
  • Either pour empiler les règles de gestion, mais on lève une checked exception à la fin
  • etc

Chacun met son curseur là où il le souhaite !