CodeGym /Cursos /Hibernate deep-dive /Mecanismos de soft delete para el catálogo

Mecanismos de soft delete para el catálogo

Hibernate deep-dive
Nivel 20, Lección 4
Disponible

1. Principios de selección del mecanismo

El soft delete tiene un punto engañoso: técnicamente parece muy simple. Agregas una columna deleted, empiezas a escribir where deleted = false en todas partes, y parece que la vida está resuelta. El problema es que esa «simplicidad» rápidamente se convierte en un pantano. A la semana aparecerán consultas donde olvidaste la condición, a las dos semanas consultas donde la condición está mal escrita, y al mes estarás atrapando un error que depende de qué repositorio escribió un desarrollador concreto y lo despierto que estaba.

En un proyecto Hibernate-heavy necesitamos otra sensación: cuando veo cualquier read-case, puedo predecir de antemano si una fila soft-deleted aparecerá en el resultado. Y no quiero jugar a «encuentra los 15 lugares donde olvidaste la condición». Por lo tanto, nuestro objetivo en este curso es fijar las reglas de visibilidad ya sea en el mapping (cuando son casi siempre iguales) o en un mecanismo de escenario explícito (cuando deben cambiar), y no esparcir condiciones por todas las consultas como confeti en una fiesta de empresa.

Tres preguntas para elegir el mecanismo

Antes de elegir una anotación (o decidir «¿anotaciones o no?»), es útil detenerse y hacerse tres preguntas. Y sí, este es uno de esos raros casos en los que «hablemos de significados» realmente te ahorra código y dolores de cabeza.

La primera pregunta es: ¿qué se considera exactamente "eliminado" en el sentido de negocio? Esto es más importante de lo que parece. A veces «eliminar un producto» significa «desaparece del catálogo para siempre». A veces significa «desaparece para los clientes, pero permanece en el historial de pedidos». A veces significa «desaparece en las listas normales, pero el administrador puede ver el archivo». La segunda pregunta: ¿dónde está físicamente el indicador de eliminación? ¿En la tabla de la entidad misma (product.deleted), en una join table (si usas ManyToMany) o en una link entity separada (como en nuestro caso product_category_assignment.deleted)? Y la tercera pregunta: ¿la visibilidad debe cambiar según el escenario? Es decir, «normalmente ocultamos, pero a veces mostramos» es una mecánica completamente diferente a «ocultamos siempre».

Estas tres preguntas te darán no una «anotación correcta», sino una clase de solución correcta. Y entonces se vuelve más fácil: Hibernate es una herramienta que se puede elegir para una tarea, no un casino donde apuestas por @SoftDelete y esperas que la vida mejore.

Mapa de mecanismos y limitaciones

Para no tener todo esto en la cabeza como una mezcla confusa, es útil resumir los mecanismos en una pequeña tabla. La tabla no es magia, pero evita el «oh, ¿ya hablamos de esto?».

Mecanismo Dónde se aplica Siempre activado Se puede cambiar Cambia la semántica de remove() Cuándo es adecuado
@SoftDelete en la entidad (y algunas tablas de relación) sí prácticamente no (para HQL/JPQL normal) sí (normalmente UPDATE en lugar de DELETE) cuando el soft delete es parte del ciclo de vida de la entidad y se necesita un default fuerte
@SQLRestriction en entidad/colección sí no no cuando la regla de visibilidad es simple, estática y no se quiere/no se puede tocar la semántica de delete
@SQLJoinTable Restriction en @ManyToMany join table sí no no cuando el indicador de visibilidad está en la join table y la regla siempre es la misma
@Filter + enableFilter() en entidad/colección no sí no (por sí mismo) cuando la visibilidad debe cambiar según el escenario
@FilterJoinTable en join table (en ManyToMany) no sí no cuando el indicador está en la join table, pero la regla depende del escenario

