Implementing a soft delete strategy with Spring Data JPA and Hibernate

Rather than permanently erasing rows, many Australian teams prefer to keep customer records hidden behind a flag. This pattern, called soft delete, satisfies retention controls demanded by the Australian Privacy Principles while still keeping referential integrity intact across JPA entities. The technique also plays nicely with audit trails that ASIC examiners occasionally request from fintechs operating in the Sydney and Melbourne CBDs.

Spring Data JPA and Hibernate give you several ways to implement this behaviour, ranging from a simple boolean column to lifecycle callbacks and repository-level filtering. The next sections walk through a pragmatic setup that holds up in production, including the inevitable edge cases around cascading, restoration, and testing.

What soft delete actually means in a JPA context

When you mark a record as deleted instead of issuing a DELETE statement, the row stays in the table and travels through foreign keys unchanged. Reporting jobs, analytical pipelines, and admin screens can still query the row when needed, but ordinary user flows treat it as if it were gone.

This trade-off matters for any application that touches personally identifiable information. Under the Privacy Act 1988, organisations are expected to handle data subject requests carefully, and a soft delete can give you a natural buffer zone for undoing accidental removals before the data is hard-purged after a reasonable retention window. Many Sydney-based consultancies pair this technique with scheduled batch jobs that purge archived rows after the seven-year window that ASIC suggests for certain financial records.

Hibernate does not ship with a built-in soft delete feature the way some ORMs do, so you build it from primitives you already know: a column, an annotation or two, and a repository convention.

Mapping the deleted flag on your entity

The most common approach adds a deleted boolean column, defaulting to false, alongside your existing fields. A more useful choice is a timestamp column called deleted_at, because knowing when an entity was removed is often more valuable than just knowing that it was removed, especially when auditors ask for evidence.

@Entity
@Table(name = "customer")
@SQLDelete(sql = "UPDATE customer SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Customer {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private LocalDateTime deletedAt;
    // getters, setters
}

The @SQLDelete annotation replaces Hibernate's default DELETE with an UPDATE, while @SQLRestriction injects a filter into every SELECT the entity manager issues. Note that the older @Where annotation is deprecated as of Hibernate 6.3, so reaching for @SQLRestriction keeps the setup future-proof.

Australian teams often name their flag column using snake_case to match legacy schemas found at the big four banks, so settle on a naming convention before migrating a large dataset.

Filtering through repository queries

Adding the annotation is only half the job. You also need repository methods that explicitly bypass the filter when required, such as admin searches or reconciliation scripts.

Because @SQLRestriction rewrites the query automatically, simple finder methods honour the filter without extra code. To retrieve the deleted records, write a custom query that drops the restriction:

@Query(value = "SELECT * FROM customer WHERE deleted_at IS NOT NULL",
       nativeQuery = true)
List<Customer> findAllDeleted();

If you also use Spring Data specifications, remember that predicates you compose will be combined with the generated restriction clause, so be deliberate about how you express isDeleted checks. A native query is the cleanest escape hatch when you really need full visibility, and it is the route most presenters at the Melbourne Java User Group reach for when auditing historical customer data.

Restoring a soft-deleted entity

Resurrection is just another UPDATE. A straightforward service method flips the flag back to null and persists the entity again:

@Transactional
public Customer restore(Long id) {
    Customer c = repository.findByIdIncludingDeleted(id)
                           .orElseThrow(NotFoundException::new);
    c.setDeletedAt(null);
    return repository.save(c);
}

Repository finders that include deleted rows usually come from a custom repository fragment built with @Query and no entity manager filter. Keep in mind that restoring an entity can quietly re-expose orphan rows in child collections, so cascading rules deserve a closer look.

If you are building internal tooling for a finance team in Brisbane or Perth, a confirmation screen that previews the cascade is worth the extra hour, since users tend to trust undo buttons more when they can see what will happen.

Cascading soft deletes through related entities

The annotation approach behaves well for an isolated entity, but it does not automatically extend to OneToMany or ManyToOne associations. If a Customer owns multiple Invoices, you need to decide how soft delete propagates.

A common pattern uses @SQLDelete on each entity you want to archive, then manually walks related collections during the service-layer deletion:

@Transactional
public void softDeleteCustomer(Long id) {
    Customer c = repo.findById(id).orElseThrow();
    c.getInvoices().forEach(inv -> inv.setDeletedAt(now()));
    c.setDeletedAt(now());
}

For very large aggregates, batched updates run faster than loading every related row into memory. Hibernate's @FilterDef and @Filter annotations offer a session-scoped alternative that can be turned on for admin views and off for normal flows, which is handy when regulators in Australia request a snapshot of all related records without changing your production code paths.

Testing soft delete behaviour with JUnit and Testcontainers

Unit tests give you speed, but integration tests catch issues like accidentally exposing deleted rows through a derived query. Testcontainers spinning up a real PostgreSQL container in the ap-southeast-2 region gives you confidence that the schema behaves the same way locally as it does in your Sydney production cluster.

Pair Testcontainers with JUnit 5 and assert that the row physically stays put:

@Test
void deleteMarksRowRatherThanRemovingIt() {
    Customer c = repo.save(new Customer("Alice"));
    service.softDelete(c.getId());
    assertThat(repo.count()).isEqualTo(1L);
    assertThat(c.getDeletedAt()).isNotNull();
}

Mockito handles the service layer nicely, but for repository behaviour, run the test against the container, not a mocked JpaRepository. The Australian tech community often shares Testcontainer configurations out of the YOW! conference repositories, and it is a worthwhile investment of an afternoon to copy a working setup rather than rebuild one from scratch over a Tuesday arvo.

For deeper reading on the Java side of these annotations, this how to use Java 5 guide covers the language mechanics behind annotation processing that the soft delete pattern relies on.

Recommended practices for production rollouts