Data

Designing a Hierarchical Comment System with a Closure Table in Spring Boot

jin@catsriding.com
May 25, 2024
Published byJin
Designing a Hierarchical Comment System with a Closure Table in Spring Boot

Designing a Scalable Nested Comment System with Closure Tables in Spring Boot

Comment systems play a central role in facilitating user interaction across a wide range of online platforms, including websites, blogs, YouTube, and social networks. To support such interactions effectively, a well-structured database and efficient API implementation are essential. When these components are properly designed and integrated, they enable a smooth and scalable commenting experience.

We’ll explore how to build a nested comment system from the ground up, starting with database design and moving on to core APIs for creating and retrieving comments.

1. Data Modeling Strategies for Hierarchical Comments

The first step in building a comment system is designing the database schema. A comment system must efficiently represent and manage a hierarchical structure, which requires a well-suited data modeling strategy. This section explores two common approaches to modeling hierarchical data: the self-referencing strategy and the closure table strategy.

1-1. Self-Referencing Strategy

The self-referencing strategy expresses parent-child relationships within the same table. It is one of the most intuitive ways to implement a nested comment structure.

design-a-robust-comments-system-with-spring_00.png

For example, by using a parent_id column to reference another comment, we can represent nested replies as follows:

SELF_REF_COMMENTS
+----+-----------+---------------+
| id | parent_id | content       |
+----+-----------+---------------+
| 1  | null      | Great article!|
| 2  | 1         | Thanks!       |
| 3  | 1         | I agree.      |
| 4  | 3         | Me too.       |
+----+-----------+---------------+

The main advantage of this strategy lies in its simplicity. It is easy to design, implement, and maintain, and the parent-child relationship is clearly represented. For this reason, many systems adopt this approach by default.

However, this strategy has limitations when handling deep hierarchies. Retrieving all descendants of a comment or constructing a full tree structure often requires recursive or repeated queries. This can lead to performance issues and complex query logic.

1-2. Closure Table Strategy

The closure table strategy normalizes the hierarchy by explicitly storing all ancestor-descendant relationships in a separate table. This enables fast lookups of parent or child comments and allows efficient traversal of complex hierarchical structures.

design-a-robust-comments-system-with-spring_01.png

This strategy introduces an additional COMMENT_CLOSURE table alongside the COMMENTS table. The closure table stores every ancestor-descendant pair, including the self-relation for each comment.

Key benefits of the closure table strategy include:

  • Fast queries to retrieve all descendants or ancestors of a given comment
  • Ability to determine depth and relationships directly from the table
  • Lower risk of errors such as circular references or infinite loops

The trade-off is that every relationship must be explicitly stored. When a new comment is added, multiple rows must be inserted into the closure table, increasing write costs and storage usage. Despite this, it is well suited for systems where data volume is high and read performance is critical.

Let’s apply the closure table strategy to a comment creation scenario. Suppose a client submits a new comment C with comment B as its parent.

  1. The client sends the parent comment ID (B) along with the request to create comment C.
  2. The server first inserts comment C into the COMMENTS table.
  3. It then adds a self-reference for comment C in the COMMENT_CLOSURE table:
COMMENT_CLOSURE
+-------------+---------------+-------+
| ancestor_id | descendant_id | depth |
+-------------+---------------+-------+
| C           | C             | 0     |
+-------------+---------------+-------+
  1. Next, the server queries all ancestor relationships of comment B from the COMMENT_CLOSURE table:
select * from COMMENT_CLOSURE where descendant_id = :parentId

+-------------+---------------+-------+
| ancestor_id | descendant_id | depth |
+-------------+---------------+-------+
| A           | B             | 1     |
| B           | B             | 0     |
+-------------+---------------+-------+
  1. For each ancestor of B (A and B), new relationships to comment C are inserted into the closure table, effectively linking C to the existing hierarchy:
COMMENT_CLOSURE
+-------------+---------------+-------+
| ancestor_id | descendant_id | depth |
+-------------+---------------+-------+
| A           | C             | 2     |
| B           | C             | 1     |
+-------------+---------------+-------+
  1. The final state of the closure table after the insertion is as follows:
COMMENT_CLOSURE
+-------------+---------------+-------+
| ancestor_id | descendant_id | depth |
+-------------+---------------+-------+
| A           | A             | 0     |
| B           | B             | 0     |
| A           | B             | 1     |
| C           | C             | 0     |
| A           | C             | 2     |
| B           | C             | 1     |
+-------------+---------------+-------+

