April 2022
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.
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.
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 Deletion | Soft Deletion | |
|---|---|---|
| Mechanism | DELETE FROM table WHERE id = ? | UPDATE table SET is_deleted = true WHERE id = ? |
| Data recovery | ❌ Gone forever | ✅ Can be restored |
| Audit trail | ❌ Lost | ✅ Preserved |
| Referential integrity | Can cause FK violations | FK always satisfied |
| Disk usage | Lower over time | Grows unless cleaned up |
| Query complexity | Simple | Requires WHERE is_deleted = false everywhere |
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.
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.
Soft deletion gives you a safety net. Hard deletion requires point-in-time backups and significant recovery effort.
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.
SoftDeletable InterfaceEverything 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.
@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.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).Each entity in this graph that implements SoftDeletable is annotated with @DependantReferences pointing to all entities that reference it.
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 ImplementationThis 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.
@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.
When repository.delete(entity) is called, the system decides whether to soft-delete or hard-delete based on whether the entity is referenced elsewhere.
softDelete is a direct update — no reference checking.
Every query automatically excludes soft-deleted records via DeletedSpecification.
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.
Spring Data JPA normally instantiates SimpleJpaRepository for all repositories. To use JpaSoftDeletingRepository as the base for specific repositories, a custom factory chain is needed.
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.
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).
isHardDeletable WorksSupplier referencesGiven:
@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.
getQuery and getCountQueryThe 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).
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
@Queryor native query you write must includeAND entity.isDeleted = false(orWHERE ... AND supplier.isDeleted = false).
ConstraintViolationException surprises.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.
When adding soft deletion to a new entity, here is everything you need:
SoftDeletable on the entity classis_deleted BOOLEAN NOT NULL DEFAULT FALSE column to the entity@JsonIgnore to the isDeleted field@DependantReferences(...) listing all entity classes that hold a reference to it@CustomRepository(baseClass = JpaSoftDeletingRepository.class)SoftDeletingRepository<T, ID> in the repository interfaceAND entity.isDeleted = false to every custom @Query in the repositoryis_deleted columnArchitecture based on a production Spring Boot 2.x / Spring Data JPA system. Diagrams reflect the actual behaviour of JpaSoftDeletingRepository.