Don't DELETE Me Bro
Deleting Data in a Spring Boot REST API
Some say that all good things must come to an end. While this exploration of the DELETE method marks the end of this article series, you’ll see that deletion is not necessarily the end of life for the data items backing your REST API.
Guide to the Series: REST Fundamentals with Spring Boot
- Introduction: REST API development with Spring Boot
- GET All The Things
- Test All the Things
- Send It, POST-Haste
- To PUT Or To PATCH
- Don’t DELETE Me Bro (you are here)
Handle a Delete Request
The controller method to handle a DELETE request is quite simple. The @DeleteMapping includes a path variable placeholder which is used to identify the specific resource to be deleted. After calling the related service method to delete the resource, it simply returns a 204 No Content response.
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteWorkOrder(@PathVariable("id") Long id) {
workOrderService.deleteWorkOrder(id);
return ResponseEntity.noContent().build();
}
The Service implementation is also very straightforward, dimple delegating to the JpaRepository to delete the corresponding entity.
@Override
public void deleteWorkOrder(Long id) {
workOrderRepository.deleteById(id);
}
Hard-delete or Soft-delete?
Although deleting data is rather simple, there are still some options to consider. By default, the deleteById(Long id) repository method will indeed delete the appropriate data from the database. However, some systems may require an alternative approach where the data is not really deleted, but only marked or flagged in a way that the system overall will behave as if the entity was gone (such as by not including it in query results) while the entity record does still exist in the database, and may be accessible from other systems. This approach is called "soft-delete."
Adding support for soft deletes is a matter of adding two Hibernate annotations to the entity object, along with implementing a lifecycle callback method:
First, we add a simple boolean field to indicate if an entity is deleted or not.
private boolean deleted = Boolean.FALSE;
Next, we define a SQL statement to be executed in place of the default delete statement that would otherwise be executed, and use it to annotate the entity class via the @SQLDelete Hibernate annotation.
@SQLDelete(sql = "UPDATE work_orders SET deleted = true WHERE id = ?")
We also add the Hibernate @Where annotation to exclude soft-deleted entities from Hibernate queries throughout the application.
@Where(clause = "deleted = false")
It should be noted that the soft delete happens in the database, but there could still be entity objects tracked by Hibernate that will not yet reflect this change. To address that, we add a lifecycle callback method which updates the boolean field. Applying the @PreRemove annotation marks this as a method to be executed before the delete operation, ensuring data integrity between the database and these objects.
@PreRemove
private void preRemove() {
this.deleted = true;
}
Entity Object with Soft-Delete
package com.springbikeclinic.api.domain;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import javax.persistence.*;
import java.sql.Timestamp;
@Entity
@Table(name = "work_orders")
@SQLDelete(sql = "UPDATE work_orders SET deleted = true WHERE id = ?")
@Where(clause = "deleted = false")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class WorkOrder {
@Id
@GeneratedValue
private Long id;
@CreationTimestamp
@Column(nullable = false, updatable = false)
private Timestamp createdDateTime;
private String bikeDescription;
private String workDescription;
private String status;
private String mechanicNotes;
private boolean deleted = Boolean.FALSE;
@PreRemove
private void preRemove() {
this.deleted = true;
}
}
If your requirements include the option to access data items that have been soft-deleted, you’ll need to take a different approach because the @Where annotation will prevent such access. Hibernate includes the @FilterDef and @Filter annotations to define a filter and then use that filter in service methods to include or exclude the soft-deleted data.
Unit Tests
We can expand our unit tests to cover the delete operation in both the controller and our service implementation. Since the delete operation is quite simple, the tests are pretty simple as well, but they do provide basic verification that nothing has been broken and therefore they provide this value as the application evolves in the future as well.
@Test
void deleteWorkOrder_validInput_returnsNoContent() throws Exception {
mockMvc.perform(delete(WORK_ORDERS_BASE_PATH + "1"))
.andExpect(status().isNoContent());
}
We can again make use of ArgumentCaptor<T> when unit testing the service method, though it’s really just verifying that the expected id value is passed to the repository.
@Test
void deleteWorkOrder_validInput_deletePerformed() throws Exception {
Long existingId = existingWorkOrderDto.getId();
ArgumentCaptor<Long> idArgumentCaptor = ArgumentCaptor.forClass(Long.class);
// Call the SUT method
workOrderService.deleteWorkOrder(existingId);
verify(workOrderRepository, times(1)).deleteById(idArgumentCaptor.capture());
assertThat(idArgumentCaptor.getValue()).isEqualTo(existingId);
}
Integration Tests
Another option is to expand our tests to include Integration Tests, where a real Spring context is initialized and where the full application is tested across the boundaries of the controller, service, repository, and database persistence. One such example is shown below, where we utilize Spring’s TestRestTemplate to exercise the HTTP methods within the Spring application context that is started as result of the @SpringBootTest class annotation.
The example test you see below performs a simple GET request, then a DELETE request for the same entity, and finally the GET request again to verify that the entity is in fact deleted. The initial GET and DELETE are successful because, with the full context initialized, our CommandLineRunner implementation inserts this sample data entity into the H2 database that is automatically configured by Spring.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class WorkOrderControllerIntegrationTest {
public static final String LOCALHOST = "http://localhost:";
private static final String WORK_ORDERS_BASE_PATH ="/api/v1/work-orders/";
@LocalServerPort
private int serverPort;
private TestRestTemplate testRestTemplate;
@BeforeEach
void setUp() {
testRestTemplate = new TestRestTemplate();
}
@Test
void deleteWorkOrder_validInputAndThenGet_returns404() throws Exception {
String url = LOCALHOST + serverPort + WORK_ORDERS_BASE_PATH + "1";
ResponseEntity<JsonNode> workOrderResponse =
testRestTemplate.getForEntity(url, JsonNode.class);
assertThat(workOrderResponse.getStatusCode()).isEqualTo((HttpStatus.OK));
// exercise SUT method
testRestTemplate.delete(url);
// get the same entity, which should no longer exist
ResponseEntity<JsonNode> deletedWorkOrderResponse =
testRestTemplate.getForEntity(url, JsonNode.class);
assertThat(deletedWorkOrderResponse.getStatusCode()).isEqualTo((HttpStatus.NOT_FOUND));
}
}
We’ve reached the end of this series covering the fundamentals of REST API development with Spring Boot. While entire books have been written which expand on these core capabilities, I hope this introductory series highlights the ease with which you can get started and build a solid Java foundation for your REST APIs.