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.



