Soft Deletes Done Right: Hibernate Filters in Quarkus with Panache and PostgreSQL
Learn how to build a clean, audit-friendly soft-delete system in a modern Java application using Quarkus and Hibernate Filters.
You’ve been asked to build a backend service for managing products in a retail company’s internal catalog system. Business users want to “delete” products from the UI, but the compliance team insists that no data should ever be physically deleted from the database.
At the same time, product managers want to:
Only see “active” (non-deleted) products in their dashboards.
Review deleted items when necessary (for reinstating SKUs or reporting).
Ensure that all “deletes” are audit-compliant.
That’s where soft deletes come in.
Instead of deleting rows, we’ll set a deleted
flag to true
. And to keep our queries clean, we’ll use Hibernate Filters to automatically exclude these records unless we explicitly ask for them.
What You’ll Build
A Quarkus-based backend application with:
A PostgreSQL database
A
Product
entity supporting soft deletesHibernate Filters to dynamically include/exclude deleted items
A REST API to manage products, view active/deleted items, and restore soft-deleted data
Prerequisites
Make sure the following tools are installed:
JDK 11+ (JDK 17 recommended)
Maven 3.8.1+
Podman for running Dev Services
Quarkus CLI (optional but handy)
You can also grab the source-code from my Github repository and start from there.
Project Setup
Let’s scaffold the project using Maven:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=org.acme \
-DprojectArtifactId=quarkus-hibernate-filters \
-DclassName="org.acme.ProductResource" \
-Dpath="/products" \
-Dextensions="hibernate-orm-panache,rest-jackson,jdbc-postgresql"
cd quarkus-hibernate-filters
Configure the database dev service and some more Hibernate in src/main/resources/application.properties
:
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.log.sql=true
quarkus.hibernate-orm.filter."deletedProductFilter".active=false
The Product Entity with a Soft Delete Flag
Create in src/main/java/org/acme/Product.java
package org.acme.todo;
import org.hibernate.annotations.Filter;
import org.hibernate.annotations.FilterDef;
import org.hibernate.annotations.ParamDef;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
@FilterDef(name = "deletedProductFilter", parameters = @ParamDef(name = "isDeleted", type = boolean.class))
@Filter(name = "deletedProductFilter", condition = "deleted = :isDeleted")
public class Product extends PanacheEntity {
public String name;
public double price;
public boolean deleted = false;
public Product() {}
public Product(String name, double price) {
this.name = name;
this.price = price;
}
}
Key points:
@FilterDef
and@Filter
tell Hibernate how to dynamically filter out soft-deleted products.The
deleted
flag determines visibility.
Service Layer: Enabling and Disabling Filters
Create src/main/java/com/ProductService.java
:
package org.acme.todo;
import java.util.List;
import org.hibernate.Session;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class ProductService {
@Inject EntityManager entityManager;
@Transactional
public void addProduct(Product product) {
product.persist();
}
public List<Product> getActiveProducts() {
Session session = entityManager.unwrap(Session.class);
session.enableFilter("deletedProductFilter").setParameter("isDeleted", false);
List<Product> list = Product.listAll();
session.disableFilter("deletedProductFilter");
return list;
}
public List<Product> getDeletedProducts() {
Session session = entityManager.unwrap(Session.class);
session.enableFilter("deletedProductFilter").setParameter("isDeleted", true);
List<Product> list = Product.listAll();
session.disableFilter("deletedProductFilter");
return list;
}
public List<Product> getAllProductsIncludingDeleted() {
return Product.listAll();
}
@Transactional
public boolean softDeleteProduct(Long id) {
Product p = Product.findById(id);
if (p != null) {
p.deleted = true;
p.persist();
return true;
}
return false;
}
@Transactional
public boolean restoreProduct(Long id) {
Session session = entityManager.unwrap(Session.class);
session.enableFilter("deletedProductFilter").setParameter("isDeleted", true);
Product p = Product.findById(id);
session.disableFilter("deletedProductFilter");
if (p != null && p.deleted) {
p.deleted = false;
p.persist();
return true;
}
return false;
}
@Transactional
public Product findById(Long id, boolean includeDeleted) {
Session session = entityManager.unwrap(Session.class);
if (!includeDeleted)
session.enableFilter("deletedProductFilter").setParameter("isDeleted", false);
Product product = Product.findById(id);
if (!includeDeleted)
session.disableFilter("deletedProductFilter");
return product;
}
}
REST API: Exposing Product Operations
Edit ProductResource.java
:
package org.acme.todo;
import java.util.List;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/products")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ProductResource {
@Inject
ProductService service;
@POST
public Response add(Product p) {
service.addProduct(p);
return Response.status(Response.Status.CREATED).entity(p).build();
}
@GET
@Path("/active")
public List<Product> getActive() {
return service.getActiveProducts();
}
@GET
@Path("/deleted")
public List<Product> getDeleted() {
return service.getDeletedProducts();
}
@GET
@Path("/all")
public List<Product> getAll() {
return service.getAllProductsIncludingDeleted();
}
@GET
@Path("/{id}")
public Response get(@PathParam("id") Long id,
@QueryParam("includeDeleted") @DefaultValue("false") boolean include) {
Product p = service.findById(id, include);
if (p == null || (!include && p.deleted))
return Response.status(Response.Status.NOT_FOUND).entity("Product not found.").build();
return Response.ok(p).build();
}
@DELETE
@Path("/{id}")
public Response softDelete(@PathParam("id") Long id) {
return service.softDeleteProduct(id)
? Response.ok("Soft-deleted.").build()
: Response.status(Response.Status.NOT_FOUND).build();
}
@PUT
@Path("/{id}/restore")
public Response restore(@PathParam("id") Long id) {
return service.restoreProduct(id)
? Response.ok("Restored.").build()
: Response.status(Response.Status.NOT_FOUND).build();
}
}
Testing the API
Start your app:
quarkus dev
Try it with curl/Postman:
curl -X POST http://localhost:8080/products \
-H "Content-Type: application/json" \
-d '{"name":"Gaming Monitor","price":299.99}'
curl http://localhost:8080/products/active
curl -X DELETE http://localhost:8080/products/1
curl http://localhost:8080/products/active
curl http://localhost:8080/products/all
curl http://localhost:8080/products/deleted
curl -X PUT http://localhost:8080/products/1/restore
Watch the logs and observe the generated SQL queries. Hibernate will inject WHERE deleted = false
when the filter is active.
Why This Pattern Works
Separation of concerns: Query logic stays clean. The filtering is handled declaratively.
Business flexibility: Users can choose to see only active items or everything.
Compliance ready: You never lose data. Great for audit trails, GDPR, or internal QA.
Extendable: This same technique works for things like
tenantId
,archived
, orpublished
.
Where to Go from Here
This soft delete pattern is just one example of what Hibernate Filters can do in a modern Quarkus application.
Try this next:
Add a
lastModified
column and audit timestamps.Implement multi-tenancy using filters and a
tenant_id
field.Create a reusable
@SoftDelete
annotation to centralize logic.