Y aquí hay una «espina» práctica importante: @SoftDelete crea una regla de lectura muy fuerte. Si piensas «luego en JPQL escribiré where deleted = true y encontraré los eliminados», entonces Hibernate puede decepcionarte. Agregará su propia condición de visibilidad, y obtendrás una consulta al estilo deleted=true AND deleted=false. Es decir, a veces @SoftDelete resuelve perfectamente la visibilidad por defecto, pero hace que la «lectura de archivo» sea más complicada (generalmente a través de una native query o un modelado separado).

2. Proyecto: dónde está el indicador de eliminación

Ahora aterrizaremos todo en nuestro proyecto. Recuerdo un hecho importante: en Commerce Persistence Lab la relación Product ↔ Category no está hecha como un ingenuo ManyToMany. Usamos una link entity ProductCategoryAssignment. Esto significa que la cuestión de «dónde almacenar el flag deleted para la relación» se resuelve a nivel de esta entidad, no a nivel de anotaciones de join table.

De forma condicional (simplificada), nuestras tablas se ven así:

Tabla Columnas importantes Comentario
product id, sku, name, deleted sku es único y no se reutiliza
category id, code, name, deleted code es único y no se reutiliza
product_category_assignment id, product_id, category_id, sort_order, assigned_at, deleted link entity, par único product_id + category_id; la reasignación se realiza mediante restore

Es importante fijar la línea base: deleted no solo está en product y category, sino también en product_category_assignment. Para la relación, esto da una regla simple: la reasignación de una categoría no es un nuevo INSERT del mismo par, sino la restauración de una fila existente.

