Four Application handlers coordinate the comment lifecycle; there is no comment domain aggregate. Rules are split across Create, Update, Delete, GetComments, and CommentStore, so a review must cover handler code, database indexes, and soft-delete query behavior together.
1. Two identifiers with different jobs
| Identifier | Producer | Purpose |
|---|---|---|
Id | persistence entity/framework | Update and Delete route parameter |
CommentId | client or legacy Ticket data | idempotency key and ParentCommentId reference |
Do not send CommentId as /api/comments/{id}. An SDK that retains only CommentId cannot invoke the current update/delete routes. CommentThreadNode returns both identifiers for this reason.
2. Creation state machine
Create validates five required inputs and the body limit. It does not validate the persistence limits for EntityType, EntityId, CommentId, and ParentCommentId. An overlong identifier therefore fails at the provider boundary and may produce provider-specific error semantics.
Body length uses .NET string.Length: UTF-16 code units, not grapheme clusters, UTF-8 bytes, or rendered characters. A UI counter must use the same contract or the API must define a different explicit character rule.
3. Exact idempotency scope
The current key is:
TenantId + EntityType + EntityId + CommentIdThe same CommentId may identify a comment on another EntityId. Repeating a key within one subject returns the first row; a new Body or ParentCommentId is silently ignored. This is result replay, not an idempotency-conflict check.
var command = new CreateComment.Command( CommentId: operationId, EntityType: "Ticket", EntityId: ticketId, ParentCommentId: parentId, Body: normalizedBody);
// A retry must reuse the complete payload. The current service does not compare// the stored Body/ParentCommentId with a later request that reuses operationId.var first = await mediator.Send(command, cancellationToken);var replay = await mediator.Send(command, cancellationToken);
// Both successes should identify one persistence row, not two nodes.first.Value!.Id.ShouldBe(replay.Value!.Id);Two concurrent first requests can both miss before Insert. The unique index prevents two committed rows, but the handler does not catch the unique violation and reload the winner. A production implementation should normalize that race to success and compare a request fingerprint so the same key cannot silently accept different content.
4. Parent-comment rules
The parent lookup includes TenantId, EntityType, EntityId, and ParentCommentId, which keeps a normal reply in the same subject. When a parent is soft-deleted, GetByCommentId is normally filtered by soft deletion and returns no row. The code therefore reaches Comment.ParentNotFound; Comment.ParentDeleted is only reachable if a store returns a deleted row, which the current implementation does not.
The source exposes both errors, but they do not have equal reachability. There is also no maximum reply depth, per-node reply count, or thread total. Clients should not render unbounded recursion; a production API needs explicit paging and depth contracts.
5. Edit rules
Update loads an undeleted row by persistence Id and EffectiveTenantId. It then allows either:
- AuthorId equal to the current UserId string; or
- a role equal to
admin, case-insensitively.
For a non-admin, UtcNow - CreateTime > 30 minutes is rejected, so exactly 30 minutes remains allowed. Admin bypasses the window. A successful update trims Body and stores EditedAt.
using var response = await api.PutAsJsonAsync( $"/api/comments/{comment.Id}", new { body = editedBody }, cancellationToken);
// Forbidden can mean non-author or expired window; neither is safe to retry.if (response.StatusCode == HttpStatusCode.Forbidden) throw new CommentEditRejectedException( await response.Content.ReadAsStringAsync(cancellationToken));
// NotFound also covers another effective tenant and a soft-deleted record.response.EnsureSuccessStatusCode();The role-name bypass is outside the Authorization permission catalog. A safer design uses a comments.comment.moderate capability or a subject-aware policy and audits the operator, reason, subject, and original author.
6. Deletion and structural placeholders
Delete has no 30-minute limit. An author or admin can invoke it; the store sets IsDeleted=true and Body=null while retaining identifiers, parent linkage, author, and timestamps.
A repeated Delete calls the normal GetById, so soft-delete filtering turns it into NotFound rather than idempotent success. No DeletedAt, DeletedBy, or Reason is stored; the current table cannot establish who deleted content or when.
7. How the tree is built
GetComments loads every row for the subject, including deleted rows, then:
- creates a dictionary keyed by CommentId;
- creates a lookup keyed by ParentCommentId;
- treats null-parent and missing-parent rows as roots;
- recursively builds Replies;
- orders each level by CreateTime.
Equal CreateTime values have no secondary key, so ordering can vary by provider. There is no visited set, and bad data containing a cycle recurses without termination. Reading all rows also lets one hot subject amplify memory and response size.
CommentThreadNode Build(CommentRecord current, HashSet<string> path){ // Track the current recursion path; re-entry proves that imported data cycles. if (!path.Add(current.CommentId)) throw new InvalidOperationException("comment cycle detected");
var replies = children[current.CommentId] .OrderBy(x => x.CreateTime) .ThenBy(x => x.CommentId, StringComparer.Ordinal) .Select(child => Build(child, new HashSet<string>(path))) .ToList();
// Preserve the structural node but never project a deleted body. return ToNode(current, replies);}At scale, prefer cursor-paged roots, a bounded number of replies per root, and an endpoint that pages children by ParentCommentId.
8. Output safety and privacy
The service trims strings; it does not sanitize Markdown or HTML. JSON serialization usually encodes transport safely, but a UI that renders raw HTML/Markdown must use an allowlist sanitizer and reject scripts, event attributes, and dangerous URL schemes.
Clearing Body reduces exposure in the primary table, but database logs, backups, audit records, search indexes, and notification copies may still retain it. Comments has no cross-system erasure orchestration, so Body=null is not evidence of complete erasure.
9. Error-to-client behavior
| Error | Condition | Client action |
|---|---|---|
Comment.InvalidInput | required create field is absent | correct input |
Comment.BodyTooLong | Create/Update exceeds 10,000 | shorten body |
Comment.EmptyBody | Update body is empty | correct input |
Comment.ParentNotFound | parent lookup returns no row | refresh thread |
Comment.ParentDeleted | store returns a deleted parent | normally unreachable with current store |
Comment.NotAuthor | neither author nor admin | do not retry |
Comment.EditWindowExpired | non-admin is later than 30 minutes | add a follow-up or use moderation workflow |
Comment.NotFound | id missing, another tenant, or deleted | refresh/hide action |
10. Required test cases
- same key/same payload, same key/different payload, and concurrent first requests;
- replaying CommentId after soft deletion;
- active, deleted, cross-subject, and cross-tenant parents;
- exactly 30 minutes, future CreateTime, and admin casing;
- client callers, null UserId, and AuthorId=
0; - Unicode at the 10,000-code-unit boundary;
- deep chains, cycles, orphans, duplicate CommentId, and equal CreateTime;
- proving Body is absent from ordinary queries, include-deleted projections, and responses after deletion.