1. Overview

In this article, we’ll focus on understanding the switchIfEmpty() operator in Spring Reactive and its behavior with and without the defer() operator. We’ll explore how these operators interact in different scenarios, providing practical examples to illustrate their impact on reactive streams.

2. Use of switchIfEmpty() and Defer()

The switchIfEmpty() is an operator in Mono and Flux that executes an alternate producer flow if the source producer is empty. If the primary source publisher emits no data, this operator switches to data emissions from an alternate source.

Let’s consider an endpoint that retrieves user details by ID from a large file. Each time someone requests user details from the file, iterating through the file consumes a lot of time. Thus, it makes more sense for frequently accessed IDs to have their details cached instead.

When the endpoint receives a request, we’ll first search the cache. If the user details are available, we’ll return the response. If not, we’ll fetch the data from the file and cache it for subsequent requests.

In this context, the primary data provider is the flow that checks for the key’s existence in the cache, while the alternative is the flow that checks the key in the file and updates the cache. The switchIfEmpty() operator can efficiently switch the source provider based on the availability of data in the cache.

It’s also important to understand the use of the defer() operator, which defers or delays the evaluation of a function until a subscription occurs. When we do not use the defer() operator with switchIfEmpty(), the expression evaluates immediately (eagerly), potentially causing unexpected side effects.

3. Setup

Let’s explore the example further to understand the behavior of the switchIfEmpty() operator under different circumstances.

We’ll implement the necessary code and analyze the system logs to determine whether we fetch the user from the cache or the file.

3.1. Data Model

First, let’s define a user model that contains a few details like id, name, email, roles:

public class User {

    @JsonProperty("id")
    private String id;

    @JsonProperty("email")
    private String email;

    @JsonProperty("username")
    private String username;

    @JsonProperty("roles")
    private String roles;

    // standard getters and setters...
}

3.2. User Data Setup

Subsequently, let’s maintain a file in the classpath(users.json) that contains all the user details in a JSON format:

[  
  {
    "id": "66b296723881ea345705baf1",
    "email": "[email protected]",
    "username": "reid90",
    "roles": "member"
  },
  {
    "id": "66b29672e6f99a7156cc4ada",
    "email": "[email protected]",
    "username": "boyle94",
    "roles": "admin"
  },
...
]

3.3. Controller and Service Implementation

In the following step, let’s add a controller that retrieves user details by ID. It will accept an optional boolean parameter withDefer, and involve different implementations based on this query parameter:

@GetMapping("/user/{id}")
public Mono<ResponseEntity<User>> findUserDetails(@PathVariable("id") String id, 
  @RequestParam("withDefer") boolean withDefer) {
    return (withDefer ? userService.findByUserIdWithDefer(id) : 
      userService.findByUserIdWithoutDefer(id)).map(ResponseEntity::ok);
}

Then, let’s define these two implementations in UserService, with and without defer(), to understand the behavior of switchIfEmpty():

public Mono<User> findByUserIdWithDefer(String id) {
    return fetchFromCache(id).switchIfEmpty(Mono.defer(() -> fetchFromFile(id)));
}
public Mono<User> findByUserIdWithoutDefer(String id) {
    return fetchFromCache(id).switchIfEmpty(fetchFromFile(id));
}

For simplicity, let’s implement an in-memory cache to retain user information as the primary data provider for requests. We’ll also log each access, which allows us to determine whether the cache retrieved the data:

private final Map<String, User> usersCache;

private Mono<User> fetchFromCache(String id) {
    User user = usersCache.get(id);
    if (user != null) {
        LOG.info("Fetched user {} from cache", id);
        return Mono.just(user);
    }
    return Mono.empty();
}

Following that, let’s fetch user details from the file when the ID is not in the cache and update the cache if the data is found:

private Mono<User> fetchFromFile(String id) {
    try {
        File file = new ClassPathResource("users.json").getFile();
        String usersData = new String(Files.readAllBytes(file.toPath()));
        List<User> users = objectMapper.readValue(usersData, new TypeReference<List<User>>() {
        });
        User user = users.stream()
          .filter(u -> u.getId()
            .equalsIgnoreCase(id))
          .findFirst()
          .get();
        usersCache.put(user.getId(), user);
        LOG.info("Fetched user {} from file", id);
        return Mono.just(user);
    } catch (IOException e) {
        return Mono.error(e);
    }
}

Note the logging details to assert if user data was retrieved from the file.

4. Testing

Let’s add a ListAppender in the @BeforeEach test method to track the logs. We’ll use it to determine whether the cache or the file executes the function for different requests:

protected ListAppender<ILoggingEvent> listAppender;

@BeforeEach
void setLogger() {
    Logger logger = (Logger) LoggerFactory.getLogger(UserService.class);
    logger.setLevel(Level.DEBUG);
    listAppender = new ListAppender<>();
    logger.addAppender(listAppender);
    listAppender.start();
}

We can add some tests to validate various conditions in the following sections.

4.1. switchIfEmpty() With defer() on Non-Empty Source

We’ll verify that the implementation retrieves user data from the cache only when the request has the withDefer parameter set to true and we’ll assert the logger output accordingly:

