Build Reddit-Style Nested Comments in Quarkus Using PostgreSQL Recursive CTEs
Avoid N+1 query traps and fetch entire comment trees with a single SQL call in this hands-on Java developer tutorial.
A Reddit-style comment thread is a great real-world example of hierarchical data.
Every reply can have replies. Threads become trees.
If you implement this naively with JPA and lazy @OneToMany, you’ll quickly meet the N+1 query monster.
In this tutorial you’ll build:
A simple comment domain model
A PostgreSQL schema
A Jakarta Data repository using a recursive CTE (Common Table Expression) (
WITH RECURSIVE)A service that reconstructs the tree in Java
REST APIs:
addComment(parentId, text)andgetThread(rootId)All in one DB round-trip per thread
The focus: show how Quarkus with Jakarta Data + Hibernate + PostgreSQL recursive CTEs solve hierarchical data cleanly.
Prerequisites and setup
You’ll need:
Java 21 (or 17+), Maven 3.9+
Quarkus 3.27+ (any recent 3.x with Hibernate ORM 7 & Jakarta Data support)
Podman/Docker for Dev Services (PostgreSQL)
We’ll use:
quarkus-hibernate-ormquarkus-jdbc-postgresqlquarkus-resteasy-reactive-jacksonjakarta.data:jakarta.data-apiHibernate Data Repositories
Create the project
Using the Quarkus CLI:
quarkus create app org.acme:recursive-comments \
--extensions=resteasy-reactive-jackson,hibernate-orm,jdbc-postgresql
cd recursive-commentsAnd as usual, find the complete source code in my Github respository!
Add Jakarta Data & Hibernate processor
In pom.xml: add the annotationProcessors to the maven compiler plugin.
<dependencies>
<!-- existing Quarkus deps ... -->
<!-- Jakarta Data API -->
<dependency>
<groupId>jakarta.data</groupId>
<artifactId>jakarta.data-api</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<!-- existing plugins ... -->
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>${compiler-plugin.version}</version>
<configuration>
<parameters>true</parameters>
<annotationProcessorPathsUseDepMgmt>true</annotationProcessorPathsUseDepMgmt>
<annotationProcessorPaths>
<annotationProcessorPath>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-processor</artifactId>
<version>${hibernate.version}</version>
</annotationProcessorPath>
</annotationProcessorPaths>
<annotationProcessors>
<annotationProcessor>org.hibernate.processor.HibernateProcessor</annotationProcessor>
</annotationProcessors>
</configuration>
</plugin>
</plugins>
</build>
Use the Hibernate version that matches the Quarkus BOM you’re on. The Quarkus docs and Hibernate Data Repositories site describe this setup. (Hibernate)
Configure PostgreSQL via Dev Services
In src/main/resources/application.properties:
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.schema-management.strategy=drop-and-create
# Helpful in dev
quarkus.hibernate-orm.log.sql=true
quarkus.hibernate-orm.format-sql=trueQuarkus Dev Services will spin up PostgreSQL automatically in dev/test.
Domain model: Comment entity
We keep the entity minimal on purpose:
No bidirectional collections (no lazy children)
Use a simple
parent_idcolumnStore a
threadRootIdfor convenience
Create src/main/java/org/acme/comments/Comment.java:
package org.acme.comments;
import java.time.OffsetDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = “comments”)
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = “thread_root_id”, nullable = false)
private Long threadRootId;
@Column(name = “parent_id”)
private Long parentId;
@Column(nullable = false, length = 2000)
private String content;
@Column(name = “created_at”, nullable = false)
private OffsetDateTime createdAt;
// JPA requires a no-arg constructor
protected Comment() {
}
public Comment(Long threadRootId, Long parentId, String content, OffsetDateTime createdAt) {
this.threadRootId = threadRootId;
this.parentId = parentId;
this.content = content;
this.createdAt = createdAt;
}
public Long getId() {
return id;
}
public Long getThreadRootId() {
return threadRootId;
}
public Long getParentId() {
return parentId;
}
public String getContent() {
return content;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setContent(String content) {
this.content = content;
}
public void setThreadRootId(Long threadRootId) {
this.threadRootId = threadRootId;
}
}Notes:
threadRootIdpoints to the ID of the top-level comment in the thread.
For a root comment,threadRootId == id.parentIdis null for root comments, set for replies.
Jakarta Data repository with a recursive CTE
We want:
Normal CRUD using Jakarta Data
One native PostgreSQL recursive CTE to fetch the whole thread in a single query
A projection record for
id,parentId,content,level,path
Hibernate Data Repositories support:
@Repository,CrudRepository,@Insert,@Find,@QueryVendor-specific
@SQLfor native SQL queries (Thorben Janssen)
Create src/main/java/org/acme/comments/CommentRepository.java:
package org.acme.comments;
import java.util.List;
import org.hibernate.annotations.processing.SQL;
import jakarta.data.repository.CrudRepository;
import jakarta.data.repository.Repository;
@Repository
public interface CommentRepository extends CrudRepository<Comment, Long> {
/**
* Load an entire comment thread in one DB round-trip using a recursive CTE.
*
* The SQL:
* - Starts from the root comment (id = :rootId)
* - Joins children by parent_id
* - Computes a “level” and “path” for stable ordering
*/
@SQL(”“”
with recursive thread as (
select c.id,
c.parent_id,
c.content,
0 as level,
lpad(c.id::text, 10, ‘0’) as path
from comments c
where c.id = :rootId
union all
select child.id,
child.parent_id,
child.content,
parent.level + 1 as level,
parent.path || ‘.’ || lpad(child.id::text, 10, ‘0’) as path
from comments child
join thread parent on child.parent_id = parent.id
)
select id, parent_id, content, level, path
from thread
order by path
“”“)
List<ThreadRow> loadThread(long rootId);
/**
* Flat projection of the recursive CTE result.
*/
record ThreadRow(
Long id,
Long parentId,
String content,
int level,
String path) {
}
}Key points:
@Repository+CrudRepositorymakes this a Jakarta Data repository.The CTE calculates
leveland a stringpath(zero-padded id chain) so we can sort comments in a stable “tree order”.The method returns a projection record instead of
Comment.
Hibernate maps the columns to constructor parameters by position. (Thorben Janssen)
Tree reconstruction in Java
The CTE gives you a flat list.
The API should return a nested structure.
We’ll build a simple tree representation:
package org.acme.comments;
import java.util.ArrayList;
import java.util.List;
public record CommentThreadNode(
Long id,
Long parentId,
String content,
int level,
List<CommentThreadNode> replies) {
public CommentThreadNode {
// Always ensure replies is mutable
if (replies == null) {
replies = new ArrayList<>();
}
}
}Now a service that:
Inserts comments
Builds the nested structure from
ThreadRowlist
Create src/main/java/org/acme/comments/CommentService.java:
package org.acme.comments;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class CommentService {
@Inject
CommentRepository repository;
@Transactional
public Comment addRootComment(String content) {
var now = OffsetDateTime.now();
// Insert with temporary thread_root_id value (1) to satisfy NOT NULL constraint
var comment = new Comment(1L, null, content, now);
Comment inserted = repository.insert(comment);
// Update thread_root_id to the comment’s own ID using save()
inserted.setThreadRootId(inserted.getId());
return repository.save(inserted);
}
@Transactional
public Comment addReply(Long parentId, String content) {
Comment parent = repository.findById(parentId)
.orElseThrow(() -> new IllegalArgumentException(”Parent not found: “ + parentId));
var now = OffsetDateTime.now();
var reply = new Comment(parent.getThreadRootId(), parentId, content, now);
return repository.insert(reply);
}
public CommentThreadNode getThread(long rootId) {
List<CommentRepository.ThreadRow> rows = repository.loadThread(rootId);
if (rows.isEmpty()) {
throw new IllegalArgumentException(”Thread not found for rootId “ + rootId);
}
Map<Long, CommentThreadNode> byId = new LinkedHashMap<>();
CommentThreadNode root = null;
// First pass: create nodes
for (CommentRepository.ThreadRow row : rows) {
CommentThreadNode node = new CommentThreadNode(
row.id(),
row.parentId(),
row.content(),
row.level(),
new ArrayList<>());
byId.put(row.id(), node);
if (row.parentId() == null) {
root = node;
}
}
// Second pass: attach children
for (CommentThreadNode node : byId.values()) {
Long parentId = node.parentId();
if (parentId != null) {
CommentThreadNode parent = byId.get(parentId);
if (parent != null) {
parent.replies().add(node);
}
}
}
return root;
}
}A couple of comments:
repository.insert() and repository.save() come from CrudRepository (Jakarta Data, jakarta.data.repository.CrudRepository).
In a production app you’d likely model threadRootId differently and have a cleaner root creation flow. For this tutorial, the focus is recursion + tree reconstruction.
If you want to avoid the slightly awkward setThreadRootId() + save() pattern, you can:
Set threadRootId after flush using an entity listener, or
Treat “threads” as posts and keep threadRootId everywhere equal to a separate post_id.
For now, we keep it simple: root comments are inserted with a temporary thread_root_id value (1) to satisfy the NOT NULL constraint, then updated to their own ID using repository.save().
REST API layer
We expose two endpoints:
POST /comments– create a root commentPOST /comments/{parentId}/replies– add a replyGET /comments/thread/{rootId}– fetch the whole nested thread
DTOs first: src/main/java/org/acme/comments/CommentResourceDtos.java:
package org.acme.comments;
import java.time.OffsetDateTime;
import java.util.List;
public class CommentResourceDtos {
public record NewCommentRequest(
String content) {
}
public record CommentResponse(
Long id,
Long parentId,
Long threadRootId,
String content,
OffsetDateTime createdAt) {
}
public record ThreadNodeResponse(
Long id,
Long parentId,
int level,
String content,
List<ThreadNodeResponse> replies) {
}
}Then the resource: src/main/java/org/acme/comments/CommentResource.java:
package org.acme.comments;
import java.util.List;
import org.acme.comments.CommentResourceDtos.CommentResponse;
import org.acme.comments.CommentResourceDtos.NewCommentRequest;
import org.acme.comments.CommentResourceDtos.ThreadNodeResponse;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path(”/comments”)
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CommentResource {
@Inject
CommentService service;
@Transactional
@POST
public Response addRootComment(NewCommentRequest request) {
if (request == null || request.content() == null || request.content().isBlank()) {
throw new BadRequestException(”content must not be empty”);
}
Comment created = service.addRootComment(request.content());
CommentResponse dto = toCommentResponse(created);
return Response.status(Response.Status.CREATED).entity(dto).build();
}
@Transactional
@POST
@Path(”/{parentId}/replies”)
public Response addReply(@PathParam(”parentId”) Long parentId,
NewCommentRequest request) {
if (request == null || request.content() == null || request.content().isBlank()) {
throw new BadRequestException(”content must not be empty”);
}
Comment created = service.addReply(parentId, request.content());
CommentResponse dto = toCommentResponse(created);
return Response.status(Response.Status.CREATED).entity(dto).build();
}
@GET
@Path(”/thread/{rootId}”)
public ThreadNodeResponse getThread(@PathParam(”rootId”) Long rootId) {
CommentThreadNode root = service.getThread(rootId);
return toThreadNodeResponse(root);
}
private static CommentResponse toCommentResponse(Comment c) {
return new CommentResponse(
c.getId(),
c.getParentId(),
c.getThreadRootId(),
c.getContent(),
c.getCreatedAt());
}
private static ThreadNodeResponse toThreadNodeResponse(CommentThreadNode node) {
List<ThreadNodeResponse> replies = node.replies().stream()
.map(CommentResource::toThreadNodeResponse)
.toList();
return new ThreadNodeResponse(
node.id(),
node.parentId(),
node.level(),
node.content(),
replies);
}
}Try it out: dev mode & sample requests
Start Quarkus dev mode:
./mvnw quarkus:devCreate a root comment
curl -X POST http://localhost:8080/comments \
-H "Content-Type: application/json" \
-d '{"content": "This is the root comment"}'Expected response (ids will differ):
{
“id”: 1,
“parentId”: null,
“level”: 0,
“content”: “This is the root comment”,
“replies”: []
}Add replies
# Reply 1 to root
curl -X POST http://localhost:8080/comments/1/replies \
-H "Content-Type: application/json" \
-d '{"content": "First reply"}'
# Reply 2 to root
curl -X POST http://localhost:8080/comments/1/replies \
-H "Content-Type: application/json" \
-d '{"content": "Second reply"}'
# Reply to first reply (id 2 for example)
curl -X POST http://localhost:8080/comments/2/replies \
-H "Content-Type: application/json" \
-d '{"content": "Nested reply"}'
Fetch the thread
curl http://localhost:8080/comments/thread/1Example response:
{
“id”: 1,
“parentId”: null,
“level”: 0,
“content”: “This is the root comment”,
“replies”: [
{
“id”: 2,
“parentId”: 1,
“level”: 1,
“content”: “First reply”,
“replies”: [
{
“id”: 4,
“parentId”: 2,
“level”: 2,
“content”: “Nested reply”,
“replies”: []
}
]
},
{
“id”: 3,
“parentId”: 1,
“level”: 1,
“content”: “Second reply”,
“replies”: []
}
]
}
Check your logs. You should see a single SQL statement using WITH RECURSIVE, not a cascade of SELECTs for each node.
Why this avoids N+1
The classic JPA mapping would look like:
@OneToMany(mappedBy = “parent”, fetch = FetchType.LAZY)
List<Comment> children;Then a naïve implementation of getThread would:
Load the root
For each comment, touch
getChildren()and trigger another SELECTFor a deep tree, you easily get dozens or hundreds of queries
With this approach:
The recursive CTE runs once and returns the whole tree in a flat structure
Java reconstructs the tree in-memory with cheap map lookups
You stay in control of the SQL and can tune it as needed
Hibernate Data Repositories + Jakarta Data give you:
Compile-time checked repository methods
Flexible projection types (records, DTOs)
Vendor-specific annotations like
@SQLwhen you really want database-specific power
Production notes and extensions
For a real “Reddit clone”, you would add:
Indexes on
thread_root_id,parent_id,created_atOptional
LIMIT/ pagination on repliesA max recursion depth in the CTE to avoid runaway trees
Soft deletes with a
deletedflag and masking contentAdditional fields: author, score, collapsed state
You can also:
Return a flat list to the frontend and let the client build the tree
Add an endpoint that returns a breadcrumb path from any comment to the root by inverting the recursion
Add a second CTE that counts descendants per node for quick “X replies” badges
Once you’re comfortable with this, hierarchical data stops being scary.




Excellent walkthrough of solving hiearchical data without the N+1 trap. The recursive CTE approach paired with Jakarta Data's @SQL annotation is really clever for keeping control over query semantics while still getting type-safe projections. One thing worth noting is how that zero-padded path column guarantees stable sorting even when sibling comment IDs are out of order. Would be interesting to benchmark how this scales comapred to materialized path or closure table patterns when thread depth gets past maybe 10-15 levels.