Back to portfolio
Feature

April 2022

Reusable Soft Deletion Layer for Entities

Implemented a production-grade soft deletion layer for resource entities in Spring Boot, featuring automatic query filtering, annotation-driven dependency tracking, cascading checks, and scheduled hard-deletion cleanup for unreferenced records.

Java
Spring Boot
Spring Data JPA
Hibernate

Soft Deletion in Spring Boot: A Complete Guide

TL;DR: Hard deletion is irreversible and often wrong. Soft deletion flags records as deleted while keeping them in the database. This guide walks through a production-grade soft deletion architecture in Spring Boot — from the domain model to the repository layer, scheduled cleanup, and all the edge cases in between.


Table of Contents

  1. What is Soft Deletion?
  2. Why Not Just Hard Delete?
  3. Core Domain Design
  4. The Repository Architecture
  5. Delete Flow: Sequence Diagrams
  6. Scheduled Hard Deletion Cleanup
  7. Custom Repository Factory Wiring
  8. Dependency Tracking with Annotations
  9. Query Filtering: Never Leak Deleted Data
  10. Class Diagram: Full Architecture Overview
  11. Trade-offs and Gotchas
  12. Summary Checklist

1. What is Soft Deletion?

Soft deletion is the practice of marking a record as "deleted" using a flag column (e.g., is_deleted = true) rather than issuing a SQL DELETE statement. The record stays in the database but is hidden from normal queries.

Compare the two approaches:

Hard DeletionSoft Deletion
MechanismDELETE FROM table WHERE id = ?UPDATE table SET is_deleted = true WHERE id = ?
Data recovery❌ Gone forever✅ Can be restored
Audit trail❌ Lost✅ Preserved
Referential integrityCan cause FK violationsFK always satisfied
Disk usageLower over timeGrows unless cleaned up
Query complexitySimpleRequires WHERE is_deleted = false everywhere

2. Why Not Just Hard Delete?

Referential Integrity

Consider a Supplier entity referenced by BookableService, Brand, Discount, and RatePlan. If you hard-delete a Supplier that is still referenced, you'll get a foreign key constraint violation at the database level.

Soft deletion sidesteps this: the Supplier row is still there, so no FK violation occurs.

Auditing & Compliance

Many systems need to answer: "What did this booking look like when it was created?" If you hard-delete a Brand that was associated with a historical Itinerary, you lose that context. Soft deletion preserves the complete historical record.

Accidental Deletion Recovery

Soft deletion gives you a safety net. Hard deletion requires point-in-time backups and significant recovery effort.

The Problem: Cascading Dependencies

The tricky part is determining when it is actually safe to physically remove the data. If a Supplier is soft-deleted but still referenced by active BookableService rows, hard-deleting it would break things. This requires dependency tracking — the central challenge this guide addresses.


3. Core Domain Design

The SoftDeletable Interface

Everything starts with a simple interface:

public interface SoftDeletable {
    Boolean getIsDeleted();
    void setIsDeleted(Boolean isDeleted);

    default SoftDeletable isDeleted(Boolean isDeleted) {
        this.setIsDeleted(isDeleted);
        return this;
    }
}

Any entity that supports soft deletion implements this interface.

Applying it to an Entity

@Entity
@Table(name = "supplier")
@DependantReferences({
    @DependantReference(domainClass = BookableService.class, attributeName = "supplier"),
    @DependantReference(domainClass = Brand.class,          attributeName = "suppliers"),
    @DependantReference(domainClass = Destination.class,    attributeName = "suppliers"),
    @DependantReference(domainClass = Discount.class,       attributeName = "supplier"),
    @DependantReference(domainClass = RatePlan.class,       attributeName = "supplier"),
})
public class Supplier implements Serializable, SoftDeletable {

    @JsonIgnore
    @Column(name = "is_deleted", nullable = false)
    private Boolean isDeleted = Boolean.FALSE;

    @Override
    public Boolean getIsDeleted() { return this.isDeleted; }

    @Override
    public void setIsDeleted(Boolean isDeleted) { this.isDeleted = isDeleted; }
}

