1. Overview

Implementing standard REST APIs covers most of the typical use cases. However, there are some limitations in the REST-based architecture style for dealing with any bulk or batch operations.

In this tutorial, we’ll learn how to apply bulk and batch operations in a microservice. Also, we’ll implement a few custom write-oriented bulk and batch APIs.

2. Introduction to Bulk and Batch API

The terms bulk and batch operation are often used interchangeably. However, there is a hard distinction between the two.

Typically, a bulk operation means performing the same action on multiple entries of the same type. A trivial approach can be to apply the bulk action by calling the same API for each request. It can be too slow and a waste of resources. Instead, we can process multiple entries in a single round trip.

We can implement a bulk operation by applying the same operation on multiple entries of the same type in a single call. This way of operating on the collection of items reduces the overall latency and improves application performance. To implement, we can either reuse the existing endpoint that is used on a single entry or create a separate route for the bulk method.

A batch operation usually means performing different actions on multiple resource types. A batch API is a bundle of various actions on resources in a single call. These resource operations may not have any coherence. Potentially, each request route can be independent of another.

In short, the “batch” term means batching different requests.

We don’t have many well-defined standards or specifications to implement the bulk or batch operations. Also, many popular frameworks like Spring don’t have built-in support for bulk operations.

Nevertheless, in this tutorial, we’ll look at a custom implementation of bulk and batch operations using common REST constructs.

3. Example Application in Spring

Let’s imagine we need to build a simple microservice that supports both bulk and batch operations.

3.1. Maven Dependencies

First, let’s include the spring-boot-starter-web and spring-boot-starter-validation dependencies:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>3.1.5</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
    <version>3.1.5</version>
</dependency>

With the above spring-boot-starter-validation dependency, we’ve enabled the input data validation in the application. We’ll require it to validate the bulk and batch request size.

3.2. Implement the First Spring Service

We’ll implement a service that creates, updates, and deletes data on a repository.

First, let’s model the Customer class:

Next, let’s implement the CustomerService class with a createCustomers() method to store multiple Customer objects in our in-memory repository:

@Service
public class CustomerService {
    private final Map<String, Customer> customerRepoMap = new HashMap<>();

    public List<Customer> createCustomers(List<Customers> customers) {
        return customers.stream()
          .map(this::createCustomer)
          .filter(Optional::isPresent)
          .map(Optional::get)
          .collect(toList());
    }
}

Then, we’ll implement a createCustomer() method to create a single Customer object:

public Optional<Customer> createCustomer(Customer customer) {
    if (!customerRepoMap.containsKey(customer.getEmail()) && customer.getId() == 0) {
        Customer customerToCreate = new Customer(customerRepoMap.size() + 1, 
          customer.getName(), customer.getEmail());
        customerToCreate.setAddress(customer.getAddress());
        customerRepoMap.put(customerToCreate.getEmail(), customerToCreate);  
        return Optional.of(customerToCreate);
    }

    return Optional.empty();
}

In the above method, we’re only creating the customer if does not exist in the repository otherwise, we return an empty object.

Similarly, we’ll implement a method to update existing Customer details:

private Optional<Customer> updateCustomer(Customer customer) {
    Customer customerToUpdate = customerRepoMap.get(customer.getEmail());
    if (customerToUpdate != null && customerToUpdate.getId() == customer.getId()) {
        customerToUpdate.setName(customer.getName());
        customerToUpdate.setAddress(customer.getAddress());
    }
    return Optional.ofNullable(customerToUpdate);
}

Finally, we’ll implement a deleteCustomer() method to remove an existing Customer from the repository:

public Optional<Customer> deleteCustomer(Customer customer) {
    Customer customerToDelete = customerRepoMap.get(customer.getEmail());
    if (customerToDelete != null && customerToDelete.getId() == customer.getId()) {
        customerRepoMap.remove(customer.getEmail());
    }

   return Optional.ofNullable(customerToDelete);
}

3.3. Implement the Second Spring Service

Let’s also implement another service that fetches and creates address data in a repository.

