Soft deletion is a crucial pattern in database design where records are marked as deleted rather than physically removed from the database. This maintains data integrity and allows for recovery if needed. Here's a complete guide to implementing soft deletes in Spring Boot with Spring Data JPA.
1. Understanding Soft Deletes
Why Use Soft Deletes?
- Preserve historical data for auditing
- Maintain referential integrity
- Allow data recovery
- Comply with data retention policies
How It Works
Instead of DELETE FROM table WHERE id = ?
, we:
- Update a
deleted
flag (typically boolean) - Or set a
deleted_at
timestamp (more flexible)
2. Basic Implementation
Entity Setup
import javax.persistence.*;
import java.time.LocalDateTime;
@MappedSuperclass
public abstract class BaseEntity {
@Column(name = "deleted")
private boolean deleted = false;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
// Getters and setters
public boolean isDeleted() {
return deleted;
}
public void setDeleted(boolean deleted) {
this.deleted = deleted;
this.deletedAt = deleted ? LocalDateTime.now() : null;
}
public LocalDateTime getDeletedAt() {
return deletedAt;
}
// Prevent setting deletedAt directly
protected void setDeletedAt(LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
}
Extending Base Entity
@Entity
@Table(name = "users")
public class User extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// Other fields, getters, setters
}
3. Repository Customization
Custom Repository Interface
import org.springframework.data.repository.NoRepositoryBean;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@NoRepositoryBean
public interface SoftDeleteRepository<T, ID> extends JpaRepository<T, ID> {
@Query("SELECT e FROM #{#entityName} e WHERE e.deleted = false")
List<T> findAllActive();
@Query("SELECT e FROM #{#entityName} e WHERE e.deleted = true")
List<T> findAllInactive();
@Query("SELECT e FROM #{#entityName} e WHERE e.deleted = false AND e.id = ?1")
Optional<T> findActiveById(ID id);
@Transactional
@Modifying
@Query("UPDATE #{#entityName} e SET e.deleted = true, e.deletedAt = CURRENT_TIMESTAMP WHERE e.id = ?1")
void softDelete(ID id);
@Transactional
@Modifying
@Query("UPDATE #{#entityName} e SET e.deleted = false, e.deletedAt = null WHERE e.id = ?1")
void restore(ID id);
}
Entity-Specific Repository
public interface UserRepository extends SoftDeleteRepository<User, Long> {
// Custom queries specific to User
List<User> findByEmailContainingAndDeletedFalse(String email);
}
4. Filtering Deleted Entities Automatically
Hibernate Filter Approach
import org.hibernate.annotations.Filter;
import org.hibernate.annotations.FilterDef;
import org.hibernate.annotations.ParamDef;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
@Entity
@Table(name = "users")
@SQLDelete(sql = "UPDATE users SET deleted = true, deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@Where(clause = "deleted = false")
@FilterDef(name = "deletedFilter", parameters = @ParamDef(name = "isDeleted", type = "boolean"))
@Filter(name = "deletedFilter", condition = "deleted = :isDeleted")
public class User extends BaseEntity {
// Entity fields
}
Enabling Filter in Service Layer
@Service
@Transactional
public class UserService {
@PersistenceContext
private EntityManager entityManager;
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public List<User> findAllIncludingDeleted() {
entityManager.unwrap(Session.class)
.enableFilter("deletedFilter")
.setParameter("isDeleted", true);
return userRepository.findAll();
}
}
5. Handling Relationships
One-to-Many Example
@Entity
public class Author extends BaseEntity {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL)
@Where(clause = "deleted = false")
private List<Book> books = new ArrayList<>();
// Other fields and methods
}
@Entity
public class Book extends BaseEntity {
@Id
@GeneratedValue
private Long id;
private String title;
@ManyToOne
@JoinColumn(name = "author_id")
private Author author;
// Other fields and methods
}
6. Customizing Delete Behavior
Overriding Repository Delete Methods
public class SoftDeleteRepositoryImpl<T, ID> extends SimpleJpaRepository<T, ID> {
private final JpaEntityInformation<T, ?> entityInformation;
private final EntityManager em;
public SoftDeleteRepositoryImpl(JpaEntityInformation<T, ?> entityInformation,
EntityManager entityManager) {
super(entityInformation, entityManager);
this.entityInformation = entityInformation;
this.em = entityManager;
}
@Override
public void delete(T entity) {
if (entity instanceof BaseEntity) {
((BaseEntity) entity).setDeleted(true);
em.persist(entity);
} else {
super.delete(entity);
}
}
@Override
public void deleteById(ID id) {
T entity = findById(id).orElseThrow(() ->
new EmptyResultDataAccessException(
String.format("No %s entity with id %s exists!",
entityInformation.getJavaType(), id), 1));
delete(entity);
}
@Override
public void deleteAll(Iterable<? extends T> entities) {
entities.forEach(this::delete);
}
@Override
public void deleteAll() {
findAll().forEach(this::delete);
}
}
Enable Repository Customization
@Configuration
@EnableJpaRepositories(repositoryBaseClass = SoftDeleteRepositoryImpl.class)
public class JpaConfig {
// Other configurations if needed
}
7. Auditing with Soft Deletes
Extending Auditing
import org.springframework.data.annotation.*;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableBaseEntity extends BaseEntity {
@CreatedBy
private String createdBy;
@CreatedDate
private LocalDateTime createdDate;
@LastModifiedBy
private String lastModifiedBy;
@LastModifiedDate
private LocalDateTime lastModifiedDate;
@DeletedBy
private String deletedBy;
// Getters and setters
}
Configuring Auditing
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
public class AuditConfig {
@Bean
public AuditorAware<String> auditorAware() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication)
.filter(Authentication::isAuthenticated)
.map(Authentication::getName);
}
}
8. QueryDSL Integration
Adding QueryDSL Predicate
public class SoftDeleteQueryDslPredicateExecutor<T> extends QuerydslPredicateExecutor<T> {
private final EntityPath<T> path;
public SoftDeleteQueryDslPredicateExecutor(EntityPath<T> path) {
this.path = path;
}
@Override
public Optional<T> findOne(Predicate predicate) {
return super.findOne(notDeleted().and(predicate));
}
@Override
public Iterable<T> findAll(Predicate predicate) {
return super.findAll(notDeleted().and(predicate));
}
@Override
public Page<T> findAll(Predicate predicate, Pageable pageable) {
return super.findAll(notDeleted().and(predicate), pageable);
}
private BooleanExpression notDeleted() {
return Expressions.booleanPath(path, "deleted").eq(false);
}
}
9. Testing Soft Deletes
Test Class Example
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class SoftDeleteTests {
@Autowired
private UserRepository userRepository;
@Test
public void testSoftDelete() {
User user = new User();
user.setName("Test User");
user.setEmail("test@example.com");
userRepository.save(user);
// Verify user is active
assertFalse(user.isDeleted());
assertNull(user.getDeletedAt());
// Soft delete
userRepository.softDelete(user.getId());
// Verify soft delete
User deletedUser = userRepository.findById(user.getId()).orElseThrow();
assertTrue(deletedUser.isDeleted());
assertNotNull(deletedUser.getDeletedAt());
// Verify standard find excludes deleted
assertFalse(userRepository.findById(user.getId()).isPresent());
// Verify findActiveById
assertFalse(userRepository.findActiveById(user.getId()).isPresent());
// Verify findAllActive excludes
assertFalse(userRepository.findAllActive().contains(deletedUser));
// Verify findAllInactive includes
assertTrue(userRepository.findAllInactive().contains(deletedUser));
}
}
10. Performance Considerations
- Indexing: Ensure you have indexes on your deletion flags/timestamp
CREATE INDEX idx_users_deleted ON users(deleted);
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
- Partitioning: For large tables, consider partitioning by deletion status
- Batch Processing: For mass deletions, use batch updates
@Modifying
@Query("UPDATE User u SET u.deleted = true WHERE u.lastLogin < :cutoffDate")
int softDeleteInactiveUsers(@Param("cutoffDate") LocalDate cutoffDate);
11. Advanced Topics
Multi-Tenancy with Soft Deletes
@Entity
@Table(name = "users")
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = "string"))
@FilterDef(name = "deletedFilter", parameters = @ParamDef(name = "isDeleted", type = "boolean"))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
@Filter(name = "deletedFilter", condition = "deleted = :isDeleted")
public class User extends BaseEntity {
@Column(name = "tenant_id")
private String tenantId;
// Other fields
}
Archive Strategy
public interface ArchiveRepository {
void archive(Long id);
void restoreFromArchive(Long id);
}
@Service
public class UserArchiveService {
private final UserRepository userRepository;
private final ArchiveRepository archiveRepository;
public void archiveUser(Long userId) {
User user = userRepository.findById(userId).orElseThrow();
archiveRepository.archive(userId);
userRepository.softDelete(userId);
}
}
12. Best Practices
- Consistent Naming: Use consistent naming (
deleted
,deleted_at
,deleted_by
) across all entities - Documentation: Clearly document soft delete behavior in your API
- Cleanup Jobs: Implement scheduled jobs to permanently delete old soft-deleted records
- API Design: Consider separate endpoints for active vs. deleted records
- Security: Ensure proper authorization for viewing/restoring deleted records
Conclusion
Soft deletes provide a robust way to handle data deletion while maintaining referential integrity and audit trails. This comprehensive implementation covers all aspects from basic setup to advanced scenarios, ensuring you can implement soft deletes effectively in your Spring Boot applications.