Most Quarkus tutorials stop at the Active Record and Repository patterns. This article goes further — exploring a JPA Specification approach that gives you composable, type-safe filtering, sorting, and pagination out of the box.
In this article
- Solution 1 — Active Record Pattern
- Solution 2 — Repository Pattern
- Solution 3 — JPA Specification with Entity Manager
Active Record
- Simple CRUD
- Entity-owned logic
- Low boilerplate
Repository
- Separation of concerns
- Testable queries
- Standard patterns
JPA Specification
- Composable predicates
- Dynamic filtering
- Built-in pagination
Most of the example on the internet can be found for quarkus data operation are on active and repository pattern. In this article we will explore more specific way of data operation pattern using JPA specification.
Solution 1: Active Record Pattern
Defining your entity
@Entity
public class Person extends PanacheEntity {
public String name;
public LocalDate birth;
public Status status;
}
Core operations
// creating a person
Person person = new Person();
person.name = "Stef";
person.birth = LocalDate.of(1910, Month.FEBRUARY, 1);
person.status = Status.Alive;
// persist it
person.persist();
// note that once persisted, you don't need to explicitly save your entity: all
// modifications are automatically persisted on transaction commit.
// check if it is persistent
if(person.isPersistent()){
// delete it
person.delete();
}
// getting a list of all Person entities
List<Person> allPersons = Person.listAll();
// finding a specific person by ID
person = Person.findById(personId);
// finding specific persons by their IDs
List<Person> personsById = Person.findByIds(List.of(personId1, personId2));
// finding a specific person by ID via an Optional
Optional<Person> optional = Person.findByIdOptional(personId);
person = optional.orElseThrow(() -> new NotFoundException());
// finding all living persons
List<Person> livingPersons = Person.list("status", Status.Alive);
// counting all persons
long countAll = Person.count();
// counting all living persons
long countAlive = Person.count("status", Status.Alive);
// delete all living persons
Person.delete("status", Status.Alive);
// delete all persons
Person.deleteAll();
// delete by id
boolean deleted = Person.deleteById(personId);
// set the name of all living persons to 'Mortal'
Person.update("name = 'Mortal' where status = ?1", Status.Alive);
Adding custom query methods
@Entity
public class Person extends PanacheEntity {
public String name;
public LocalDate birth;
public Status status;
public static Person findByName(String name){
return find("name", name).firstResult();
}
public static List<Person> findAlive(){
return list("status", Status.Alive);
}
public static void deleteStefs(){
delete("name", "Stef");
}
}
Solution 2: Repository Pattern
The repository pattern separates persistence logic from the entity itself. The entity is a plain JPA object; a dedicated @ApplicationScoped repository class holds all query methods.
Defining the entity
@Entity
public class Person {
@Id @GeneratedValue private Long id;
private String name;
private LocalDate birth;
private Status status;
public Long getId(){
return id;
}
public void setId(Long id){
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public LocalDate getBirth() {
return birth;
}
public void setBirth(LocalDate birth) {
this.birth = birth;
}
public Status getStatus() {
return status;
}
public void setStatus(Status status) {
this.status = status;
}
}
Defining your repository
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
@ApplicationScoped
public class PersonRepository implements PanacheRepository<Person> {
// put your custom logic here as instance methods
public Person findByName(String name){
return find("name", name).firstResult();
}
public List<Person> findAlive(){
return list("status", Status.Alive);
}
public void deleteStefs(){
delete("name", "Stef");
}
}
All the operations that are defined on PanacheEntityBase are available on your repository, so using it is exactly the same as using the active record pattern, except you need to inject it:
import jakarta.inject.Inject;
@Inject
PersonRepository personRepository;
@GET
public long count(){
return personRepository.count();
}
Solution 3: JPA Specification with Entity Manager
This pattern builds on the previous two by introducing a composable predicate system. The entity declares which filters and sorts it supports; a dedicated specification class translates those into JPA CriteriaBuilder predicates. This is ideal for list endpoints that need dynamic filtering, sorting, and pagination.
@Entity
public class Person extends AuditTrail implements IFilterSortSupport, ICrudOperation<Person>
{
public String name;
public LocalDate birth;
public Status status;
@Override
public List<FilterOption> supportedFilters() {
return List.of(
FilterOption.of("name", FilterOperators.EQUAL, null),
FilterOption.of("startDate", FilterOperators.EQUAL, null),
FilterOption.of("status", FilterOperators.IN, String.join(",",
Status.PENDING.name(),
Status.REJECTED.name(),
Status.CANCELLED.name(),
Status.FAILED.name()
)
)
);
}
@Override
public List<SortOption> supportedSorts() {
return List.of(
SortOption.of("name", SortOperators.ASC),
SortOption.of("name", SortOperators.DESC),
SortOption.of("startDate", SortOperators.ASC),
SortOption.of("startDate", SortOperators.DESC),
SortOption.of("status", SortOperators.ASC),
SortOption.of("status", SortOperators.DESC)
);
}
@Override
public Promotion add() {
this.persist();
return this;
}
@Override
public Promotion update() {
return this;
}
}
@MappedSuperclass
public abstract class AuditTrail extends EntityId {
public String createdBy;
@Column(columnDefinition = "TIMESTAMP WITH TIME ZONE")
public Instant createdDate;
public String lastModifiedBy;
@Column(columnDefinition = "TIMESTAMP WITH TIME ZONE")
public Instant lastModifiedDate;
}
@MappedSuperclass
public abstract class EntityId extends PanacheEntityBase {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
public UUID id;
}
public interface IFilterSortSupport {
List<FilterOption> supportedFilters();
List<SortOption> supportedSorts();
}
public interface ICrudOperation<T> {
T add();
T update();
}
Useful method
@ApplicationScoped
public final class PersonRepository implements IPersonRepository {
private final EntityManager entityManager;
private final IPredicateSpecification<Person> PersonSpec;
public PromoRepository(EntityManager em, PromotionPredicate PersonPredicate) {
this.entityManager = em;
this.PersonSpec = PersonPredicate;
}
@Override
public FindResponse<Promotion> find(FilterSortPageableRequest request) {
List<Promotion> promotions= QueryBuilder.of(this.entityManager, Promotion.class, this.promotionSpec)
.withFilters(request.filters())
.withSort(request.sorts())
.withPaging(request.paging())
.build();
long count = Promotion.count();
return new FindResponse<>(promotions, count);
}
}
Over here we are injecting two dependency as below
private final EntityManager entityManager;
private final IPredicateSpecification<Person> PersonSpec;
public sealed interface IPredicateSpecification<T> permits BasePredicate {
List<Predicate> applyFilterPredicates(Root<T> root, CriteriaBuilder cb, List<FilterOption> filters);
Order applySortOrder(Root<T> root, CriteriaBuilder cb, SortOption sort);
void applyPagination(TypedQuery<T> query, PagingRequest paging);
}
*Then create *
@ApplicationScoped
public class PersonPredicate extends BasePredicate<Promotion> {
@Override
public List<Predicate> applyFilterPredicates(Root<Promotion> root, CriteriaBuilder cb, List<FilterOption> filters) {
if (filters == null || filters.isEmpty()) return List.of();
List<FilterOption> supported = new Promotion().supportedFilters();
PromotionSpecification spec = new PromotionSpecification(root, cb);
return filters.stream()
.map(f -> {
isSupportedFilter(supported, f);
return switch (f.field()) {
case "status" -> spec.personStatusEquals(f.values(), f.field());
case "title" -> spec.personTitleLike(f.values(), f.field());
case "startDate" -> spec.personStartDateEqual(f.values(), f.field());
default -> throw new IllegalStateException("Unsupported field: " + f.field());
};
})
.toList();
}
@Override
public Order applySortOrder(Root<Promotion> root, CriteriaBuilder cb, SortOption sort) {
List<SortOption> supportedFilters = new Promotion().supportedSorts();
isSupportedSort(supportedFilters, sort);
return super.applySortOrder(root, cb, sort);
}
}
public record PromotionSpecification(Root<Promotion> root, CriteriaBuilder cb) {
Predicate personStatusEquals(Object value, String field) {
return cb.equal(root.get(field), value);
}
Predicate personTitleLike(Object value, String field) {
return cb.like(cb.lower(root.get(field)), "%" + value.toString().toLowerCase() + "%");
}
Predicate personStartDateEqual(Object value, String field) {
return cb.equal(root.get(field), value);
}
}
public non-sealed abstract class BasePredicate<T> implements IPredicateSpecification<T> {
@Override
public List<Predicate> applyFilterPredicates(Root<T> root, CriteriaBuilder cb, List<FilterOption> filters) {
return List.of();
}
@Override
public Order applySortOrder(Root<T> root, CriteriaBuilder cb, SortOption sort) {
if (sort == null) return null;
return SortOperators.DESC.equals(sort.operator())
? cb.desc(root.get(sort.field()))
: cb.asc(root.get(sort.field()));
}
@Override
public void applyPagination(TypedQuery<T> query, PagingRequest paging) {
if (paging == null) {
query.setFirstResult(ApplicationConstants.ApplicationDefaultValue.DEFAULT_CURRENT_PAGE * ApplicationConstants.ApplicationDefaultValue.DEFAULT_PAGE_SIZE);
query.setMaxResults(ApplicationConstants.ApplicationDefaultValue.DEFAULT_PAGE_SIZE);
return;
};
query.setFirstResult(paging.currentPage() * paging.pageSize());
query.setMaxResults(paging.pageSize());
}
}
The QueryBuilder fluent API chains filters, sort, and pagination before executing a single TypedQuery. All three concerns are handled by the injected IPredicateSpecification, keeping the repository itself free of query logic.
When to use each pattern
Active Record
Simple domains where queries are few and entities are small. Fastest to write.
Repository
When you want to keep entity classes clean and query logic testable in isolation.
JPA Specification
List endpoints requiring dynamic filters, multi-field sorting, or pagination — especially where filter combinations grow over time.
Top comments (0)