@Test
void givenUserDataIsAvailableInCache_whenUserByIdIsRequestedWithDeferParameter_thenCachedResponseShouldBeRetrieved() {
    usersCache = new HashMap<>();
    User cachedUser = new User("66b29672e6f99a7156cc4ada", "[email protected]", "boyle94", "admin");
    usersCache.put("66b29672e6f99a7156cc4ada", cachedUser);
    userService.getUsers()
      .putAll(usersCache);

    webTestClient.get()
      .uri("/api/v1/user/66b29672e6f99a7156cc4ada?withDefer=true")
      .exchange()
      .expectStatus()
      .isOk()
      .expectBody(String.class)
      .isEqualTo("{\"id\":\"66b29672e6f99a7156cc4ada\"," +
        "\"email\":\"[email protected]\",\"username\":\"boyle94\",\"roles\":\"admin\"}");

    assertTrue(listAppender.list.stream()
      .anyMatch(e -> e.toString()
        .contains("Fetched user 66b29672e6f99a7156cc4ada from cache")));

    assertTrue(listAppender.list.stream()
      .noneMatch(e -> e.toString()
        .contains("Fetched user 66b29672e6f99a7156cc4ada from file")));
}

When we use switchIfEmpty() with the defer() operator, the alternative source provider doesn’t get evaluated eagerly.

4.2. switchIfEmpty() Without defer() on Non-Empty Source

Let’s add another test to check the behavior of using switchIfEmpty() without the defer() operator:

@Test
void givenUserDataIsAvailableInCache_whenUserByIdIsRequestedWithoutDeferParameter_thenUserIsFetchedFromFileInAdditionToCache() {
    usersCache = new HashMap<>();
    User cachedUser1 = new User("66b29672e6f99a7156cc4ada", "[email protected]", "boyle94", "admin");
    usersCache.put("66b29672e6f99a7156cc4ada", cachedUser1);
    userService.getUsers().putAll(usersCache);

    webTestClient.get()
      .uri("/api/v1/user/66b29672e6f99a7156cc4ada?withDefer=false")
      .exchange()
      .expectStatus()
      .isOk()
      .expectBody(String.class)
      .isEqualTo("{\"id\":\"66b29672e6f99a7156cc4ada\"," +
        "\"email\":\"[email protected]\",\"username\":\"boyle94\",\"roles\":\"admin\"}");

    assertTrue(listAppender.list.stream()
      .anyMatch(e -> e.toString()
        .contains("Fetched user 66b29672e6f99a7156cc4ada from file")));

    assertTrue(listAppender.list.stream()
      .anyMatch(e -> e.toString()
        .contains("Fetched user 66b29672e6f99a7156cc4ada from cache")));
}

As we can see, the implementation fetched the user details from both the cache and the file, but it ultimately served the response from the cache. The code block in the alternate source was triggered unnecessarily, despite emissions from the primary source (cache).

4.3. switchIfEmpty() With defer() on Empty Source

Following that, let’s add a test to verify that user details are retrieved from the file when there is no data in the cache, specifically when using the defer() operator:

@Test
void givenUserDataIsNotAvailableInCache_whenUserByIdIsRequestedWithDeferParameter_thenFileResponseShouldBeRetrieved() {
    webTestClient.get()
      .uri("/api/v1/user/66b29672e6f99a7156cc4ada?withDefer=true")
      .exchange()
      .expectStatus()
      .isOk()
      .expectBody(String.class)
      .isEqualTo("{\"id\":\"66b29672e6f99a7156cc4ada\"
        ,\"email\":\"[email protected]\",\"username\":\"boyle94\",\"roles\":\"admin\"}");

    assertTrue(listAppender.list.stream()
      .anyMatch(e -> e.toString()
        .contains("Fetched user 66b29672e6f99a7156cc4ada from file")));

    assertTrue(listAppender.list.stream()
      .noneMatch(e -> e.toString()
        .contains("Fetched user 66b29672e6f99a7156cc4ada from cache")));
}

The API fetches the user details from the file as intended, rather than attempting to retrieve them from the cache.

4.4. switchIfEmpty() Without defer() on Empty Source

Finally, let’s add a test to validate that the implementation retrieves user details from the file (i.e., no data in the cache) when used without defer():

@Test
void givenUserDataIsNotAvailableInCache_whenUserByIdIsRequestedWithoutDeferParameter_thenFileResponseShouldBeRetrieved() {
    webTestClient.get()
      .uri("/api/v1/user/66b29672e6f99a7156cc4ada?withDefer=false")
      .exchange()
      .expectStatus()
      .isOk()
      .expectBody(String.class)
      .isEqualTo("{\"id\":\"66b29672e6f99a7156cc4ada\"," + "\"email\":\"[email protected]\",\"username\":\"boyle94\",\"roles\":\"admin\"}");

    assertTrue(listAppender.list.stream()
      .anyMatch(e -> e.toString()
        .contains("Fetched user 66b29672e6f99a7156cc4ada from file")));

    assertTrue(listAppender.list.stream()
      .noneMatch(e -> e.toString()
        .contains("Fetched user 66b29672e6f99a7156cc4ada from cache")));
}

As there is no data in the cache, the user details are not fetched from the cache but will be attempted as a side-effect but still the API fetches the user details from the file as intended.

5. Conclusion

In this article, we focussed on understanding the switchIfEmpty() operator in Spring Reactive and its various behaviors through tests.

Using switchIfEmpty() in conjunction with defer() ensures access to the alternative data source only when necessary. This prevents unnecessary computations and potential side effects.

As always, the source code for the examples is available over on GitHub.