Key decisions:

  • @JsonIgnore prevents isDeleted from leaking into API responses.
  • Default is Boolean.FALSE so new records are never accidentally created as deleted.
  • @DependantReferences declares which entities hold a reference to this one (more on this below).

Entity Relationship Overview

Each entity in this graph that implements SoftDeletable is annotated with @DependantReferences pointing to all entities that reference it.


4. The Repository Architecture

The architecture uses a layered approach to intercept Spring Data JPA's default delete behaviour.

SoftDeletingRepository Interface

@NoRepositoryBean
public interface SoftDeletingRepository<T extends SoftDeletable, ID> 
    extends CrudRepository<T, ID> {

    void softDelete(T entity);
    void softDeleteInBatch(Iterable<T> entities);
    void softDeleteAllInBatch();
    boolean isHardDeletable(T entity);
    List<T> findAllNonReferencedDeletedSoftDeletables();
    void hardDeleteAllNonReferencedDeletedSoftDeletables();
}

JpaSoftDeletingRepository Implementation

This class extends Spring's SimpleJpaRepository and overrides the delete, deleteInBatch, and deleteAllInBatch methods. It also overrides getQuery and getCountQuery to automatically inject WHERE is_deleted = false into every derived query.

Repository Declaration

@CustomRepository(baseClass = JpaSoftDeletingRepository.class)
public interface SupplierRepository 
    extends JpaRepository<Supplier, String>, 
            SoftDeletingRepository<Supplier, String> {
    // custom queries here
}

The @CustomRepository annotation tells the custom factory which base implementation to use.


5. Delete Flow: Sequence Diagrams

5.1 — Standard Delete (Smart Routing)

When repository.delete(entity) is called, the system decides whether to soft-delete or hard-delete based on whether the entity is referenced elsewhere.

5.2 — Soft Delete (Explicit)

softDelete is a direct update — no reference checking.

5.3 — Delete with Batch Routing

5.4 — Query Filtering (Transparent to Callers)

Every query automatically excludes soft-deleted records via DeletedSpecification.


6. Scheduled Hard Deletion Cleanup

Soft-deleted records accumulate over time. A scheduled job periodically identifies and hard-deletes those that are no longer referenced by anything.

The cron expression 0 0 * * * * runs this every hour. Adjust to taste based on your data volume and retention requirements.


7. Custom Repository Factory Wiring

Spring Data JPA normally instantiates SimpleJpaRepository for all repositories. To use JpaSoftDeletingRepository as the base for specific repositories, a custom factory chain is needed.

Component Interaction

Registration in DatabaseConfiguration

@EnableJpaRepositories(
    basePackages = "com.golfsafaris.repository",
    repositoryFactoryBeanClass = JpaCustomRepositoryFactoryBean.class
)
public class DatabaseConfiguration { ... }

When a repository is annotated with @CustomRepository(baseClass = JpaSoftDeletingRepository.class), the factory reads that annotation and uses the specified base class instead of SimpleJpaRepository.


8. Dependency Tracking with Annotations

The @DependantReference / @DependantReferences annotation pair is what makes reference-aware deletion possible without resorting to catching ConstraintViolationException at runtime (fragile and non-portable across databases).

How isHardDeletable Works

Example: Checking Supplier references

Given:

@DependantReferences({
    @DependantReference(domainClass = BookableService.class, attributeName = "supplier"),
    @DependantReference(domainClass = Brand.class,          attributeName = "suppliers"),
    ...
})

The system executes criteria queries equivalent to:

SELECT COUNT(*) FROM bookable_service WHERE supplier_id = :id
SELECT COUNT(*) FROM brand_supplier WHERE supplier_id = :id
-- etc.

If any of these returns > 0, the entity is soft-deleted instead of hard-deleted.

Note on ProxyUtils: Spring and Hibernate often wrap entities in CGLIB proxies. ProxyUtils.getUserClass(entity) ensures you're inspecting the real class, not the proxy wrapper, when looking up annotations.


9. Query Filtering: Never Leak Deleted Data

Overriding getQuery and getCountQuery

The most elegant part of this design is that all Spring Data derived queries (findAll(), findById(), custom @Query with JPQL, etc.) are automatically filtered — without requiring every repository method to manually add AND is_deleted = false.