First, we’ll define the Address class:

public class Address implements Serializable {
    private int id;
    private String street;
    private String city;
    //standard getters and setters
}

Then, let’s implement the AddressService class with a createAddress() method:

public Address createAddress(Address address) {
    Address createdAddress = null;
    String addressUniqueKey = address.getStreet().concat(address.getCity());
    if (!addressRepoMap.containsKey(addressUniqueKey)) {
        createdAddress = new Address(addressRepoMap.size() + 1, 
          address.getStreet(), address.getCity());
        addressRepoMap.put(addressUniqueKey, createdAddress);
    }
    return createdAddress;
}

4. Implement a Bulk API Using Existing Endpoint

Now let’s create an API to support bulk and single-item create operations.

4.1. Implement a Bulk Controller

We’ll implement a BulkController class with an endpoint to create either a single or multiple customers in a single call.

First, we’ll define the bulk request in a JSON format:

[
    {
        "name": "<name>",
        "email": "<email>",
        "address": "<address>"
    }
]

With this approach, we can handle bulk operations using a custom HTTP header – X-ActionType – to differentiate between a bulk or single-item operation.

Then, we’ll implement the bulkCreateCustomers() method in the BulkController class and use the above CustomerService’s methods:

@PostMapping(path = "/customers/bulk")
public ResponseEntity<List<Customer>> bulkCreateCustomers(
  @RequestHeader(value="X-ActionType") String actionType, 
  @RequestBody @Valid @Size(min = 1, max = 20) List<Customer> customers) {
    List<Customer> customerList = actionType.equals("bulk") ? 
      customerService.createCustomers(customers) :
      singletonList(customerService.createCustomer(customers.get(0)).orElse(null));

    return new ResponseEntity<>(customerList, HttpStatus.CREATED);
}

In the above code, we’re using the X-ActionType header to accept any bulk request. Also, we’ve added an input request size validation using the @Size annotation. The code decides whether to pass the whole list to createCustomers() or just element 0 to createCustomer().

The different create functions return either a list or a single Optional, so we convert the latter into a List for the HTTP response to be the same in both cases.

4.2. Validate the Bulk API

We’ll run the application and validate the bulk operation by executing the above endpoint:

$ curl -i --request POST 'http://localhost:8080/api/customers/bulk' \
--header 'X-ActionType: bulk' \
--header 'Content-Type: application/json' \
--data-raw '[
    {
        "name": "test1",
        "email": "[email protected]",
        "address": "address1"
    },
    {
        "name": "test2",
        "email": "[email protected]",
        "address": "address2"
    }
]'

We’ll get the below successful response with customers created:

HTTP/1.1 201 
[{"id":1,"name":"test1","email":"[email protected]","address":"address1"},
{"id":1,"name":"test2","email":"[email protected]","address":"address2"},
...

Next, we’ll implement another approach for the bulk operation.

5. Implement a Bulk API Using a Different Endpoint

It’s not that common to have different actions on the same resource in a bulk API. However, let’s look at the most flexible approach possible to see how it can be done.

We might implement an atomic bulk operation, where the whole request succeeds or fails within a single transaction. Or, we can allow the updates that succeed to happen independently of those that fail, with a response that indicates whether it was a full or partial success. We’ll implement the second of these.

5.1. Define the Request and Response Model

Let’s consider a use case of creating, updating, and deleting multiple customers in a single call.

We’ll define the bulk request as a JSON format:

[
    {
        "bulkActionType": "<CREATE OR UPDATE OR DELETE>",
        "customers": [
            {
                "name": "<name>",
                "email": "<email>",
                "address": "<address>"
            }
        ]
    }
]

First, we’ll model the above JSON format into the CustomerBulkRequest class:

public class CustomerBulkRequest {
    private BulkActionType bulkActionType;
    private List<Customer> customers;
    //standard getters and setters
}

Next, we’ll implement the BulkActionType enum:

public enum BulkActionType {
    CREATE, UPDATE, DELETE
}

Then, let’s define the CustomerBulkResponse class as the HTTP response object*:*

public class CustomerBulkResponse {
    private BulkActionType bulkActionType;
    private List<Customer> customers;
    private BulkStatus status;
    //standard getters and setters
}

Finally, we’ll define the BulkStatus enum to specify each operation’s return status:

public enum BulkStatus {
    PROCESSED, PARTIALLY_PROCESSED, NOT_PROCESSED
}

5.2. Implement the Bulk Controller

We’ll implement a bulk API that takes the bulk requests and processes based on the bulkActionType enum and then returns the bulk status and customer data together.

First, we’ll create an EnumMap in the BulkController class and map the BulkActionType enum to its own CustomerService’s Function:

@RestController
@RequestMapping("/api")
@Validated
public class BulkController {
    private final CustomerService customerService;
    private final EnumMap<BulkActionType, Function<Customer, Optional<Customer>>> bulkActionFuncMap = 
      new EnumMap<>(BulkActionType.class);

    public BulkController(CustomerService customerService) {
        this.customerService = customerService;
        bulkActionFuncMap.put(BulkActionType.CREATE, customerService::createCustomer);
        bulkActionFuncMap.put(BulkActionType.UPDATE, customerService::updateCustomer);
        bulkActionFuncMap.put(BulkActionType.DELETE, customerService::deleteCustomer);
    }
}

This EnumMap provides a binding between the request type and the method on the CustomerService that we need to satisfy it. It helps us avoid lengthy switch or if statements.

We can pass the Function returned from the EnumMap against the action type to the map() method on a stream of Customer objects:

List<Customer> customers = customerBulkRequest.getCustomers().stream()
   .map(bulkActionFuncMap.get(customerBulkRequest.getBulkActionType()))
   ...

As all our Function objects map from Customer to Optional, this essentially uses the map() operation in the stream to execute the bulk request, leaving the resulting Customer in the stream (if available).

Let’s put this together in the full controller method:

@PostMapping(path = "/customers/bulk")
public ResponseEntity<List<CustomerBulkResponse>> bulkProcessCustomers(
  @RequestBody @Valid @Size(min = 1, max = 20) 
  List<CustomerBulkRequest> customerBulkRequests) {
    List<CustomerBulkResponse> customerBulkResponseList = new ArrayList<>();

    customerBulkRequests.forEach(customerBulkRequest -> {
        List<Customer> customers = customerBulkRequest.getCustomers().stream()
          .map(bulkActionFuncMap.get(customerBulkRequest.getBulkActionType()))
          .filter(Optional::isPresent)
          .map(Optional::get)
          .collect(toList());
        
        BulkStatus bulkStatus = getBulkStatus(customerBulkRequest.getCustomers(), 
          customers);     
        customerBulkResponseList.add(CustomerBulkResponse.getCustomerBulkResponse(customers, 
          customerbulkRequest.getBulkActionType(), bulkStatus));
    });

    return new ResponseEntity<>(customerBulkResponseList, HttpStatus.Multi_Status);
}

Also, we’ll complete the getBulkStatus method to return a specific bulkStatus enum based on the number of customers created:

private BulkStatus getBulkStatus(List<Customer> customersInRequest, 
  List<Customer> customersProcessed) {
    if (!customersProcessed.isEmpty()) {
        return customersProcessed.size() == customersInRequest.size() ?
          BulkStatus.PROCESSED : 
          BulkStatus.PARTIALLY_PROCESSED;
    }

    return BulkStatus.NOT_PROCESSED;
}

We should note that adding the input validations for any conflicts between each operation can also be considered.

5.3. Validate the Bulk API

We’ll run the application and call the above endpoint i.e. /customers/bulk:

$ curl -i --request POST 'http://localhost:8080/api/customers/bulk' \
--header 'Content-Type: application/json' \
--data-raw '[
    {
        "bulkActionType": "CREATE",
        "customers": [
            {
                "name": "test4",
                "email": "[email protected]",
                ...
            }
        ]
    },
    {
        "bulkActionType": "UPDATE",
        "customers": [
            ...
        ]
    },
    {
        "bulkActionType": "DELETE",
        "customers": [
            ...
        ]
    }
]'