erDiagram
  PRODUCT ||--o{ PRODUCT_CATEGORY_ASSIGNMENT : "has"
  CATEGORY ||--o{ PRODUCT_CATEGORY_ASSIGNMENT : "has"

  PRODUCT {
    bigint id
    varchar sku
    varchar name
    boolean deleted
  }

  CATEGORY {
    bigint id
    varchar code
    varchar name
    boolean deleted
  }

  PRODUCT_CATEGORY_ASSIGNMENT {
    bigint id
    bigint product_id
    bigint category_id
    int sort_order
    timestamp assigned_at
    boolean deleted
  }

El esquema aquí no es importante por sí mismo, sino como respuesta a la segunda de las «tres preguntas»: ¿dónde está el indicador de visibilidad? En nuestro caso, está en las tablas de las entidades y también en la tabla de la link entity.

3. Elección para Product: @SoftDelete y sku

Para el producto, el soft delete es un clásico del género. Un producto puede desaparecer del catálogo activo, pero permanecer en el historial de pedidos, en los rastros de auditoría, en los enlaces y en general en todos los lugares donde es importante el hecho mismo de su existencia. Por lo tanto, para Product hemos elegido un default fuerte: @SoftDelete a nivel de entidad (strategy = SoftDeleteType.DELETED, columnName = "deleted"). La mecánica aquí es simple: remove() significa ocultación lógica, no un DELETE físico. A continuación, más importante que repetir el mapping es comprender sus consecuencias para sku.

Matiz: unicidad de sku

Ahora, el lugar más importante donde los estudiantes suelen pisar el rastrillo, y el rastrillo es paciente, no huye.

En nuestro proyecto hay una regla: sku no se reutiliza después de un soft delete. Esto significa que si un producto existió alguna vez con SKU-123, no queremos crear un nuevo producto con el mismo sku un año después, incluso si el antiguo está «eliminado». Para ello, la regla debe expresarse no solo en el código Java, sino también en el esquema de la base de datos. Por lo tanto, el índice/constraint único en sku sigue siendo normal, «para todas las filas», sin excepciones sobre deleted.

Y ahora la trampa: muchas comprobaciones al estilo existsBySku() comenzarán a comportarse «como si sku estuviera libre», porque las filas soft-deleted a menudo no son visibles a través de la lectura ORM. Es decir, puedes obtener una comprobación lógicamente incorrecta en el servicio: «sku libre», y al insertar, una caída por el constraint único en PostgreSQL. Esto no es un error de PostgreSQL, es un error en nuestra expectativa.

La forma más práctica de hacer que el comportamiento sea honesto es crear un método de verificación separado que mire directamente la tabla (native query), es decir, «incluyendo eliminados»:

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Método especial: verificamos la "ocupación" del sku en la tabla, incluyendo las filas archivadas
    @Query(value = "select exists (select 1 from product where sku = :sku)", nativeQuery = true)
    boolean existsBySkuIncludingDeleted(String sku);
}

Sí, se ve un poco «más sucio» que un derived query. Pero expresa honestamente la regla de negocio: sku está ocupado, incluso si el producto está archivado.

4. Elección para Category: @SoftDelete y lecturas de archivo

Las categorías son casi el mismo caso, solo que con una naturaleza más de «directorio». Para Category tomamos el mismo soft delete a nivel de entidad con la columna deleted: la categoría desaparece de las lecturas normales, pero permanece en enlaces e historial. Por lo tanto, aquí la cuestión principal ya no es cómo funciona remove(), sino cómo leer el archivo de manera honesta.

Importante advertencia: consultas de "archivo" y @SoftDelete

Si de repente quieres hacer esto:

// NO LO HAGAS ASÍ (como mínimo, no esperes que funcione)
List<Category> deleted = entityManager.createQuery(
        "select c from Category c where c.deleted = true",
        Category.class
).getResultList();

…entonces Hibernate puede generar un SQL donde tu condición aparezca junto con su condición de visibilidad por defecto. Y obtendrás deleted=true AND deleted=false. Esto no es «Hibernate es malo», es la lógica de @SoftDelete: considera que las filas eliminadas son, por definición, invisibles para HQL/JPQL.

Si realmente necesitas una «lista de archivo», es más simple y honesto seguir el camino que ya recorrimos en el módulo sobre read-model: hacer una lectura separada a través de una native query a una proyección/DTO, y no intentar que una entidad gestionada vea temporalmente «lo que no debe ver».

Por ejemplo, una proyección muy compacta para el archivo:

// Proyección para lectura de archivo: no necesitamos una entidad gestionada, solo unos pocos campos
public interface ArchivedCategoryRow {
    Long getId();
    String getCode();
}

Y la consulta:

import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;

public interface CategoryArchiveQuery extends Repository<Category, Long> {

    // Read-case separado: leemos las categorías archivadas directamente con SQL
    @Query(value = "select id, code from category where deleted = true order by code", nativeQuery = true)
    java.util.List<ArchivedCategoryRow> findArchived();
}

Desde el punto de vista del curso, esto es hermoso: el modelo de escritura (entidad) vive con reglas fuertes, y el modelo de lectura para escenarios especiales se hace por separado y de forma transparente.

5. Relación Product—Category: elección del mecanismo

Cuando se trata de relaciones, a menudo en la cabeza del principiante aparece un pensamiento simple: «Bueno, es solo una tabla que conecta dos id… ¿qué hay que elegir?» Y aquí es donde la realidad de ORM comienza a sonreír suavemente y saca una segunda silla.

En el mundo de ManyToMany realmente tienes una join table, y si quieres ocultar filas de relación, necesitas herramientas a nivel de join table: @SQLJoinTableRestriction (estático) o @FilterJoinTable (dinámico). Allí el indicador de visibilidad está precisamente en la tabla de relación, y filtras precisamente las filas de relación.

Pero en nuestro caso, la relación está desglosada en la entidad ProductCategoryAssignment. Esto significa que el ciclo de vida y la visibilidad se diseñan a nivel de la entidad misma, no a nivel de una join table oculta.

Soft delete para ProductCategoryAssignment: nuestro default

En el catálogo elegimos este camino: ProductCategoryAssignment también vive con deleted. La relación tiene sus propios campos, queremos conservar el hecho de asignaciones pasadas, y la reasignación de una categoría la tratamos como una restauración de la misma fila, no como un segundo INSERT.

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.FetchType;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.annotations.SoftDeleteType;

@Entity
@SoftDelete(strategy = SoftDeleteType.DELETED, columnName = "deleted") // ocultamos suavemente la asignación de categoría
public class ProductCategoryAssignment {

    @Id
    private Long id; // PK de la fila de asignación

    @ManyToOne(fetch = FetchType.LAZY)
    private Product product; // a qué producto pertenece la asignación

    @ManyToOne(fetch = FetchType.LAZY)
    private Category category; // qué categoría está asignada
}

Y entonces remove() se convierte en una «archivación» de la relación.

El delete físico es útil tenerlo en mente como una opción contrastante, pero no como el default elegido. Tan pronto como la relación vive como una entidad propia, el soft delete le da la misma previsibilidad que para Product / Category.

Una vez que eliges soft delete para una link entity, aparece una responsabilidad adicional: ¿qué hacer con la unicidad del par product_id + category_id? Si en la base de datos hay un constraint único y la fila de la relación permanece, no podrás simplemente «crear una nueva relación de nuevo». Por lo tanto, «asignar una categoría otra vez» no es un insert de un nuevo par, sino restaurar el registro existente.

Mini-patrón restore en lugar de insert (cuando la relación fue eliminada)

Una de las formas más simples es darle al repositorio un UPDATE nativo que «resucite» la relación si ya existe:

import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;

public interface ProductCategoryAssignmentRepository extends Repository<ProductCategoryAssignment, Long> {

    // Restauración: si la fila de asignación ya existe y está marcada como deleted=true, simplemente la cambiamos de nuevo
    @Modifying
    @Query(value = """
        update product_category_assignment
           set deleted = false
         where product_id = :productId
           and category_id = :categoryId
           and deleted = true
        """, nativeQuery = true)
    int restore(long productId, long categoryId);
}

Y en el servicio, la lógica puede verse así (simplificada):

import jakarta.transaction.Transactional;

@Transactional
public void assignCategory(long productId, long categoryId) {
    // Primero intentamos restaurar la relación archivada (si existe)
    int restored = assignmentRepository.restore(productId, categoryId);
    if (restored > 0) return; // la relación ya existía, simplemente la restauramos

    // De lo contrario, creamos una nueva relación de la manera habitual (insert de una nueva fila)
}

Observa el estilo: aquí no intentamos «engañar» a Hibernate, reconocemos que el soft delete es una regla de visibilidad, no una desaparición de la fila. Por lo tanto, la restauración es una operación consciente.

6. Un default y escenarios separados

El error arquitectónico más común en el tema de soft delete se ve así: el desarrollador primero pone @SoftDelete, luego agrega @SQLRestriction, luego por si acaso @Filter, y luego en el repositorio escribe where deleted = false «para mayor seguridad». Este es ese raro caso en el que «asegurar» hace que el sistema sea menos seguro. Ya no entiendes qué filtró exactamente el registro: ¿la anotación? ¿el filtro? ¿la condición en la consulta? ¿o simplemente «tuve suerte»?

Por lo tanto, la regla práctica para nuestro curso suena aburrida, pero salva proyectos: cada entidad debe tener un mecanismo de visibilidad principal por defecto, y las excepciones de escenario deben estar diseñadas como read-cases separados. Para Product, Category y ProductCategoryAssignment nuestro default es @SoftDelete a nivel de entidad. Esto significa que el servicio común y el repositorio común leen solo las filas «vivas», sin condiciones manuales. @SQLRestriction y @Filter son útiles aquí no como una segunda capa obligatoria «para mayor seguridad», sino como mecanismos separados para aquellos casos donde se resuelve una tarea específica del lado de lectura.

Si necesitas una lectura por escenario (por ejemplo, «lista de archivo»), tienes dos opciones honestas. La primera opción es usar filtros dinámicos (@Filter), pero entonces debes recordar que @SoftDelete no está diseñado para «desactivarse temporalmente» en consultas HQL/JPQL. Es decir, el filtro para la visibilidad de registros eliminados generalmente tiene sentido cuando modelas la eliminación tú mismo (por ejemplo, a través de un flag deleted manual y @Filter), no a través de @SoftDelete.

La segunda opción es lo que ya mostramos con ArchivedCategoryRow: hacer un read-case separado a través de una native query y una proyección. En el marco del curso, esta opción suele ser más simple porque muestra explícitamente lo que estás haciendo y no crea un estado oculto en la Session. Sí, escribes SQL. Pero ya sabes leer el log de SQL, y en general estamos aquí precisamente para eso.

7. Errores típicos con soft delete

Error nº1: intentar obtener filas eliminadas a través de JPQL «directamente» con @SoftDelete.
La intuición sugiere: «bueno, hay un campo deleted, escribiré where deleted=true». Pero @SoftDelete agrega su propia regla de visibilidad, y corres el riesgo de obtener una condición sin sentido como deleted=true AND deleted=false. Si necesitas leer el archivo, haz un read-case nativo separado en una proyección/DTO o modela el soft delete manualmente sin @SoftDelete.

Error nº2: verificar la unicidad de una clave de negocio (sku, code) a través de métodos ORM comunes.
Es muy fácil escribir existsBySku() y pensar que si el método devuelve false, entonces sku está libre. Con soft delete, esto puede ser falso: las filas eliminadas a menudo no son visibles para la lectura ORM. En un proyecto donde las claves no se reutilizan, o verificas la unicidad con una consulta nativa «incluyendo eliminados», o confías honestamente en el constraint único de la base de datos y manejas la violación como un error de negocio.

Error nº3: mezclar varios mecanismos de filtrado «por si acaso».
La combinación de @SoftDelete + @SQLRestriction + @Filter + condiciones manuales en las consultas convierte rápidamente la lectura de datos en magia impredecible. Externamente puede parecer «somos muy seguros», pero en realidad solo has complicado la explicación: ¿por qué la fila no apareció en el resultado? Elige un mecanismo default principal y haz lecturas de escenario separadas por separado.

Error nº4: ignorar las consecuencias del soft delete para las relaciones.
Si haces soft delete de Category pero dejas las relaciones y continúas navegando perezosamente por el grafo, puedes obtener efectos inesperados: en algún lugar la categoría no se cargó, en algún lugar la consulta devolvió menos filas, en algún lugar la relación «desapareció». Esto no es motivo para activar EAGER, es motivo para diseñar las lecturas según el caso de uso y recordar que el soft delete es un cambio de visibilidad, no «como si la fila no existiera».

Error nº5: intentar reutilizar una relación o clave como si el soft delete fuera una eliminación física.
Si la fila permanece en la base de datos, entonces también permanece en las restricciones únicas. Para ProductCategoryAssignment esto es especialmente notable: el par único product_id + category_id no desaparece después de un soft delete. Por lo tanto, o haces un «restore» de la relación existente, o cambias la regla de unicidad (lo cual ya es una historia de ingeniería separada), o eliges conscientemente el delete físico para las relaciones.

1
Tarea
Hibernate deep-dive, nivel 20, lección 4
Bloqueada
Catálogo de productos con un mecanismo por defecto y lectura de archivo separada
Catálogo de productos con un mecanismo por defecto y lectura de archivo separada
1
Tarea
Hibernate deep-dive, nivel 20, lección 4
Bloqueada
Entidad de enlace con borrado suave y restauración en lugar de nuevo insert
Entidad de enlace con borrado suave y restauración en lugar de nuevo insert
1
Cuestionario/control
Borrado suave, nivel 20, lección 4
No disponible
Borrado suave
Filtros y restricciones de Hibernate
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION