Skip to content

13.1.4 Defining an OrderRepository aggregate root service

The first step toward persisting TacoOrder and Taco objects together such that TacoOrder is the aggregate root is to add a Taco collection property to the TacoOrder class. This is shown next.

Listing 13.8 Adding a Taco collection to TacoOrder

java
@Data
public class TacoOrder {

  ...

  @Transient
  private transient List<Taco> tacos = new ArrayList<>();

  public void addTaco(Taco taco) {
    this.tacos.add(taco);
    if (taco.getId() != null) {
      this.tacoIds.add(taco.getId());
    }
  }
}

Aside from adding a new List<Taco> property named tacos to the TacoOrder class, the addTaco() method now adds the given Taco to that list (as well as adding its id to the tacoIds set as before).

Notice, however, that the tacos property is annotated with @Transient (as well as marked with Java’s transient keyword). This indicates that Spring Data R2DBC shouldn’t attempt to persist this property. Without the @Transient annotation, Spring Data R2DBC would try to persist it and result in an error, due to it not supporting such relationships.

When a TacoOrder is saved, only the tacoIds property will be written to the database, and the tacos property will be ignored. Even so, at least now TacoOrder has a place to hold Taco objects. That will come in handy both for saving Taco objects when a TacoOrder is saved and also to read in Taco objects when a TacoOrder is fetched.

Now we can create a service bean that saves and reads TacoOrder objects along with their respective Taco objects. Let’s start with saving a TacoOrder. The TacoOrderAggregateService class defined in the next code listing has a save() method that does precisely that.

Listing 13.9 Saving TacoOrders and Tacos as an aggregate

java
package tacos.web.api;

import java.util.ArrayList;
import java.util.List;

import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Mono;
import tacos.Taco;
import tacos.TacoOrder;
import tacos.data.OrderRepository;
import tacos.data.TacoRepository;

@Service
@RequiredArgsConstructor
public class TacoOrderAggregateService {
    
  private final TacoRepository tacoRepo;
  private final OrderRepository orderRepo;

  public Mono<TacoOrder> save(TacoOrder tacoOrder) {
    return Mono.just(tacoOrder)
      .flatMap(order -> {
        List<Taco> tacos = order.getTacos();
        order.setTacos(new ArrayList<>());
        return tacoRepo.saveAll(tacos)
            .map(taco -> {
              order.addTaco(taco);
              return order;
          }).last();
      })
      .flatMap(orderRepo::save);
  }
}

Although there aren’t many lines in listing 13.9, there’s a lot going on in the save() method that requires some explanation. Firstly, the TacoOrder that is received as a parameter is wrapped in a Mono using the Mono.just() method. This allows us to work with it as a reactive type throughout the rest of the save() method.

The next thing we do is apply a flatMap() to the Mono<TacoOrder> we just created. Both map() and flatMap() are options for doing transformations on a data object passing through a Mono or Flux, but because the operations we perform in the course of doing the transformation will result in a Mono<TacoOrder>, the flatMap() operation ensures that we continue working with a Mono<TacoOrder> after the mapping and not a Mono<Mono<TacoOrder>>, as would be the case if we used map() instead.

The purpose of the mapping is to ensure that the TacoOrder ends up with the IDs of the child Taco objects and saves those Taco objects along the way. Each Taco object’s ID is probably null initially for a new TacoOrder, and we won’t know the IDs until after the Taco objects have been saved.

After fetching the List<Taco> from the TacoOrder, which we’ll use when saving Taco objects, we reset the tacos property to an empty list. We’ll be rebuilding that list with new Taco objects that have been assigned IDs after having been saved.

A call to the saveAll() method on the injected TacoRepository saves all of our Taco objects. The saveAll() method returns a Flux<Taco> that we then cycle through by way of the map() method. In this case, the transformation operation is secondary to the fact that each Taco object is being added back to the TacoOrder. But to ensure that it’s a TacoOrder and not a Taco that ends up on the resulting Flux, the mapping operation returns the TacoOrder instead of the Taco. A call to last() ensures that we won’t have duplicate TacoOrder objects (one for each Taco) as a result of the mapping operation.

