Pragmatic.Tags
Add normalized many-to-many tags to any Pragmatic entity with a single attribute.
Pragmatic.Tags is a trait package that generates the shared tag entity, junction entity, actions, and endpoints in the consuming boundary.
The Problem
Section titled “The Problem”Tagging looks simple until you try to keep it consistent:
- normalize values
- avoid duplicates
- decide whether tags are curated or free-form
- model the many-to-many relationship
- expose add/remove/list endpoints
- keep usage counts and scope rules coherent
Without a shared implementation, every project solves these details differently.
The Solution
Section titled “The Solution”One attribute activates the tagging pipeline:
using Pragmatic.Tags;using Pragmatic.Persistence.Entity;
[Entity][Resource("articles")][HasTags]public partial class Article : IEntity{ public string Title { get; set; } = "";}The generator creates:
- a shared concrete
{Boundary}Tagentity derived fromTagBase - a
{Parent}TagLinkjunction entity derived fromEntityTagBase<TEntityId> Add{Parent}TagActionandRemove{Parent}TagAction- a read side:
{Parent}TagDtoand the pagedList{Parent}TagsQuery POST,GETandDELETEendpoints under the parent resource- a
Tagsnavigation on the parent entity
Installation
Section titled “Installation”<PackageReference Include="Pragmatic.Tags" />Typical consumers also reference:
Pragmatic.PersistencePragmatic.Endpointsif HTTP endpoints are desired
Quick Start
Section titled “Quick Start”[Entity][Resource("articles")][HasTags(AllowCustom = true, Scope = "content", MaxPerEntity = 25)]public partial class Article : IEntity{ public string Title { get; set; } = "";}Generated workflows, under /api/{boundary}/articles/{articleId}/tags ({boundary} is the lowercased
boundary name, or v1 when the entity has no [BelongsTo<TBoundary>]):
POST …/tags: add a tag to an entityGET …/tags: list the tags on an entity (paged)DELETE …/tags/{tagId}: remove a tag from an entity
Each route is gated on a generated permission constant: ArticleTagPermissions.Add, .Remove, .Read
= {boundary}.article.tags.{add|remove|read}, boundary and entity in kebab-case, so a caller without
it receives 403. Without a boundary the first segment is omitted (article.tags.add), unlike the
other traits, which use app: tags predate that fallback and existing grants depend on the shape.
Attribute Options
Section titled “Attribute Options”| Option | Default | Description |
|---|---|---|
MaxPerEntity |
50 |
Maximum number of tags on a single entity instance. 0 means unlimited. |
AllowCustom |
true |
Whether new tags can be created on the fly. When false, the generated add action enforces a curated taxonomy: a value that does not already exist in the tag’s scope is rejected with a NotFoundError instead of being minted. |
CaseSensitive |
false |
Whether Urgent and urgent are distinct tags. |
Scope |
parent entity type name | Namespace for tag isolation or sharing. |
SubBoundary |
{Parent}Tags |
Override generated sub-boundary name. |
Tag Models
Section titled “Tag Models”TagBase provides the shared tag metadata:
ValueDisplayValueScopeUsageCountCreatedAt,CreatedBy
EntityTagBase<TEntityId> provides the junction record:
ParentEntityIdTagIdAddedAtAddedBy
Custom Policy Hook
Section titled “Custom Policy Hook”Register an ITagPolicy<TEntityId> implementation to enforce a curated taxonomy and
react to tag lifecycle events. The generated add/remove actions resolve the policy
from the DbContext’s service provider and call these hooks automatically when one
is registered (no policy registered → default behavior). All four members have
default implementations, so override only what you need:
| Member | When the generated action calls it |
|---|---|
Task<bool> IsAllowedAsync(string tagValue, CancellationToken) |
On add, before anything else: return false to reject the value (ForbiddenError). Default allows any non-blank value. |
string Normalize(string tagValue) |
On add, to normalize the value before storage/matching (overrides the built-in trim/lowercase, honoring CaseSensitive only in the default path). |
Task OnTagAddedAsync(TEntityId entityId, Guid tagId, string tagValue, CancellationToken) |
On add, after the junction is created (audit, projections, notifications). |
Task OnTagRemovedAsync(TEntityId entityId, Guid tagId, string tagValue, CancellationToken) |
On remove, after the junction is removed. |
public sealed class ArticleTagPolicy : ITagPolicy<Guid>{ private static readonly HashSet<string> Curated = new(StringComparer.OrdinalIgnoreCase) { "news", "opinion", "review" };
public Task<bool> IsAllowedAsync(string tagValue, CancellationToken ct = default) => Task.FromResult(Curated.Contains(tagValue));
public Task OnTagAddedAsync(Guid entityId, Guid tagId, string tagValue, CancellationToken ct = default) { // e.g. refresh a read model or emit a domain event return Task.CompletedTask; }}
// Registrationservices.AddScoped<ITagPolicy<Guid>, ArticleTagPolicy>();Status
Section titled “Status”Functional within 1.0.0-alpha: the [HasTags] trait with its entity, actions and endpoints, tested
end to end in the Showcase. See the roadmap.
Documentation
Section titled “Documentation”Local docs:
Related modules:
Requirements
Section titled “Requirements”- .NET 10.0+
Pragmatic.Persistence.EFCoreandPragmatic.Actions(endpoints also needPragmatic.Endpoints)Pragmatic.SourceGeneratoranalyzer
License
Section titled “License”Part of the Pragmatic.Design ecosystem. See Licensing. Pragmatic.Tags is licensed under the PolyForm Small Business 1.0.0 license (free for small businesses; commercial license above the threshold).
