Getting Started
Three concrete scenarios showing how [HasTags] fits different taxonomy styles.
Install
Section titled “Install”dotnet add package Pragmatic.TagsTypical companion packages (already present in most Pragmatic hosts):
dotnet add package Pragmatic.Persistence.EFCoredotnet add package Pragmatic.EndpointsScenario 1: free-form tagging
Section titled “Scenario 1: free-form tagging”Users tag their own articles with whatever labels they want.
using Pragmatic.Persistence.Entity;using Pragmatic.Tags;
[Entity][Resource("articles")][HasTags(AllowCustom = true, MaxPerEntity = 20)]public partial class Article{ public string Title { get; set; } = ""; public string AuthorId { get; set; } = "";}That’s it. After the next build, the following endpoints exist:
POST /articles/{id}/tags: add a tag (body: a bare JSON string, e.g."urgent")GET /articles/{id}/tags: list the tags on the article (paged)DELETE /articles/{id}/tags/{tagId}: remove
Each endpoint is gated on its own permission: {boundary}.article.tags.add, .read and .remove.
Usage:
curl -X POST /articles/42/tags \ -H 'Content-Type: application/json' \ -d '"Urgent"'
curl '/articles/42/tags?page=1&pageSize=20'# → { "items": [ { "tagId": "...", "articleId": "...", "value": "urgent",# "displayValue": "Urgent", "scope": "content",# "addedAt": "...", "addedBy": "..." } ],# "totalCount": 1, "page": 1, "pageSize": 20 }Because AllowCustom = true, any new value creates a Tag row on the fly. Because MaxPerEntity = 20, the 21st distinct tag on a single article is refused with a ForbiddenError (HTTP 403).
Scenario 2: curated taxonomy
Section titled “Scenario 2: curated taxonomy”Moderators maintain a closed set of tags. Users can attach existing tags but cannot create new ones.
[Entity][Resource("articles")][HasTags(AllowCustom = false, Scope = "editorial-content")]public partial class Article { ... }Seed the allowed tags at startup (or via an admin UI):
public sealed class SeedEditorialTagsStartup : IStartupStep{ public int Order => 900; public async Task OnStartupAsync(IServiceProvider services, CancellationToken ct) { var db = services.GetRequiredService<AppDbContext>(); if (!await db.Set<Tag>().AnyAsync(ct)) { db.Set<Tag>().AddRange( new Tag { Value = "breaking", DisplayValue = "Breaking", Scope = "editorial-content" }, new Tag { Value = "exclusive", DisplayValue = "Exclusive", Scope = "editorial-content" }, new Tag { Value = "opinion", DisplayValue = "Opinion", Scope = "editorial-content" }); await db.SaveChangesAsync(ct); } }}Now:
curl -X POST /articles/42/tags -d '"breaking"' # 201 Createdcurl -X POST /articles/42/tags -d '"nonsense"' # 404: not in the curated taxonomyCurated + policy validation
Section titled “Curated + policy validation”Combine AllowCustom = false with an ITagPolicy for richer validation:
public sealed class EditorialTagPolicy : ITagPolicy<Guid>{ public string Normalize(string tagValue) => tagValue.Trim().ToLowerInvariant();
// Returning false rejects the value: the add action answers 403. public Task<bool> IsAllowedAsync(string tagValue, CancellationToken ct = default) => Task.FromResult(Regex.IsMatch(tagValue, "^[a-z]+$"));
public Task OnTagAddedAsync(Guid entityId, Guid tagId, string tagValue, CancellationToken ct = default) => Task.CompletedTask;
public Task OnTagRemovedAsync(Guid entityId, Guid tagId, string tagValue, CancellationToken ct = default) => Task.CompletedTask;}
services.AddScoped<ITagPolicy<Guid>, EditorialTagPolicy>();Scenario 3: shared scope across multiple entities
Section titled “Scenario 3: shared scope across multiple entities”Multiple entity types share the same tag pool. Adding "launch" to an article and to a product reuses the same underlying Tag row.
[Entity] [HasTags(Scope = "marketing")] public partial class Article { ... }[Entity] [HasTags(Scope = "marketing")] public partial class Product { ... }[Entity] [HasTags(Scope = "marketing")] public partial class Campaign { ... }Analytics queries across all three types become trivial:
-- "What entities are tagged 'launch'?"SELECT Value, SUM(UsageCount) AS totalFROM TagsWHERE Scope = 'marketing' AND Value = 'launch'Or per-entity counts:
SELECT t.Value, COUNT(DISTINCT at.ParentEntityId) AS articles, COUNT(DISTINCT pt.ParentEntityId) AS productsFROM Tags tLEFT JOIN ArticleTags at ON at.TagId = t.IdLEFT JOIN ProductTags pt ON pt.TagId = t.IdWHERE t.Scope = 'marketing'GROUP BY t.ValueBeyond the three scenarios
Section titled “Beyond the three scenarios”Query the tag collection from code
Section titled “Query the tag collection from code”var article = await db.Set<Article>() .Include(a => a.Tags) // collection navigation .ThenInclude(at => at.Tag) // the shared Tag entity .FirstAsync(a => a.Id == id);
foreach (var at in article.Tags) Console.WriteLine(at.Tag.DisplayValue);Paged list via the generated query
Section titled “Paged list via the generated query”The generated List{Parent}TagsQuery runs over the junction and projects {Parent}TagDto
(TagId, {Parent}Id, Value, DisplayValue, Scope, AddedAt, AddedBy); this is exactly
what GET /articles/{id}/tags serves.
var query = new ListArticleTagsQuery { ArticleId = articleId, Page = 1, PageSize = 20 };
var page = await queryExecutor.ExecuteAsync<ArticleTagLink, ArticleTagDto>( query, db.Set<ArticleTagLink>(), ct);
foreach (var tag in page.Items) Console.WriteLine(tag.DisplayValue);Case sensitivity
Section titled “Case sensitivity”[HasTags(CaseSensitive = true)] // "CSS" and "css" are different tags[HasTags(CaseSensitive = false)] // default: they collapseFlip this on for technology tag sets (C# vs c#, iOS vs ios) where casing carries meaning.
Custom sub-boundary name
Section titled “Custom sub-boundary name”[HasTags(SubBoundary = "ArticleLabels")]By default the generated sub-boundary is {Parent}Tags. Override when the naming feels unnatural in your domain.
- Concepts: scope, normalisation, usage counter, comparison with Comments
- Common Mistakes
- Troubleshooting
Related:
Pragmatic.Comments: similar trait for rich user-authored content