@Override
protected <S extends T> TypedQuery<S> getQuery(
    @Nullable Specification<S> spec, Class<S> domainClass, Sort sort) {

    Specification<S> notDeleted = new DeletedSpecification<>();
    return super.getQuery(
        spec != null ? spec.and(notDeleted) : notDeleted,
        domainClass, sort
    );
}

DeletedSpecification adds WHERE is_deleted = false (or true when looking for soft-deleted records to clean up).

Manual JPQL Queries Still Need Filtering

This automatic injection only works for criteria-based queries. Custom @Query annotations bypass getQuery, so they need manual filtering:

// ✅ Correct — manually filtered
@Query("SELECT c FROM Customer c WHERE c.isDeleted = false")
List<Customer> findAllWithEagerRelationships();

// ❌ Wrong — leaks deleted records
@Query("SELECT c FROM Customer c")
List<Customer> findAllWithEagerRelationships();

Rule of thumb: Any @Query or native query you write must include AND entity.isDeleted = false (or WHERE ... AND supplier.isDeleted = false).


10. Class Diagram: Full Architecture Overview


11. Trade-offs and Gotchas

✅ Pros

  • Safe deletes by default — you never accidentally orphan data.
  • Transparent filtering — criteria queries are auto-filtered; callers don't need to think about it.
  • Deferred physical cleanup — the scheduled job handles garbage collection during off-peak hours.
  • Annotation-driven dependency tracking — no runtime ConstraintViolationException surprises.

⚠️ Gotchas

1. Manual @Query must be updated

Every JPQL or native query you write bypasses the automatic DeletedSpecification. Forgetting AND x.isDeleted = false in a custom query will silently return deleted records. Consider adding a linting rule or integration test that asserts no soft-deleted records appear in query results.

2. Unique constraints may need rethinking

If email on Customer has a UNIQUE constraint, a soft-deleted customer with that email will prevent a new customer from registering with the same address. Solutions include composite unique constraints like UNIQUE(email, is_deleted) or using a separate deleted_at timestamp instead of a boolean.

3. Database index bloat

Soft-deleted rows still participate in index scans unless you use partial indexes. On PostgreSQL:

CREATE INDEX idx_supplier_active ON supplier(id) WHERE is_deleted = false;

4. N+1 in isHardDeletable

Each @DependantReference triggers a separate COUNT query. An entity with 7 dependant references issues 7 queries. For bulk deletions, this compounds. Consider batching or caching the reference check results.

5. Cascaded soft deletion is not automatic

Deleting an Itinerary does not automatically soft-delete its ItineraryCustomer children. You must handle cascaded soft deletion explicitly in your service layer:

public void deleteItinerary(String id) {
    Itinerary itinerary = fetchEntity(id);
    itinerary.getItineraryCustomers().forEach(ic -> itineraryCustomerRepo.delete(ic));
    itineraryRepository.delete(itinerary);
}

6. @Scheduled on repository is unusual

Scheduling the cleanup job directly on the repository works, but it ties infrastructure concerns to the data layer. A cleaner alternative is a dedicated SoftDeletionCleanupService that calls hardDeleteAllNonReferencedDeletedSoftDeletables() on each soft-deleting repository.


12. Summary Checklist

When adding soft deletion to a new entity, here is everything you need:

  • Implement SoftDeletable on the entity class
  • Add is_deleted BOOLEAN NOT NULL DEFAULT FALSE column to the entity
  • Add @JsonIgnore to the isDeleted field
  • Annotate the entity with @DependantReferences(...) listing all entity classes that hold a reference to it
  • Annotate the repository with @CustomRepository(baseClass = JpaSoftDeletingRepository.class)
  • Extend SoftDeletingRepository<T, ID> in the repository interface
  • Add AND entity.isDeleted = false to every custom @Query in the repository
  • Add a Liquibase changeset to add the is_deleted column
  • Review service layer for cascade soft-deletion needs
  • Consider partial indexes on active records in your database

Architecture based on a production Spring Boot 2.x / Spring Data JPA system. Diagrams reflect the actual behaviour of JpaSoftDeletingRepository.