DEV Community

Cover image for Data Access Patterns in Quarkus:Beyond the Basics
anand jaisy
anand jaisy

Posted on

Data Access Patterns in Quarkus:Beyond the Basics

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

  1. Solution 1 — Active Record Pattern
  2. Solution 2 — Repository Pattern
  3. 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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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");
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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;
    }

}
Enter fullscreen mode Exit fullscreen mode
@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();
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

*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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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)