In Spring Boot and JPA, Bidirectional relationships require bidirectional management. You can't just delete the child; you have to break the link from the parent side too.Here is my simple demo for this.
Have you ever written a standard delete function, tested it, got a 200 OK success response, but when you checked the database, the record was still watching at you?
I recently faced this exact issue while building the Invoice Module for my project, Rytways. I was trying to delete an item from an invoice. The frontend said "Success", the backend said "OK", but the data refused to die. It was a Zombie Object. 🧟♂️
Here is the breakdown of the problem, the code involved, and the fix.
1. The Setup (The Entities)
I have a Bidirectional One-to-Many relationship between an Invoice Header (Parent) and Invoice Details (Child).
The Parent (InvoiceHeaderEntity.java):
Notice the CascadeType.ALL and orphanRemoval = true. This implies that the Header controls the lifecycle of its details.
@Entity
@Getter
@Setter
public class InvoiceHeaderEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long invoiceHeaderId;
// The Parent holds a list of children
@OneToMany(mappedBy = "invoiceHeader", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
@JsonIgnoreProperties("invoiceHeader")
private List<InvoiceDetailsEntity> invoiceDetails;
// ... other fields
}
The Child (InvoiceDetailsEntity.java):
@Entity
@Data
public class InvoiceDetailsEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long invoiceDetailsId;
@ManyToOne(fetch = FetchType.EAGER)
@JsonIgnoreProperties("invoiceDetails")
@JoinColumn(name = "invoice_header_id", nullable = false)
private InvoiceHeaderEntity invoiceHeader;
// ... other fields
}
2. The Problem (The "Zombie" Effect)
When I called the standard repository.deleteById(id) on the child, Hibernate did something tricky:
-
Loading: Because of
FetchType.EAGER, loading the Child to delete it also loaded the Parent (InvoiceHeader). -
Memory State: The loaded Parent entity still had the Child in its
invoiceDetailslist in memory. - The Conflict: I told the Repo to delete the Child. But when the transaction tried to commit, Hibernate checked the Parent.
-
Resurrection: Hibernate saw the Child was still in the Parent's list. Since
CascadeType.ALLis active, Hibernate prioritized the Parent's state and effectively canceled the deletion (or re-saved the child).
3. The Fix (The Service Layer)
To fix this, we cannot just delete the child. We must remove the child from the parent's list and let orphanRemoval=true handle the database deletion.
InvoiceDetailsService.java (Corrected Code):
@Service
public class InvoiceDetailsService {
@Autowired
InvoiceDetailsRepo invoiceDetailsRepo;
@Autowired
InvoiceHeaderRepo invoiceHeaderRepo;
public String deleteInvoiceDetails(Long id) {
// 1. Find the item to be deleted
InvoiceDetailsEntity detail = invoiceDetailsRepo.findById(id)
.orElseThrow(() -> new RuntimeException("Item not found"));
// 2. Get the parent Header
InvoiceHeaderEntity header = detail.getInvoiceHeader();
if (header != null) {
// 3. THE FIX: Remove the item from the parent's list.
// Using removeIf ensures we match by ID and avoid object reference issues.
header.getInvoiceDetails().removeIf(d -> d.getInvoiceDetailsId().equals(id));
// 4. Save the Header.
// Because 'orphanRemoval = true', JPA deletes the item from DB
// automatically since it's no longer in the list.
invoiceHeaderRepo.save(header);
} else {
// Fallback if no header exists
invoiceDetailsRepo.deleteById(id);
}
return "Invoice Details is deleted :Id: " + id;
}
}
4. The Controller
The controller remains simple. It just call updated service.
InvoiceDetailsController.java:
@RestController
@RequestMapping("/invoiceDetails")
public class InvoiceDetailsController {
@Autowired
private InvoiceDetailsService invoiceDetailsService;
@DeleteMapping("/delete/{id}")
public ResponseEntity<String> deleteInvoiceDetails(@PathVariable Long id) {
// We don't need to manually calculate summary here anymore
// if the service handles the relationship correctly.
invoiceDetailsService.deleteInvoiceDetails(id);
return new ResponseEntity<>("Invoice details deleted successfully", HttpStatus.OK);
}
}
5. The Frontend (React)
On the client side, I call this API. If the response is OK, I remove the item from the UI state.
NewSales.js:
const handleDelete = async (id) => {
// Calling the Spring Boot API
const res = await del(api + "/invoiceDetails/delete/" + id);
console.log("deleted data response..", res);
if (response.ok) {
AlertHandler("Item deleted", "success");
// Update the UI state to reflect the deletion immediately
// This prevents the user from seeing the item until a refresh
setItemFormData((prev) => prev.filter((item) => item.invoiceDetailsId !== id));
// Optionally reload the full data to ensure sync with DB
// await loadInvoiceDetails();
} else {
AlertHandler("Failed to delete item", "danger");
}
};
Key Takeaway
In Full Stack development, a "200 OK" doesn't always mean the data operation went as planned. When using JPA/Hibernate, Bidirectional relationships require bidirectional management.
If you delete a child, make sure the parent knows about it!

Top comments (0)