import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import java.time.Instant;
@MappedSuperclass
public abstract class SoftDeletable {
@Column(nullable = false)
private boolean deleted = false;
@Column(name = "deleted_at")
private Instant deletedAt;
public void markDeleted() {
this.deleted = true;
this.deletedAt = Instant.now();
}
public void restore() {
this.deleted = false;
this.deletedAt = null;
}
public boolean isDeleted() {
return deleted;
}
public Instant getDeletedAt() {
return deletedAt;
}
}
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
@Entity
@Table(name = "customers")
@Where(clause = "deleted = false")
@SQLDelete(sql = "UPDATE customers SET deleted = true, deleted_at = now() WHERE id = ?")
public class Customer extends SoftDeletable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private String displayName;
protected Customer() {
}
public Customer(String email, String displayName) {
this.email = email;
this.displayName = displayName;
}
public Long getId() {
return id;
}
public String getEmail() {
return email;
}
public String getDisplayName() {
return displayName;
}
}
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface CustomerRepository extends JpaRepository<Customer, Long> {
// Native query bypasses the @Where(deleted = false) filter.
@Query(value = "SELECT * FROM customers WHERE id = :id AND deleted = true", nativeQuery = true)
Optional<Customer> findDeletedById(@Param("id") Long id);
@Modifying
@Query(value = "UPDATE customers SET deleted = false, deleted_at = NULL WHERE id = :id", nativeQuery = true)
int restoreById(@Param("id") Long id);
}
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CustomerService {
private final CustomerRepository repository;
private final EntityManager entityManager;
public CustomerService(CustomerRepository repository, EntityManager entityManager) {
this.repository = repository;
this.entityManager = entityManager;
}
@Transactional
public void softDelete(Long id) {
Customer customer = repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Customer not found: " + id));
repository.delete(customer); // rewritten to UPDATE by @SQLDelete
}
@Transactional
public Customer restore(Long id) {
Customer deleted = repository.findDeletedById(id)
.orElseThrow(() -> new IllegalStateException("No deleted customer with id " + id));
int updated = repository.restoreById(deleted.getId());
if (updated == 0) {
throw new IllegalStateException("Restore failed for customer " + id);
}
// Native update skipped the persistence context; drop stale state before reloading.
entityManager.clear();
return repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Customer vanished after restore: " + id));
}
}
Soft-deletion keeps rows in the database but hides them from normal queries by flagging them as deleted instead of issuing a DELETE. This preserves history, keeps foreign keys valid, and makes accidental removals recoverable. The trade-off is that every read must exclude deleted rows, which is easy to forget; the approach shown pushes that filtering down into the mapping layer so it happens automatically.
In SoftDeletable, a mapped superclass centralizes the deleted boolean and a deletedAt timestamp so every soft-deletable entity inherits the same columns and helper logic. The markDeleted and restore methods keep the flag and timestamp in sync, avoiding the bug where one is set without the other.
In Customer entity, the class extends SoftDeletable and is annotated with Hibernate's @Where(clause = "deleted = false"). That clause is appended to every query Hibernate generates for the entity — finder methods, JPQL, and lazy association loads all silently skip deleted rows. The @SQLDelete annotation rewrites the physical delete Hibernate emits so calling repository.delete(entity) runs an UPDATE ... SET deleted = true instead of removing the row, meaning existing delete call sites become soft deletes without changes.
Because @Where also hides deleted rows from the repository, restoring them needs a path that bypasses the filter. In CustomerRepository, a native @Query with nativeQuery = true selects by id while ignoring the clause, and a modifying @Modifying restoreById query flips the flag directly in SQL. Native queries are used deliberately: JPQL would inherit the @Where filter and never see the deleted row.
In CustomerService, softDelete delegates to the repository's overridden delete, while restore first loads the row through findDeletedById, guards against restoring something not actually deleted, then issues restoreById. The @Transactional boundary ensures the modifying query and any follow-up reads commit together.
The main pitfalls to remember: unique constraints still apply to soft-deleted rows, so a deleted column often belongs in partial unique indexes; and native restore queries skip Hibernate's dirty-checking, so the persistence context should be cleared or the entity reloaded afterward.
Related snips
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
class AddSettingsToAccounts < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_column :accounts, :settings, :jsonb, null: false, default: {}
Postgres JSONB Partial Index for Feature Flags
module EmailNormalization
extend ActiveSupport::Concern
included do
attr_accessor :soft_warnings
Soft Validation: Normalize + Validate Email
class CreateTopSellersMv < ActiveRecord::Migration[7.0]
def up
execute <<~SQL
CREATE MATERIALIZED VIEW top_sellers AS
SELECT p.id AS product_id,
p.name AS product_name,
Cache-Friendly “Top N” with Materialized View Refresh
class CreateDeadJobs < ActiveRecord::Migration[7.1]
def change
create_table :dead_jobs do |t|
t.string :jid, null: false
t.string :queue, null: false
t.string :klass, null: false
Background Job Dead Letter Queue (DLQ) Table
Share this code
Here's the card — post it anywhere.