DEV Community

Vigneshwaralingam
Vigneshwaralingam

Posted on

The Zombie Entity: Why my Spring Boot API returned "200 OK" but refused to Delete

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.

linkedinPost

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
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

2. The Problem (The "Zombie" Effect)

When I called the standard repository.deleteById(id) on the child, Hibernate did something tricky:

  1. Loading: Because of FetchType.EAGER, loading the Child to delete it also loaded the Parent (InvoiceHeader).
  2. Memory State: The loaded Parent entity still had the Child in its invoiceDetails list in memory.
  3. The Conflict: I told the Repo to delete the Child. But when the transaction tried to commit, Hibernate checked the Parent.
  4. Resurrection: Hibernate saw the Child was still in the Parent's list. Since CascadeType.ALL is 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;
    }
}

Enter fullscreen mode Exit fullscreen mode

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);
    }
}

Enter fullscreen mode Exit fullscreen mode

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");
    }
};

Enter fullscreen mode Exit fullscreen mode

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)