Let’s now verify the successful response:

HTTP/1.1 207 
[{"customers":[{"id":4,"name":"test4","email":"[email protected]","address":"address4"}],"status":"PROCESSED","bulkType":"CREATE"},
...

Next, we’ll implement a batch API that processes both the customers and addresses, bundled together in a single batch call.

6. Implement a Batch API

Typically, a batch API request is a collection of sub-requests having its own method, resource URL, and payload.

We’ll implement a batch API that creates and updates two resource types. Of course, we can have other operations included like the delete operation. But, for simplicity, we’ll only consider the POST and PATCH methods.

6.1. Implement the Batch Request Model

First, we’ll define the mixed-data request model in the JSON format:

[
    {
        "method": "POST",
        "relativeUrl": "/address",
        "data": {
            "street": "<street>",
            "city": "<city>"
        }
    },
    {
        "method": "PATCH",
        "relativeUrl": "/customer",
        "data": {
            "id": "<id>",
            "name": "<name>",
            "email": "<email>",
            "address": "<address>"
        }
    }
]

We’ll implement the above JSON structure as the BatchRequest class:

public class BatchRequest {
    private HttpMethod method;
    private String relativeUrl;
    private JsonNode data;
    //standard getters and setters
}

6.2. Implement the Batch Controller

We’ll implement a batch API to create addresses and update customers with their addresses in a single request. We’ll write this API in the same microservice for simplicity. In another architectural pattern, we might choose to implement it in a different microservice that calls the individual endpoints in parallel.

With the above BatchRequest class, we’ll have a problem deserializing the JsonNode into a specific type class. We can easily solve this by using the ObjectMapper’s convertValue method to convert the JsonNode to a strongly typed object.

For the batch API, we’ll call either the AddressService or CustomerService method based on the requested HttpMethod and relativeUrl parameters in the BatchRequest class.

We’ll implement the batch endpoint in the BatchController class:

@PostMapping(path = "/batch")
public String batchUpdateCustomerWithAddress(
  @RequestBody @Valid @Size(min = 1, max = 20) List<BatchRequest> batchRequests) {
    batchRequests.forEach(batchRequest -> {
        if (batchRequest.getMethod().equals(HttpMethod.POST) && 
          batchRequest.getRelativeUrl().equals("/address")) {
            addressService.createAddress(objectMapper.convertValue(batchRequest.getData(), 
              Address.class));
        } else if (batchRequest.getMethod().equals(HttpMethod.PATCH) && 
            batchRequest.getRelativeUrl().equals("/customer")) {
            customerService.updateCustomer(objectMapper.convertValue(batchRequest.getData(), 
              Customer.class));
        }
    });

    return "Batch update is processed";
}

6.3. Validate the Batch API

We’ll execute the above /batch endpoint:

$ curl -i --request POST 'http://localhost:8080/api/batch' \
--header 'Content-Type: application/json' \
--data-raw '[
    {
        "method": "POST",
        "relativeUrl": "/address",
        "data": {
            "street": "test1",
            "city": "test"
        }
    },
    {
        "method": "PATCH",
        "relativeUrl": "/customer",
        "data": {
            "id": "1",
            "name": "test1",
            "email": "[email protected]",
            "address": "address2"
        }
    }
]'

We’ll validate the below response:

HTTP/1.1 200
Batch update is processed

7. Conclusion

In this article, we’ve learned how to apply bulk and batch operations in a Spring application. We’ve also understood their function as well as differences.

For the bulk operation, we’ve implemented it in two different APIs, one reusing an existing POST endpoint to create multiple resources and another approach creating a separate endpoint to allow multiple operations on multiple resources of the same type.

We’ve also implemented a batch API that allows us to apply different operations to different resources. The batch API combined different sub-requests using the HttpMethod and relativeUrl along with the payload.

As always, the example code can be found over on GitHub.