At this point, all Taco objects should have been saved and then pushed back into the parent TacoOrder object, along with their newly assigned IDs. All that’s left is to save the TacoOrder, which is what the final flatMap() call does. Again, we choose flatMap() here to ensure that the Mono<TacoOrder> returned from the call to OrderRepository.save() doesn’t get wrapped in another Mono. We want our save() method to return a Mono<TacoOrder>, not a Mono<Mono<TacoOrder>>.

Now let’s have a look at a method that will read a TacoOrder by its ID, reconstituting all of the child Taco objects. The following code sample shows a new findById() method for that purpose.

Listing 13.10 Reading TacoOrders and Tacos as an aggregate

java
public Mono<TacoOrder> findById(Long id) {
  return orderRepo
    .findById(id)
    .flatMap(order -> {
      return tacoRepo.findAllById(order.getTacoIds())
        .map(taco -> {
          order.addTaco(taco);
          return order;
        }).last();
  });
}

The new findById() method is a bit shorter than the save() method. But we still have a lot to unpack in this small method.

The first thing to do is fetch the TacoOrder by calling the findById() method on the OrderRepository. This returns a Mono<TacoOrder> that is then flat-mapped to transform it from a TacoOrder that has only Taco IDs into a TacoOrder that includes complete Taco objects.

The lambda given to the flatMap() method makes a call to the TacoRepository.findAllById() method to fetch all Taco objects referenced in the tacoIds property at once. This results in a Flux<Taco> that is cycled over via map(), adding each Taco to the parent TacoOrder, much like we did in the save() method after saving all Taco objects with saveAll().

Again, the map() operation is used more as a means of iterating over the Taco objects rather than as a transformation. But the lambda given to map() returns the parent TacoOrder each time so that we end up with a Flux<TacoOrder> instead of a Flux<Taco>. The call to last() takes the last entry in that Flux and returns a Mono<TacoOrder>, which is what we return from the findById() method.

The code in the save() and findById() methods may be a little confusing if you’re not already in a reactive mind-set. Reactive programming requires a different mindset and can be confusing at first, but you’ll come to recognize it as quite elegant as your reactive programming skills get stronger.

As with any code—but especially code that may appear confusing like that in TacoOrderAggregateService—it’s a good idea to write tests to ensure that it works as expected. The test will also serve as an example of how the TacoOrderAggregateService can be used. The following code listing shows a test for TacoOrderAggregateService.

Listing 13.11 Testing the TacoOrderAggregateService

java
package tacos.web.api;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest;
import org.springframework.test.annotation.DirtiesContext;

import reactor.test.StepVerifier;
import tacos.Taco;
import tacos.TacoOrder;
import tacos.data.OrderRepository;
import tacos.data.TacoRepository;

@DataR2dbcTest
@DirtiesContext
public class TacoOrderAggregateServiceTests {

  @Autowired
  TacoRepository tacoRepo;

  @Autowired
  OrderRepository orderRepo;

  TacoOrderAggregateService service;

  @BeforeEach
  public void setup() {
    this.service = new TacoOrderAggregateService(tacoRepo, orderRepo);
  }