With all ancestor-descendant relationships explicitly stored, retrieving all descendants of a given comment becomes straightforward. For example, the following query retrieves the full subtree rooted at comment A:

select
    *
from
    COMMENT_CLOSURE
where
    ancestor_id = 'A' -- ID of the top-level comment
order by
    depth;

This query yields the following result:

COMMENT_CLOSURE
+-------------+---------------+-------+
| ancestor_id | descendant_id | depth |
+-------------+---------------+-------+
| A           | A             | 0     |
| A           | B             | 1     |
| A           | C             | 2     |
+-------------+---------------+-------+

As demonstrated, the closure table strategy is highly effective in comment systems with hierarchical structures, offering efficient and consistent query performance regardless of depth.

2. Implementing Comment System

We now turn to a practical implementation scenario, adding comment functionality to blog posts while demonstrating how the closure table strategy enables effective management of hierarchical comment structures.

2-1. Defining the Database Schema and Initializing Spring Boot Project

The first step in implementing the comment system is defining the data model. In this phase, we design a database schema based on the closure table strategy and set up a Spring Boot project to support it.

To apply the closure table strategy, we begin by designing the necessary database tables for the comment system.

design-a-robust-comments-system-with-spring_03.png

The schema consists of the following five core tables:

  • USERS: Stores user information and distinguishes between authors of posts and comments.
  • POSTS: Stores post metadata. Each comment is linked to a specific post.
  • COMMENTS: Stores individual comment content and author metadata.
  • COMMENT_CLOSURE: Represents hierarchical relationships between comments and serves as the foundation of the closure table strategy.
  • POST_COMMENTS: An explicit association table mapping comments to the posts they belong to.

Below is the SQL schema reflecting this structure:

MySQL
create table USERS
(
    id         bigint unsigned not null auto_increment primary key,
    username   varchar(60)     not null,
    updated_at datetime        not null default current_timestamp,
    created_at datetime        not null default current_timestamp
);

create table POSTS
(
    id         bigint unsigned not null auto_increment primary key,
    user_id    bigint unsigned not null,
    title      varchar(255)    not null,
    content    text            not null,
    is_deleted bit(1)          not null default 0,
    updated_at datetime        not null default current_timestamp,
    created_at datetime        not null default current_timestamp,
    foreign key (user_id) references USERS(id)
);

create table COMMENTS
(
    id         bigint unsigned not null auto_increment primary key,
    user_id    bigint unsigned not null,
    content    varchar(255)    not null,
    is_deleted bit(1)          not null default 0,
    updated_at datetime        not null default current_timestamp,
    created_at datetime        not null default current_timestamp,
    foreign key (user_id) references USERS(id)
);

create table COMMENT_CLOSURE
(
    id            bigint unsigned not null auto_increment primary key,
    ancestor_id   bigint unsigned not null,
    descendant_id bigint unsigned not null,
    depth         int             not null,
    updated_at    datetime        not null default current_timestamp,
    created_at    datetime        not null default current_timestamp,
    unique key UK_COMMENT_CLOSURE_ANCESTOR_ID_DESCENDANT_ID(ancestor_id, descendant_id),
    foreign key (ancestor_id) references COMMENTS(id),
    foreign key (descendant_id) references COMMENTS(id)
);

create table POST_COMMENTS
(
    id         bigint unsigned not null auto_increment primary key,
    post_id    bigint unsigned not null,
    comment_id bigint unsigned not null,
    is_deleted bit(1)          not null default 0,
    updated_at datetime        not null default current_timestamp,
    created_at datetime        not null default current_timestamp,
    unique key UK_POST_COMMENTS_POST_ID_COMMENT_ID(post_id, comment_id),
    foreign key (post_id) references POSTS(id),
    foreign key (comment_id) references COMMENTS(id)
);

With the schema in place, we can proceed to set up the actual project.

This implementation uses Java 17 and Spring Boot 3.2.4, with Gradle as the build tool. We configure MySQL as the database and integrate Querydsl to efficiently query the hierarchical comment structure. We also configure dependencies for validation, logging, and JPA.

The following dependencies are added to the build.gradle file:

