21

I'm trying to convert a project to use Spring WebFlux and am running into a problem getting some basic business logic working. I have a repository layer that is responsible for retrieving / persisting records and a service layer that is responsible for the business rules of the application. What I want to do (in the service) layer is check if a user already exists for the given username. If so, I want to respond with an error. If not, I want to allow the insert to happen.

I call a method on the repository layer that will find a user by username and if not found it will return an empty Mono. This is working as expected; however, I have tried various combinations of flatMap and (defaultIfEmpty and swithIfEmpty) but am unable to get it to compile / build.

    public Mono<User> insertUser(User user) {
        return userRepository.findByUsername(user.username())
            .flatMap(__ -> Mono.error(new DuplicateResourceException("User already exists with username [" + user.username() + "]")))
            .switchIfEmpty(userRepository.insertUser(user));
    }

The error that I'm getting is that Mono<Object> cannot be converted to Mono<User>, so the swithIfEmpty doesn't seem to reflect the appropriate type and casting doesn't seem to work either.

Sanyam Goel
  • 2,138
  • 22
  • 40
cgaskill
  • 411
  • 1
  • 3
  • 7

5 Answers5

20

After additional testing, and taking into consideration the responses from my fellow developers, I have landed on the following solution:

    public Mono<User> insertUser(User user) {
        return userRepository.findByUsername(user.username())
            .flatMap(__ -> Mono.error(new DuplicateResourceException("User already exists with username [" + user.username() + "]")))
            .switchIfEmpty(Mono.defer(() -> userRepository.insertUser(user)))
            .cast(User.class);
    }

As Thomas stated, the compiler was getting confused. My assumption is because the flatMap was returning a Mono with an error and the switchIfEmpty was returning a Mono with a User so it reverts to a Mono with an Object (hence the additional .cast operator to get it to compile).

The other addition was to add the Mono.defer in the switchMap. Otherwise, the switchIfEmpty was always firing.

I'm still open to other suggestions / alternatives (since this seems like it would be a fairly common need / pattern).

cgaskill
  • 411
  • 1
  • 3
  • 7
6

I'm using abstract class that has type parameter E so I couldn't use .cast(E.class). Our solution was

private Mono<E> checkIfStatementExists(E statement) {
    return this.statementService.getByStatementRequestId(statement.getStatementRequestId())
            .flatMap(sr -> Mono.<E>error(new ValidationException("Statement already exists for this request!")))
            .switchIfEmpty(Mono.just(statement));
}

I think I need to dicuss this matter with my co-workers next week.

Edit. We had discussion with co-workers and the updated code is above.

hejupitk
  • 61
  • 1
  • 2
4

Why you are getting this compiler error is because of the following.

flatmap takes what is in your completed Mono and tries to convert it to whatever type it can infer. Mono.error contains a type and this type is of Object.

One way could instead be to move your logic into the flatmap.

// This is just example code using strings instead of repos
public Mono<String> insertUser(String user) {
    return Mono.just(user)
            // Here we map/convert instead based on logic
            .flatMap(__ -> {
                if (__.isEmpty())
                    return Mono.error(new IllegalArgumentException("User already exists with username [" + user + "]"));
                return Mono.just(user);
            }).switchIfEmpty(Mono.just(user));
}

switchIfEmpty is not super good for making logical decisions imho. The documentation states

Fallback to an alternative Mono if this mono is completed without data

It's more of a fallback to something else if we don't get anything, so we can keep the flow of data going.

You can also

Mono.empty().doOnNext(o -> {
        throw new IllegalArgumentException("User already exists with username [" + o + "]");
    }).switchIfEmpty(Mono.just("hello")).subscribe(System.out::println);
Toerktumlare
  • 12,548
  • 3
  • 35
  • 54
  • The `flatMap` will return the User that was found by the call to `userRepository.findUserByUsername`, which doesn't have an `isEmpty` method like a String. My expectation was that if the call to `userRepository.findUserByUsername` simply completes without emitting a User the code in the `flatMap` would not even be invoked and the code in the `switchIfEmpty` (or `defaultIfEmpty`) would be invoked which would allow me to insert the User (since they don't already exists). – cgaskill Sep 27 '19 at 11:19
  • ofc your user doesn't have a `isEmpty` method, that was not the point. You can null check instead. If it completes but returns empty, it will not enter the flatmap. But if it completes and has a value how can the compiler know what you want to return, you are now telling it to return `Mono`. But how can the compiler know you intentions? flatMap needs to be able to infer the return type and in your code, you are telling it to infer the return type of Mono. You have to think like the compiler, how does the compiler know? well it doesn't. – Toerktumlare Sep 27 '19 at 11:24
  • but tbh, my solution to this would instead to put a unique constraint on the field in the database and then you dont need to do all this logic in your application, you always try to save, and if the save fails, you return the error to the client. – Toerktumlare Sep 27 '19 at 11:40
  • That would work if you performed actual deletes in the database. Our process is to never delete (we don't like to lose data) so we perform logical deletes (we inactivate the record). So we can't have a unique constraint because we would want to allow another user to use that same username (as long as it is inactive). So only 1 active record with that username. – cgaskill Sep 28 '19 at 12:38
  • Clean solution, better answer than chat GPT could give me. Humans 1 : AI 0 – A Redfearn Aug 17 '23 at 16:22
1

Had the same problem and ended up doing below,

Please Note: This does not require type cast and works in both scenarios i.e,

  • Throw an error if element already exists in the database.

  • Insert element and return Mono otherwise.

      public Mono<UserDTO> insertUser(User user) {
         return this.userRepository.findByUsername(user.getUsername())
                 .flatMap(foundUser-> null != foundUser ? Mono
                         .error(new UserAlreadyExistsException(ExceptionsConstants.USER_ALREADY_EXISTS_EXCEPTION))
                         : Mono.just(user))
                 .switchIfEmpty(this.userRepository.save(user))
                 .map(userCreated -> copyUtils.createUserDTO(userCreated));
      }
    
Sanyam Goel
  • 2,138
  • 22
  • 40
0

Instead of using the Mono::cast to change the type of the Mono, this can be solved by specifying the type of the Mono.error using a bit of Java generic magic in the form of Mono.<User>error(...). This has the additional advantage that you do not need to alter the flow of the Mono pipeline by adding logic to the flatMap method with null handling but never actually receiving a null.

This would look as follows:

public Mono<User> insertUser(User user) {
    return userRepository.findByUsername(user.username())
        .flatMap(__ -> Mono.<User>error(new DuplicateResourceException("User already exists with username [" + user.username() + "]")))
        .switchIfEmpty(userRepository.insertUser(user));
}
aseychell
  • 1,794
  • 17
  • 35