  @Test
  public void shouldSaveAndFetchOrders() {
    TacoOrder newOrder = new TacoOrder();
    newOrder.setDeliveryName("Test Customer");
    newOrder.setDeliveryStreet("1234 North Street");
    newOrder.setDeliveryCity("Notrees");
    newOrder.setDeliveryState("TX");
    newOrder.setDeliveryZip("79759");
    newOrder.setCcNumber("4111111111111111");
    newOrder.setCcExpiration("12/24");
    newOrder.setCcCVV("123");

    newOrder.addTaco(new Taco("Test Taco One"));
    newOrder.addTaco(new Taco("Test Taco Two"));

    StepVerifier.create(service.save(newOrder))
      .assertNext(this::assertOrder)
      .verifyComplete();
    StepVerifier.create(service.findById(1L))
      .assertNext(this::assertOrder)
      .verifyComplete();
  }
  private void assertOrder(TacoOrder savedOrder) {
    assertThat(savedOrder.getId()).isEqualTo(1L);
    assertThat(savedOrder.getDeliveryName()).isEqualTo("Test Customer");
    assertThat(savedOrder.getDeliveryName()).isEqualTo("Test Customer");
    assertThat(savedOrder.getDeliveryStreet()).isEqualTo("1234 North Street");
    assertThat(savedOrder.getDeliveryCity()).isEqualTo("Notrees");
    assertThat(savedOrder.getDeliveryState()).isEqualTo("TX");
    assertThat(savedOrder.getDeliveryZip()).isEqualTo("79759");
    assertThat(savedOrder.getCcNumber()).isEqualTo("4111111111111111");
    assertThat(savedOrder.getCcExpiration()).isEqualTo("12/24");
    assertThat(savedOrder.getCcCVV()).isEqualTo("123");
    assertThat(savedOrder.getTacoIds()).hasSize(2);
    assertThat(savedOrder.getTacos().get(0).getId()).isEqualTo(1L);
    assertThat(savedOrder.getTacos().get(0).getName())
        .isEqualTo("Test Taco One");
    assertThat(savedOrder.getTacos().get(1).getId()).isEqualTo(2L);
    assertThat(savedOrder.getTacos().get(1).getName())
        .isEqualTo("Test Taco Two");
  }
}

Listing 13.11 contains a lot of lines, but much of it is asserting the contents of a TacoOrder in the assertOrder() method. We’ll focus on the other parts as we review this test.

The test class is annotated with @DataR2dbcTest to have Spring create an application context with all of our repositories as beans. @DataR2dbcTest seeks out a configuration class annotated with @SpringBootConfiguration to define the Spring application context. In a single-module project, the bootstrap class annotated with @SpringBootApplication (which itself is annotated with @SpringBootConfiguration) serves this purpose. But in our multimodule project, this test class isn’t in the same project as the bootstrap class, so we’ll need a simple configuration class like this one:

java
package tacos;

import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;

@SpringBootConfiguration
@EnableAutoConfiguration
public class TestConfig {

}

Not only does this satisfy the need for a @SpringBootConfiguration-annotated class, but it also enables autoconfiguration, ensuring that (among other things) the repository implementations will be created

On its own, TacoOrderAggregateServiceTests should pass fine. But in an IDE that may share JVMs and Spring application contexts between test runs, running this test alongside other persistence tests may result in conflicting data being written to the in-memory H2 database. The @DirtiesContext annotation is used here to ensure that the Spring application context is reset between test runs, resulting in a new and empty H2 database on each run.

The setup() method creates an instance of TacoOrderAggregateService using the TacoRepository and OrderRepository objects injected into the test class. The TacoOrderAggregateService is assigned to an instance variable so that the test method(s) can use it.

Now we’re finally ready to test our aggregation service. The first several lines of shouldSaveAndFetchOrders() builds up a TacoOrder object and populates it with a couple of test Taco objects. Then the TacoOrder is saved via the save() method from TacoOrderAggregateService, which returns a Mono<TacoOrder> representing the saved order. Using StepVerifier, we assert that the TacoOrder in the returned Mono matches our expectations, including that it contains the child Taco objects.

Next, we call the service’s findById() method, which also returns a Mono<TacoOrder>. As with the call to save(), a StepVerifier is used to step through each TacoOrder in the returned Mono (there should be only one) and asserts that it meets our expectations.

In both StepVerifier situations, a call to verifyComplete() ensures that there are no more objects in the Mono and that the Mono is complete.

It’s worth noting that although we could apply a similar aggregation operation to ensure that Taco objects always contain fully defined Ingredient objects, we choose not to, given that Ingredient is its own aggregate root, likely being referenced by multiple Taco objects. Therefore, every Taco will carry only a Set<Long> to reference Ingredient IDs, which can then be looked up separately via IngredientRepository.

Although it may require a bit more work to aggregate entities, Spring Data R2DBC provides a way of working with relational data in a reactive way. But it’s not the only reactive persistence option provided by Spring. Let’s have a look at how to work with MongoDB using reactive Spring Data repositories.

Released under the MIT License.