Design a Comment Service API
Threaded comments, pagination by cursor, moderation hooks, the reaction subsystem. The CRUD that scales to millions of nodes.
Context#
A comment service is the most-deployed piece of social infrastructure on the internet. News articles have it; product pages have it; pull requests, design docs, podcasts, photo albums — every platform that lets users say something about a thing eventually owns one. Disqus built an entire company around being it as a service. Reddit’s threading model is the gold standard for what a popular comment tree looks like at scale. GitHub’s PR review comments are a comment service with an attached state machine.
The temptation in an interview is to dismiss this as “just CRUD” — four endpoints, a database table, done. That misses the actual difficulty:
- Threading: replies-to-replies create an unbounded tree. How do you paginate one?
- Reads vs writes: comment systems are 100:1 read-heavy. The write path can be slow; the read path cannot.
- Moderation: every public surface needs a flag → review → action loop, often async.
- Reactions: thumbs-up / heart / laugh — a sub-entity with its own lifecycle and aggregate counts.
- Edits and deletes: do you tombstone, hard-delete, or soft-delete? The choice has legal and UX implications.
The interviewer’s hidden objectives:
- Can you model threading without an unbounded recursive query?
- Do you pick cursor pagination correctly given live writes?
- Can you describe the moderation pipeline as a state machine and an async queue, not a synchronous block?
- Can you size the reaction aggregate so it doesn’t hot-spot on viral comments?
- Can you defend an edit-history model and an author-vs-mod delete distinction?
Reference: Reddit, Disqus, Hacker News, GitHub PR reviews.
Requirements (functional and non-functional)#
Functional — in scope:
- Create a top-level comment on a thread (e.g. an article, a video, a PR).
- Reply to an existing comment (creates a child).
- Edit a comment within an edit window.
- Delete a comment (author tombstone or mod-action hide).
- Add or remove a reaction (thumbs-up, heart, etc.) on a comment.
- List comments on a thread, paginated; reply-trees lazily expanded.
- Flag a comment for moderation review.
Functional — out of scope:
- Identity / auth — assume an upstream identity service issues JWTs.
- Notification delivery — pushed to a separate service via async event.
- Rich-text rendering / sanitisation — handled by a content pipeline upstream of write.
- Search inside comments — covered by the search service via async indexing.
Non-functional:
- Latency: read list
<= 200 ms p95, write<= 300 ms p95. - Throughput: 50k reads/s sustained per thread (viral hotspot), 5k writes/s aggregate.
- Availability: 99.95% on the read path; 99.9% on the write path.
- Consistency: writes are read-your-writes for the author; eventually consistent for other viewers within 1 s.
- Storage: bounded by retention policy; comments are kept indefinitely by default.
Use case diagram#
┌──────────────┐ ┌──────────────┐ │ Commenter │ │ Moderator │ └──────┬───────┘ └──────┬───────┘ │ │ ┌───────────────┼───────────────┐ ┌───────┴────────┐ ▼ ▼ ▼ ▼ ▼[post reply] [react] [edit/delete] [review flag] [hide/restore] │ │ │ │ │ └───────────────┴───────┬───────┴───────┴────────────────┘ ▼ ┌────────────────────────────┐ │ Comment Service API │ └────────────┬───────────────┘ │ ┌──────────┴──────────┐ ▼ ▼ [Viewer] read tree [Async] events outThree actors: commenter, moderator, viewer. The viewer is read-only and the dominant load.
Class diagram#
┌───────────────────────┐ │ CommentService │ ├───────────────────────┤ │ create(req): Comment │ │ reply(req): Comment │ │ edit(id, body): Comment│ │ delete(id, by): void │ │ list(threadId, cur) │ │ react(id, type) │ │ flag(id, reason) │ └──────────┬────────────┘ │ owns ▼ ┌───────────────────────┐ ┌─────────────────────┐ │ Thread │ 1 ─── * │ Comment │ ├───────────────────────┤ ├─────────────────────┤ │ id │ │ id │ │ subject_type │ │ thread_id │ │ subject_id │ │ parent_id? │ │ comment_count │ │ depth (0..N) │ │ created_at │ │ author_id │ └───────────────────────┘ │ body │ │ status │ │ created_at │ │ edited_at? │ └────┬────────────┬───┘ │ │ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ │ Reaction │ │ Moderation │ ├─────────────────┤ ├─────────────────┤ │ comment_id │ │ comment_id │ │ user_id │ │ flag_count │ │ type (👍/❤/😂) │ │ reviewed_by? │ │ created_at │ │ action │ └─────────────────┘ └─────────────────┘Thread is the comment-container — a polymorphic anchor referenced via (subject_type, subject_id). Comment.parent_id is the threading link. Reaction is a separate row per (comment, user, type) triple; aggregate counts live denormalised on Comment for read speed. Moderation tracks flagging state separately so a flag action doesn’t lock the parent row.
Sequence diagram (key flows)#
The post-reply flow — the canonical write path:
Client CommentAPI DB EventBus Cache │ POST /threads/{t}/comments │ │ │ │──────────────────►│ │ │ │ │ │ validate │ │ │ │ │ check rate│ │ │ │ │ insert │ │ │ │ │──────────►│ │ │ │ │ comment │ │ │ │ │◄──────────│ │ │ │ │ emit event│ │ │ │ │─────────────────────────►│ │ │ │ invalidate thread cache │ │ │ │ ──────────────────────────────────────►│ │ 201 + Comment │ │ │ │◄──────────────────│ │ │ ▼ [moderation, notifications, search-index]The list flow — the dominant read path:
Client CommentAPI Cache DB │ GET /threads/{t}/comments?cursor=... │──────────────────►│ │ │ │ │ cache key │ │ │ │─────────────►│ │ │ │ hit? │ │ │ │◄─────────────│ │ │ │ miss │ │ │ │ query top-N │ │ │ │ where depth=0│ │ │ │─────────────────────────────►│ │ │ top comments + reply_count │ │ │◄─────────────────────────────│ │ │ set cache │ │ │ │─────────────►│ │ │ 200 + page │ │ │ │◄──────────────────│ │ │The moderation flow — async, decoupled from the write:
Flagger CommentAPI ModQueue Moderator │ POST /comments/{c}/flag │ │ │ │─────────────────►│ │ │ │ │ │ insert flag │ │ │ │ enqueue if threshold ►│ │ │ 202 Accepted │ │ │ │ │◄─────────────────│ │ │ │ │ │ │ │ (later) GET │ │ │ /moderation/queue──────────►│ │ │ │ │ POST /moderation/{c}/action│ │ ◄─────────────────────────│ │ │ apply action ◄────────│ │ │ │ Comment.status = Hidden │Activity diagram (for non-trivial state)#
Comment.status is the meaningful state machine:
[author: POST] │ ▼ ┌──────────┐ │ Active │◄────┐ └────┬─────┘ │ un-hide │ │ (mod restore) ┌──────────┼───────────┘ │ │ edit │ │ author delete ▼ ▼ ┌─────────┐ ┌─────────┐ │ Edited │ │ Deleted │ (tombstone — body cleared, │ │ │ │ structure preserved for thread) └────┬────┘ └─────────┘ │ │ flag threshold → mod review → hide ▼ ┌─────────┐ │ Hidden │ │ (mod) │ └─────────┘The tombstone matters: deleting a comment that has children must preserve the parent slot so the tree doesn’t collapse, mirroring Reddit’s “[deleted]” placeholder. The body is cleared; the Comment row stays.
API implementation#
Endpoint catalogue#
| Method | Path | Purpose |
|---|---|---|
POST | /v1/threads/{t}/comments | Create top-level comment |
POST | /v1/comments/{id}/replies | Reply to a comment |
PATCH | /v1/comments/{id} | Edit body |
DELETE | /v1/comments/{id} | Tombstone (author) or hide (mod) |
GET | /v1/threads/{t}/comments | List top-level comments, cursor-paginated |
GET | /v1/comments/{id}/replies | Expand a subtree, cursor-paginated |
POST | /v1/comments/{id}/reactions | Add reaction |
DELETE | /v1/comments/{id}/reactions/{type} | Remove reaction |
POST | /v1/comments/{id}/flag | Flag for moderation |
GET | /v1/moderation/queue | (Mod-only) review queue |
POST | /v1/moderation/{id}/action | (Mod-only) hide / restore / dismiss |
Two pagination cursors are in play: one over top-level comments per thread, one over replies per parent. They share format (opaque base64 of (score, id) or (created_at, id)).
Threading shape#
We use the adjacency list model: each row carries parent_id. Depth is materialised on insert. To bound query cost, the list endpoint returns top-level comments with reply_count and the first ~3 replies inline; deeper subtrees are fetched on demand via GET /v1/comments/{id}/replies. The hard limit on depth is 7 — past that the tree is visually unreadable and the UI flattens further nesting.
OpenAPI schema (excerpt)#
paths: /v1/threads/{threadId}/comments: post: operationId: createComment parameters: - { name: threadId, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [body] properties: body: { type: string, minLength: 1, maxLength: 10000 } responses: '201': description: Created content: application/json: schema: $ref: '#/components/schemas/Comment' '400': { description: Invalid body } '429': { description: Too many writes } get: operationId: listComments parameters: - { name: threadId, in: path, required: true, schema: { type: string } } - name: cursor in: query schema: { type: string } - name: page_size in: query schema: { type: integer, minimum: 1, maximum: 100, default: 25 } - name: sort in: query schema: type: string enum: [top, new, old] default: top responses: '200': description: Page of comments content: application/json: schema: type: object required: [items] properties: items: type: array items: { $ref: '#/components/schemas/Comment' } next_cursor: { type: string, nullable: true } /v1/comments/{id}/reactions: post: operationId: addReaction parameters: - { name: id, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [type] properties: type: type: string enum: [thumbsup, heart, laugh, sad, angry] responses: '200': { description: Reaction added (idempotent) }components: schemas: Comment: type: object required: [id, thread_id, author_id, body, status, created_at, depth] properties: id: { type: string } thread_id: { type: string } parent_id: { type: string, nullable: true } author_id: { type: string } body: { type: string } status: type: string enum: [Active, Edited, Deleted, Hidden] depth: { type: integer } created_at: { type: string, format: date-time } edited_at: { type: string, format: date-time, nullable: true } reply_count: { type: integer } reactions: type: object additionalProperties: { type: integer } first_replies: type: array items: { $ref: '#/components/schemas/Comment' }Cursor payload#
The cursor encodes (sort_key, id) so that pagination is stable under concurrent writes. For sort=new, the cursor is base64({"t": "2026-05-30T12:00:00Z", "id": "abc"}). The server returns rows strictly before this tuple in the sort order.
Idempotency#
Reaction add/remove is idempotent by (user_id, comment_id, type) — adding the same reaction twice is a no-op. Comment create accepts an optional Idempotency-Key header; identical retries within 24 h return the original Comment and a 201 with Idempotent-Replayed: true header. See the idempotency-in-api-design building-block writeup for the full rationale.
Client samples — three languages#
The reply-with-edit flow shown in three languages.
import requests, uuid
API = "https://api.example.com"TOKEN = "Bearer eyJhbGciOi..."
def post_reply(parent_id, body): return requests.post( f"{API}/v1/comments/{parent_id}/replies", json={"body": body}, headers={ "Authorization": TOKEN, "Idempotency-Key": str(uuid.uuid4()), }, timeout=2, ).json()
def edit(comment_id, body): return requests.patch( f"{API}/v1/comments/{comment_id}", json={"body": body}, headers={"Authorization": TOKEN}, timeout=2, ).json()
def react(comment_id, kind): requests.post( f"{API}/v1/comments/{comment_id}/reactions", json={"type": kind}, headers={"Authorization": TOKEN}, ).raise_for_status()
c = post_reply("abc123", "Solid analysis. The pagination angle is underrated.")edit(c["id"], c["body"] + " (edit: typo)")react(c["id"], "thumbsup")package main
import ( "bytes" "encoding/json" "fmt" "io" "net/http"
"github.com/google/uuid")
const API = "https://api.example.com"const TOKEN = "Bearer eyJhbGciOi..."
type Comment struct { ID string `json:"id"` Body string `json:"body"`}
func postReply(parentID, body string) (*Comment, error) { payload, _ := json.Marshal(map[string]string{"body": body}) req, _ := http.NewRequest("POST", fmt.Sprintf("%s/v1/comments/%s/replies", API, parentID), bytes.NewReader(payload)) req.Header.Set("Authorization", TOKEN) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", uuid.NewString())
resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close()
var c Comment if err := json.NewDecoder(resp.Body).Decode(&c); err != nil { return nil, err } return &c, nil}
func react(commentID, kind string) error { payload, _ := json.Marshal(map[string]string{"type": kind}) req, _ := http.NewRequest("POST", fmt.Sprintf("%s/v1/comments/%s/reactions", API, commentID), bytes.NewReader(payload)) req.Header.Set("Authorization", TOKEN) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return err } io.Copy(io.Discard, resp.Body) return resp.Body.Close()}
func main() { c, _ := postReply("abc123", "Solid analysis.") react(c.ID, "thumbsup")}import { randomUUID } from "node:crypto";
const API = "https://api.example.com";const TOKEN = "Bearer eyJhbGciOi...";
async function postReply(parentId, body) { const r = await fetch(`${API}/v1/comments/${parentId}/replies`, { method: "POST", headers: { Authorization: TOKEN, "Content-Type": "application/json", "Idempotency-Key": randomUUID(), }, body: JSON.stringify({ body }), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json();}
async function edit(id, body) { const r = await fetch(`${API}/v1/comments/${id}`, { method: "PATCH", headers: { Authorization: TOKEN, "Content-Type": "application/json" }, body: JSON.stringify({ body }), }); return r.json();}
async function react(id, type) { await fetch(`${API}/v1/comments/${id}/reactions`, { method: "POST", headers: { Authorization: TOKEN, "Content-Type": "application/json" }, body: JSON.stringify({ type }), });}
const c = await postReply("abc123", "Solid analysis.");await edit(c.id, c.body + " (edit: typo)");await react(c.id, "thumbsup");Latency budget#
The 200 ms p95 read budget breaks down as:
| Phase | Budget | Notes |
|---|---|---|
| Gateway (rate limit, auth) | 5 ms | JWT cached |
| Cache lookup | 2 ms | Redis hit on hot threads (>90%) |
| DB query on miss | 100 ms p95 | Index on (thread_id, parent_id, sort_key) |
| Reaction aggregation | included | Denormalised on Comment |
| Serialize + transport | 20 ms | JSON, ~30 KB |
| Margin for tail | 70 ms | Slow shard / GC pause |
| Total | ~200 ms | At or under the budget |
The write path is allowed 300 ms — the extra 100 ms is for the synchronous fanout (cache invalidate + event emit + denormalised parent update).
Trade-offs and extensions#
| Decision | Why | Cost if requirements change |
|---|---|---|
| Adjacency list, not nested set | Insert is O(1); read needs care | Recursive subtree reads are multi-query |
| Tombstone on delete | Preserves tree structure | Body is irrecoverable; legal requests are expensive |
| Reaction counts denormalised | Reads stay O(1) | Write path is heavier; eventual consistency on count |
Cursor by (sort_key, id) | Stable under writes | Can’t jump to “page 47” |
| Async moderation pipeline | Doesn’t block writes | Bad content visible briefly |
| Top-3 replies inline, rest on demand | Two-phase load is acceptable UX | Increases endpoint count by one |
| Depth cap at 7 | Bounds query / UX complexity | Some platforms (Reddit) go unbounded with collapse |
A clean contrast:
Threaded view (Reddit-style)
- Tree expand/collapse
- Per-subtree pagination
- Two query patterns: top-level + subtree
- Reaction tally per node
- Higher cognitive load
Flat chronological (Hacker News-style)
- Single sorted list
- Single cursor
- One query pattern
- “In reply to” prefix only
- Easier for high-velocity feeds
Likely follow-up extensions:
- Mentions and notifications. Parse
@usernamein body server-side; emit amentionevent. Notification delivery is out of scope; the event bus picks it up. - Rich-text and attachments. Body becomes a JSON document (matching the rich-content building block); attachments flow through the file service via signed URLs.
- Per-thread moderation policy. Some threads are pre-moderated (queue before publish), others post-moderated. Add a
moderation_modefield onThread. - Audit log. Every state transition writes to an immutable log for legal compliance.
Mock interview follow-ups#
- “How do you handle 100k comments on a viral post?” — Cursor pagination + denormalised counts + edge cache on the top-level list. Subtree expansion is rare; pay for it on demand.
- “What happens if two users add the same reaction at the same time?” — Idempotent by
(user, comment, type); the underlyingINSERT ... ON CONFLICT DO NOTHINGis the canonical pattern. - “Why not nested set / closure table?” — Insert cost. Closure tables make insert O(depth); on a 5k QPS write path that’s prohibitive. Adjacency list keeps insert at O(1), reads stay tractable with the right index.
- “What’s the consistency model?” — Read-your-writes for the author via session-pinning to the primary DB. Other viewers see eventual consistency within 1 s as caches invalidate and replicas catch up.
- “How do edits affect notifications?” — They don’t, in v1. Mentions added in an edit fire a new event; mentions removed are not retracted. This matches Slack and Discord behaviour.
- “How do you protect against spam?” — Per-user rate limit at the gateway + bayesian-classifier check before persistence + moderation flag aggregation; community downvote also surfaces to the queue.
- “What if a comment violates GDPR / right-to-be-forgotten?” — Hard delete with a special admin endpoint, audit-logged. Replaces the body with a tombstone and drops the row after retention.
Related#
- Design a Search Service API — comments are indexed asynchronously into the search service.
- Design a File Service API — attachments on comments flow through the file service.
- Design a Pub-Sub Service API — the event bus that carries comment-create / mention events.
- Design the Twitter API — built on the same threaded-reply primitive at far larger scale.
- The API-Design Walk-through — the seven-step recipe this writeup followed.