build.gradle
dependencies {
    implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"

    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    runtimeOnly 'com.mysql:mysql-connector-j'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

Next, we define the database connection and Hibernate settings in application.yml, along with SQL logging configuration for development purposes:

application.yml
spring:
  profiles:
    active: catsriding

  output:
    ansi:
      enabled: always

  datasource:
    url: jdbc:mysql://localhost:3306/playgrounds
    username: catsriding
    password: catsriding

  jpa:
    database: mysql
    open-in-view: false
    generate-ddl: false
    properties:
      hibernate:
        default_batch_fetch_size: 1000
        format_sql: true
    hibernate:
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl

logging:
  level:
    org:
      hibernate:
        SQL: debug
        type:
          descriptor:
            sql: trace

Based on the defined schema, we now create corresponding JPA entities. These entity classes map directly to the database tables, allowing us to work with the data in object form:

Entities
@Entity @Table(name = "USERS") public class UserEntity {...}
@Entity @Table(name = "POSTS") public class PostEntity {...}
@Entity @Table(name = "COMMENTS") public class CommentEntity {...}
@Entity @Table(name = "COMMENT_CLOSURE") public class CommentClosureEntity {...}
@Entity @Table(name = "POST_COMMENTS") public class PostCommentEntity {...}

Once the entities are ready, we define repository interfaces to manage them. Each repository extends JpaRepository to provide standard CRUD operations, and we also include custom extension interfaces for domain-specific logic:

Repositories
public interface UserJpaRepository extends JpaRepository<UserEntity, Long>, UserJpaRepositoryExtension {...}
public interface PostJpaRepository extends JpaRepository<PostEntity, Long>, PostJpaRepositoryExtension {...}
public interface CommentJpaRepository extends JpaRepository<CommentEntity, Long>, CommentJpaRepositoryExtension {...}
public interface CommentClosureJpaRepository extends JpaRepository<CommentClosureEntity, Long>, CommentClosureJpaRepositoryExtension {...}
public interface PostCommentJpaRepository extends JpaRepository<PostCommentEntity, Long>, PostCommentJpaRepositoryExtension {...}

With the schema, project configuration, entities, and repositories in place, the foundational setup for building the comment system is complete. We are now ready to implement the core features for creating and retrieving nested comments.

2-2. Implementing Comment Creation API

The comment creation API is responsible for accepting input from the client, persisting the comment, and dynamically updating the hierarchical structure using the closure table.

We start by defining a DTO to encapsulate the comment creation request:

PostCommentCreateRequest.java
@Slf4j
@Getter
@Builder
public class PostCommentCreateRequest {

    @NotNull(message = "User ID is a required field")
    @Positive(message = "User ID must be a positive number")
    private final Long userId;

    @Positive(message = "Parent ID must be a positive number if provided")
    private final Long parentId;

    @NotBlank(message = "Content is a required field")
    @Size(max = 255, message = "Content must be less than or equal to 255 characters")
    private final String content;

}

In a real-world application, user information would typically be extracted from an authentication token. However, for simplicity, we receive the userId directly from the request body. The parentId is optional; if omitted, the comment is treated as a top-level entry.

The controller validates the request and delegates the core logic to the service layer:

PostCommentController.java
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/posts/{postId}/comments")
public class PostCommentController {

    private final PostCommentService service;

    @PostMapping
    public ResponseEntity<?> postsCommentCreateApi(
                @PathVariable Long postId,
                @Valid @RequestBody PostCommentCreateRequest request) {
        PostCommentCreateResponse response = service.createPostComment(
                request.toCommentCreate(),
                request.toPostCommentCreate());
        return ResponseEntity
                .ok(response);
    }
}

The service handles the full workflow, including comment persistence, closure table updates, and linking the comment to the corresponding post:

PostCommentServiceImpl.java
@Slf4j
@Service
@RequiredArgsConstructor
public class PostCommentServiceImpl implements PostCommentService {

    private final PostCommentRepository postCommentRepository;

    private final PostService postService;
    private final CommentService commentService;
    private final ClockHolder clock;

    @Override
    @Transactional
    public PostCommentCreateResponse createPostComment(
            CommentCreate commentCreate,
            PostCommentCreate postCommentCreate) {
        Post post = postService.retrievePostById(postCommentCreate.getPostId());
        Comment comment = commentService.createComment(commentCreate);
        PostComment postComment = postCommentRepository.save(PostComment.from(post, comment, clock.now()));

        log.info(
                "createPostComment: Successfully created post comment - postId={} commentId={} postCommentId={}",
                post.getId(),
                comment.getId(),
                postComment.getId());

        return PostCommentCreateResponse.response(post.getId(), comment.getId());
    }
}

The core flow of the service includes:

  • Retrieving the target post to ensure it exists.
  • Creating a new comment based on the user's input.
  • Registering an initial closure record indicating that the comment is its own ancestor (depth = 0).
  • If a parent comment exists, querying its ancestors and adding closure relationships between them and the new comment.
  • Explicitly linking the new comment to the target post using a join table.

The closure table is central to how hierarchical relationships are managed. Each comment begins by registering itself as its own ancestor, forming the base of a tree node. If a parent is specified, the new comment is then connected to the entire ancestral chain of that parent.

CommentClosure.java
@Getter
public class CommentClosure {

    private final Long id;
    private final Comment ancestor;
    private final Comment descendant;
    private final Integer depth;
    private final LocalDateTime updatedAt;
    private final LocalDateTime createdAt;

    @Builder
    private CommentClosure(
            Long id,
            Comment ancestor,
            Comment descendant,
            Integer depth,
            LocalDateTime updatedAt,
            LocalDateTime createdAt) {
        this.id = id;
        this.ancestor = ancestor;
        this.descendant = descendant;
        this.depth = depth;
        this.updatedAt = updatedAt;
        this.createdAt = createdAt;
    }

    public static CommentClosure initClosure(Comment selfNode, LocalDateTime now) {
        return CommentClosure.builder()
                .ancestor(selfNode)
                .descendant(selfNode)
                .depth(initializeDepth())
                .updatedAt(now)
                .createdAt(now)
                .build();
    }

    private static int initializeDepth() {
        return 0;
    }
}

This initial self-link establishes the root of the comment's position in the hierarchy. Once created, the comment becomes a node in the overall comment tree.

CommentServiceImpl.java
@Transactional
public Comment createComment(CommentCreate commentCreate) {
    ...
    
+   CommentClosure selfClosure = CommentClosure.initClosure(comment, clock.now());
+   selfClosure = commentClosureRepository.save(selfClosure);

    return comment;
}

While the self-referencing closure is simple, it serves as the foundation for building all subsequent hierarchical relationships. If a parentId is provided, the parent’s ancestors must be retrieved and linked to the new comment to complete the tree expansion.

The closure table stores not only direct parent-child links but all ancestor-descendant paths, so a new comment must be linked to all its ancestors—not just the immediate parent.

To do this, we query the closure table using the parent's ID:

CommentClosureJpaRepositoryImpl.java
@Slf4j
@RequiredArgsConstructor
public class CommentClosureJpaRepositoryImpl implements CommentClosureJpaRepositoryExtension {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<CommentClosureEntity> fetchAllAncestorsBy(ParentCommentId parentCommentId) {
        QCommentEntity ancestor = new QCommentEntity("ancestor");
        QCommentEntity descendant = new QCommentEntity("descendant");
        QUserEntity ancestorUser = new QUserEntity("ancestorUser");
        QUserEntity descendantUser = new QUserEntity("descendantUser");

        return queryFactory
                .select(commentClosureEntity)
                .from(commentClosureEntity)
                .innerJoin(commentClosureEntity.ancestor, ancestor).fetchJoin()
                .innerJoin(ancestor.user, ancestorUser).fetchJoin()
                .innerJoin(commentClosureEntity.descendant, descendant).fetchJoin()
                .innerJoin(descendant.user, descendantUser).fetchJoin()
                .where(
                        descendant.id.eq(parentCommentId.getId()),
                        ancestor.isDeleted.isFalse(),
                        descendant.isDeleted.isFalse()
                )
                .fetch();
    }
}

Once the ancestors are retrieved, we create a new closure record for each of them, linking them to the newly created comment. The depth is incremented by one from the ancestor's depth to reflect the new path.

CommentClosure.java
@Getter
public class CommentClosure {

    ...

    public static CommentClosure mergeClosure(
            CommentClosure ancestorNode,
            CommentClosure descendantNode,
            LocalDateTime now) {
        return CommentClosure.builder()
                .ancestor(ancestorNode.getAncestor())
                .descendant(descendantNode.getDescendant())
                .depth(increaseDepth(ancestorNode.getDepth()))
                .updatedAt(now)
                .createdAt(now)
                .build();
    }
 
    private static int increaseDepth(Integer depth) {
        return depth + 1;
    }
}

These newly constructed relationships are then saved to the database:

CommentServiceImpl.java
private void linkClosures(CommentCreate commentCreate, CommentClosure descendant) {
    if (!commentCreate.hasParentId()) return;

    List<Long> ids = commentClosureRepository.fetchAncestors(commentCreate.getParentCommentId())
            .stream()
            .map(ancestor -> CommentClosure.mergeClosure(ancestor, descendant, clock.now()))
            .map(commentClosureRepository::save)
            .map(CommentClosure::getId)
            .toList();

    log.info("linkClosures: Successfully created closures - ids={}", ids);
}

This ensures that the new comment is explicitly linked to all ancestors in the hierarchy, not just its direct parent. Unlike adjacency list models that store only immediate relationships, the closure table allows for a complete and efficient traversal of the tree.

The closure table's power lies in this structural advantage: regardless of depth or position, it allows us to query a comment's ancestors or descendants in a single efficient query, with depth available to determine relative position.

Finally, to associate the comment with a specific post, we store the relationship in the POST_COMMENTS table. This keeps the post-comment linkage cleanly separated from the comment hierarchy and maintains cohesion in the data model.

PostCommentService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class PostCommentServiceImpl implements PostCommentService {

    ...

    @Override
    @Transactional
    public PostCommentCreateResponse createPostComment(
            CommentCreate commentCreate,
            PostCommentCreate postCommentCreate) {
        ...

        PostComment postComment = postCommentRepository.save(PostComment.from(post, comment, clock.now()));

        ...
    }
}

Through this process, the comment creation API becomes much more than a simple persistence endpoint. It provides a scalable and maintainable foundation for managing deeply nested comment structures with high query performance, thanks to the closure table design.

2-3. Testing Comment Creation API

Let’s verify that the comment creation API behaves as expected. Using an HTTP client, we send actual requests and inspect both the response and resulting changes in the database.

We begin by inserting test data for users and posts, which are prerequisites for creating comments:

MySQL
insert into USERS (username)
values ('Alice'),
       ('Bob'),
       ('Charlie'),
       ('Dave'),
       ('Eve'),
       ('Frank'),
       ('Grace'),
       ('Heidi'),
       ('Igor'),
       ('Judy');


insert into POSTS (user_id, title, content, is_deleted)
values (1, 'My First Post', 'This is my first post on this platform', 0),
       (2, 'Getting Started', 'This post will guide you on how to get started with this platform', 0),
       (3, 'Tips and Tricks', 'This post shares some tips and tricks for new users', 0),
       (4, 'About Me', 'This post is all about me', 0),
       (5, 'My Journey', 'This post narrates my journey on this platform', 0);

Now, let’s post the first top-level comment. Since no parentId is specified, it will be treated as a root-level comment:

POST /posts/1/comments HTTP/1.1
Content-Type: application/json
Host: localhost:8080
Connection: close
User-Agent: RapidAPI/4.2.0 (Macintosh; OS X/14.4.1) GCDHTTPRequest
Content-Length: 52

{"userId":1,"content":"A"}

If the request is successful, the following records are added to the database:

console
# COMMENTS
+--+-------+-------+
|id|user_id|content|
+--+-------+-------+
|1 |1      |A      |
+--+-------+-------+

# COMMENT_CLOSURE
+--+-----------+-------------+-----+
|id|ancestor_id|descendant_id|depth|
+--+-----------+-------------+-----+
|1 |1          |1            |0    |
+--+-----------+-------------+-----+

# POST_COMMENTS
+--+-------+----------+
|id|post_id|comment_id|
+--+-------+----------+
|1 |1      |1         |
+--+-------+----------+

Next, we post a reply to comment A by specifying its parentId:

POST /posts/1/comments HTTP/1.1
Content-Type: application/json
Host: localhost:8080
Connection: close
User-Agent: RapidAPI/4.2.0 (Macintosh; OS X/14.4.1) GCDHTTPRequest
Content-Length: 65

{"userId":3,"parentId":1,"content":"B"}

The closure table now reflects the hierarchical relationship between comment B and its parent:

console
# COMMENTS
+--+-------+-------+
|id|user_id|content|
+--+-------+-------+
|1 |1      |A      |
|2 |3      |B      |
+--+-------+-------+

# COMMENT_CLOSURE
+--+-----------+-------------+-----+
|id|ancestor_id|descendant_id|depth|
+--+-----------+-------------+-----+
|1 |1          |1            |0    |
|2 |2          |2            |0    |
|3 |1          |2            |1    |
+--+-----------+-------------+-----+

# POST_COMMENTS
+--+-------+----------+
|id|post_id|comment_id|
+--+-------+----------+
|1 |1      |1         |
|2 |1      |2         |
+--+-------+----------+

Finally, we post another reply, this time to comment B, creating a third level in the hierarchy:

POST /posts/1/comments HTTP/1.1
Content-Type: application/json
Host: localhost:8085
Connection: close
User-Agent: RapidAPI/4.2.0 (Macintosh; OS X/14.4.1) GCDHTTPRequest
Content-Length: 65

{"userId":2,"parentId":2,"content":"C"}

The closure table is updated with all ancestor-descendant relationships for comment C:

console
# COMMENTS
+--+-------+-------+
|id|user_id|content|
+--+-------+-------+
|1 |1      |A      |
|2 |3      |B      |
|3 |2      |C      |
+--+-------+-------+

# COMMENT_CLOSURE
+--+-----------+-------------+-----+
|id|ancestor_id|descendant_id|depth|
+--+-----------+-------------+-----+
|1 |1          |1            |0    |
|2 |2          |2            |0    |
|3 |1          |2            |1    |
|4 |3          |3            |0    |
|5 |1          |3            |2    |
|6 |2          |3            |1    |
+--+-----------+-------------+-----+

# POST_COMMENTS
+--+-------+----------+
|id|post_id|comment_id|
+--+-------+----------+
|1 |1      |1         |
|2 |1      |2         |
|3 |1      |3         |
+--+-------+----------+

As shown, each time a new comment is added, the closure table automatically reflects all paths from the comment to each of its ancestors. The depth field indicates the hierarchical distance, enabling efficient sorting, tree rendering, and path-based queries.

This confirms that the API correctly builds and maintains the comment hierarchy using the closure table strategy.

2-4. Implementing Root Comment List API

Next, we implement an API to retrieve root-level comments for a specific post. This API returns only top-level comments (i.e., those without a parentId) along with a flag indicating whether each comment has any replies.

Rather than fetching all nested replies at once, the system is designed to support progressive loading—such as a “load more” UI pattern. This approach reduces the initial payload size and improves front-end rendering performance, especially for deeply nested trees. It also encourages a clear separation of concerns between frontend and backend by enabling independent retrieval of child comments.

We begin by defining a controller method that handles comment list requests. It receives the postId from the client and returns only the top-level comments for that post:

PostCommentController.java
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/posts/{postId}/comments")
public class PostCommentController {

    @GetMapping
    public ResponseEntity<?> postsCommentsApi(
            @PathVariable Long postId) {
        PostCommentsRequest request = PostCommentsRequest.from(postId);
        PostCommentsResponse response = service.retrievePostComments(request.toPostId());
        return ResponseEntity
                .ok(response);
    }
}

The service layer handles the business logic in the following sequence:

  1. Fetch top-level comments for the given post.
  2. Determine whether each comment has any replies.
  3. Count the total number of comments associated with the post.
  4. Package the data into a structured response object.
PostCommentServiceImpl.java
@Slf4j
@Service
@RequiredArgsConstructor
public class PostCommentServiceImpl implements PostCommentService {

    @Override
    @Transactional(readOnly = true)
    public PostCommentsResponse retrievePostComments(PostId postId) {
        List<PostCommentItem> comments = postCommentRepository.fetchBy(from(postId));
        List<Long> commentIds = transform(comments, PostCommentItem::getCommentId);
        Map<Long, Boolean> statuses = commentService.hasNestedComments(commentIds);
        long totalCount = postCommentRepository.countTotal(postId);

        return PostCommentsResponse.from(comments, statuses, totalCount);
    }

}

To extract only root comments, we join the COMMENT_CLOSURE table and filter out any comments that appear as descendants with a non-zero depth. This SQL query shows how that condition is expressed:

MySQL
select
    *
from
    COMMENTS comments
    inner join POST_COMMENTS post_comments
            on comments.id = post_comments.comment_id
    left join COMMENT_CLOSURE closures
            on comments.id = closures.descendant_id
            and closures.depth > 0
where
      post_comments.post_id = :postId
  and closures.id is null

Only comments that do not appear in the closure table with depth > 0 are considered root comments:

console
+--+-------+----------------+-------+----------+----+-----------+-------------+-----+----------+----------+
|id|user_id|content         |post_id|comment_id|id  |ancestor_id|descendant_id|depth|updated_at|created_at|
+--+-------+----------------+-------+----------+----+-----------+-------------+-----+----------+----------+
|1 |1      |A               |1      |1         |null|null       |null         |null |null      |null      |
+--+-------+----------------+-------+----------+----+-----------+-------------+-----+----------+----------+

This logic is implemented using Querydsl in the repository layer:

PostCommentJpaRepositoryImpl.java
@Slf4j
@RequiredArgsConstructor
public class PostCommentJpaRepositoryImpl implements PostCommentJpaRepositoryExtension {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<PostCommentItemResult> fetchBy(PostCommentPageableCond cond) {
        return queryFactory
                .select(Projections.constructor(
                        PostCommentItemResult.class,
                        postEntity.id.as("postId"),
                        userEntity.id.as("userId"),
                        userEntity.username.as("username"),
                        commentEntity.id.as("commentId"),
                        commentEntity.content.as("content"),
                        commentEntity.createdAt.as("createdAt"),
                        commentEntity.updatedAt.as("updatedAt")
                ))
                .from(postCommentEntity)
                .innerJoin(postCommentEntity.post, postEntity)
                .innerJoin(postCommentEntity.comment, commentEntity)
                .innerJoin(commentEntity.user, userEntity)
                .leftJoin(commentClosureEntity)
                .on(commentClosureEntity.descendant.eq(commentEntity).and(commentClosureEntity.depth.gt(0)))
                .where(
                        postEntity.id.eq(cond.getPostId()),
                        commentClosureEntity.id.isNull(),
                        postEntity.isDeleted.isFalse(),
                        commentEntity.isDeleted.isFalse()
                )
                .fetch();
    }
}

To determine whether each top-level comment has replies, we check how many descendant records exist for each comment in the closure table. If a comment appears more than once as an ancestor, it has children:

CommentClosureJpaRepositoryImpl.java
@Slf4j
@RequiredArgsConstructor
public class CommentClosureJpaRepositoryImpl implements CommentClosureJpaRepositoryExtension {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<NestedCommentStatResult> fetchNestedCommentStats(List<Long> commentIds) {
        QCommentEntity ancestor = new QCommentEntity("ancestor");
        QCommentEntity descendant = new QCommentEntity("descendant");
        CaseBuilder caseBuilder = new CaseBuilder();

        return queryFactory
                .select(Projections.constructor(
                        NestedCommentStatResult.class,
                        ancestor.id.as("id"),
                        caseBuilder
                                .when(ancestor.id.count().gt(1)).then(true)
                                .otherwise(false)
                                .as("nestedExists")
                ))
                .from(commentClosureEntity)
                .innerJoin(commentClosureEntity.ancestor, ancestor)
                .innerJoin(commentClosureEntity.descendant, descendant)
                .where(
                        ancestor.id.in(commentIds),
                        descendant.isDeleted.isFalse()
                )
                .groupBy(ancestor.id)
                .fetch();
    }

This result is transformed into a map for easy access by comment ID:

CommentServiceImpl.java
@Override
public Map<Long, Boolean> hasNestedComments(List<Long> commentIds) {
    List<CommentStat> stats = commentClosureRepository.retrieveCommentStatsBy(commentIds);
    return stats.stream()
            .collect(Collectors.toMap(CommentStat::getId, CommentStat::isNestedExists));
}

We also compute the total number of comments on the post, including nested ones:

PostCommentJpaRepositoryImpl.java
@Override
public long countTotal(PostId postId) {
    return queryFactory
            .select(postCommentEntity.id.count())
            .from(postCommentEntity)
            .innerJoin(postCommentEntity.post, postEntity)
            .innerJoin(postCommentEntity.comment, commentEntity)
            .where(
                    postEntity.id.eq(postId.getId()),
                    postEntity.isDeleted.isFalse(),
                    commentEntity.isDeleted.isFalse(),
                    postCommentEntity.isDeleted.isFalse()
            )
            .fetchFirst();
}

Finally, we package the result into a response object that includes the root-level comments, author information, reply existence flags, and total comment count:

PostCommentsResponse.java
@Slf4j
@Getter
@Builder
public class PostCommentsResponse {

    private final List<Comment> data;
    private final long totalCount;

    public static PostCommentsResponse from(
            List<PostCommentItem> comments,
            Map<Long, Boolean> statuses,
            long totalCount) {
        List<Comment> data = comments.stream()
                .map(element -> Comment.from(element, statuses.get(element.getCommentId())))
                .toList();

        return PostCommentsResponse.builder()
                .data(data)
                .totalCount(totalCount)
                .build();
    }

    @Getter
    @Builder
    private static class Comment {

        private final Long id;
        private final Author author;
        private final String content;
        private final LocalDateTime createdAt;
        private final LocalDateTime updatedAt;
        private final boolean hasNested;

        private static Comment from(PostCommentItem comment, boolean hasNested) {
            return Comment.builder()
                    .id(comment.getCommentId())
                    .author(Author.from(comment))
                    .content(comment.getContent())
                    .createdAt(comment.getCreatedAt())
                    .updatedAt(comment.getUpdatedAt())
                    .hasNested(hasNested)
                    .build();
        }
    }

    @Getter
    @Builder
    private static class Author {

        private final Long id;
        private final String username;

        private static Author from(PostCommentItem comment) {
            return Author.builder()
                    .id(comment.getUserId())
                    .username(comment.getUsername())
                    .build();
        }
    }
}

By designing the API around hierarchical structure awareness, we move beyond a simple flat list and provide structural insight into the comment tree. Decoupling root and nested comment retrieval enables a more responsive UI and allows for scalable data access patterns.

2-5. Testing Root Comment List API

With the root comment list API implemented, we now verify its behavior by sending actual requests and validating the response using an HTTP client.

Here is a sample GET request:

GET /posts/1/comments HTTP/1.1
Content-Type: application/json
Host: localhost:8080
Connection: close
User-Agent: RapidAPI/4.2.0 (Macintosh; OS X/14.4.1) GCDHTTPRequest

If the request is successful, the API returns a JSON response in the following format:

Response
{
  "data": [
    {
      "id": 1,
      "author": {
        "id": 1,
        "username": "Alice"
      },
      "content": "A",
      "createdAt": "2024-04-23T01:39:30",
      "updatedAt": "2024-04-23T01:39:30",
      "hasNested": true
    },
    {
      "id": 4,
      "author": {
        "id": 7,
        "username": "Grace"
      },
      "content": "D",
      "createdAt": "2024-04-23T01:42:10",
      "updatedAt": "2024-04-23T01:42:10",
      "hasNested": false
    },
    {
      "id": 5,
      "author": {
        "id": 5,
        "username": "Eve"
      },
      "content": "E",
      "createdAt": "2024-04-23T01:42:13",
      "updatedAt": "2024-04-23T01:42:13",
      "hasNested": false
    }
  ],
  "totalCount": 5
}

The hasNested field indicates whether each comment has replies. This allows the client to determine whether to display a “Load more” button or trigger a separate API call to fetch child comments.

Using the data[].id values, the client can issue follow-up requests to retrieve replies for each comment. This response format provides all the necessary context to render a hierarchical comment UI.

3. Conclusion

We’ve walked through the end-to-end process of implementing a comment feature for blog posts, using the closure table strategy to support hierarchical comment structures.

While the closure table introduces some complexity during the write phase, it significantly simplifies and optimizes read operations. This structure enables efficient querying of both ancestors and descendants, regardless of tree depth or shape—making it particularly well-suited for scalable comment systems.

Although this post covered only the root comment list API, the same closure table principles can be applied to implement an API for fetching child comments. By querying the closure table with ancestor_id = :parentId and depth > 0, we can retrieve all nested replies under a specific comment.

Despite its apparent simplicity, a comment system’s design can have major implications for scalability and maintainability. In many systems, optimizing for read performance at the cost of write complexity is a worthwhile trade-off—and mastering structural patterns like closure tables is key to making that design sustainable.

The code samples shown here highlight core concepts and selected components. For the full implementation, refer to the GitHub Repository.