Skip to content

Concepts

Every entity that needs user comments requires the same boilerplate: a child entity with FK, EF configuration, CRUD actions, DTOs, endpoints, and permissions. This is 15-20 files of repetitive code per entity.

Pragmatic.Comments is a trait plugin: add [HasComments] to any entity and the source generator produces everything. No manual wiring, no base classes to configure.

[HasComments] on entity
↓ ForAttributeWithMetadataName (TraitFeature)
CommentTraitTransform.Transform()
↓ CommentTraitModel
├── CommentEntityTemplate → ReservationComment.g.cs
├── CommentEntityConfigTemplate → EntityConfig.ReservationComment.g.cs
├── ParentTraitNavigationTemplate → Reservation partial (ICollection<> Comments)
├── CommentActionsTemplate → Add/GetById/Update/Delete actions
├── CommentDtoTemplate → ReservationCommentDto with Projection
├── CommentListQueryTemplate → ListReservationCommentsQuery
├── CommentPermissionsTemplate → Permission constants
├── TraitEntityMetadataTemplate → Assembly metadata for host DbContext
└── TraitEndpointModelBuilder → EndpointModels → EndpointsFeature handlers

The SG cannot see its own output in the same Roslyn pass. So trait-generated actions and endpoints are injected programmatically into the existing pipelines:

  • ActionModelActionsFeature.Register(traitActions:) → Invoker + SetDependencies
  • QueryModelQueryFeature.Register(resourceQueries:) → Apply() + ToSpecification()
  • EndpointModelEndpointsFeature.Register(programmaticEndpoints:) → HTTP handlers

This means trait actions get the full pipeline: DI, UnitOfWork, validation, activity tracing — identical to hand-written [DomainAction] classes.

Each parent entity gets its own comment table — no shared polymorphic table:

Reservations ReservationComments
┌──────────┐ ┌─────────────────────┐
│ Id (PK) │◄────────│ ReservationId (FK) │
│ ... │ │ PersistenceId (PK) │
└──────────┘ │ Content │
│ AuthorId │
│ ReplyToId (self-FK) │
│ Status (varchar) │
│ IsDeleted │
└─────────────────────┘

Benefits: full referential integrity, cascade delete, no EntityType/EntityId polymorphism, independent indexes per table.

When AllowReplies = true (default), comments support nested threading via ReplyToId self-referencing FK:

Comment A (ReplyToId = null) ← top-level
├── Comment B (ReplyToId = A.Id) ← reply to A
│ └── Comment C (ReplyToId = B.Id) ← reply to B
└── Comment D (ReplyToId = A.Id) ← another reply to A

The self-FK uses DeleteBehavior.Restrict to prevent orphaned replies.

When RequireApproval = true:

  • New comments start with Status = PendingApproval
  • A Moderate{Entity}CommentAction is generated
  • The query filter excludes Rejected comments
  • Permission: {boundary}.{entity}.comments.moderate
Add Comment:
1. Resolve ICommentPolicy<TId> from DI (optional)
2. policy.CanAddAsync() → reject if false
3. Create entity, set fields from ICurrentUser + IClock
4. _db.Set<T>().Add(comment)
5. policy.OnAddedAsync() → notification hook
Update Comment:
1. policy.CanEditAsync() → reject if false
2. Fallback: author-only check if no policy
3. Edit window enforcement if configured
4. Set Content, IsEdited, UpdatedAt, UpdatedBy
Delete Comment:
1. policy.CanDeleteAsync() → reject if false
2. Soft-delete: IsDeleted, DeletedAt, DeletedBy

Comment actions are grouped in a sub-boundary interface:

public interface IBookingActions
{
IBookingReservationCommentsActions ReservationComments { get; }
// ...
}
public interface IBookingReservationCommentsActions
{
Task<Result<Guid, IError>> AddReservationComment(...);
Task<VoidResult<IError>> UpdateReservationComment(...);
Task<VoidResult<IError>> DeleteReservationComment(...);
// ...
}

Customizable via [HasComments(SubBoundary = "CustomName")].

The SG generates assembly metadata (_Metadata.TraitEntities.g.cs) that the host reads at compile time. The host’s DbContextFeature:

  1. Reads trait entity metadata from referenced assemblies
  2. Adds DbSet<ReservationComment> to BoundaryDbContext + MigrationDbContext
  3. Applies the ReservationCommentEntityConfig (generated in module, public)

Schema metadata also includes the trait entity with resolved properties for Pragmatic.Migrations table creation.