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<Guid>][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:{ "value": "urgent" })DELETE /articles/{id}/tags/{value}— removeGET /articles/{id}/tags— list
Usage:
curl -X POST /articles/42/tags \ -H 'Content-Type: application/json' \ -d '{"value": "urgent"}'
curl /articles/42/tags# → [ { "value": "urgent", "displayValue": "urgent", "addedAt": "..." } ]Because AllowCustom = true, any new value creates a Tag row on the fly. Because MaxPerEntity = 20, the 21st tag on a single article returns LimitReachedError.
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<Guid>][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 '{"value": "breaking"}' # 200 OKcurl -X POST /articles/42/tags -d '{"value": "nonsense"}' # 400 — not in 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 raw) => raw.Trim().ToLowerInvariant();
public Result<Unit, IError> Validate(string value, Guid entityId) { if (!Regex.IsMatch(value, "^[a-z]+$")) return Result.Failure(new ValidationError("Tags must be lowercase letters only")); return Result.Success(Unit.Value); }
public ValueTask OnAddedAsync(Guid entityId, string value, CancellationToken ct) => default; public ValueTask OnRemovedAsync(Guid entityId, string value, CancellationToken ct) => default;}
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<Guid>] [HasTags(Scope = "marketing")] public partial class Article { ... }[Entity<Guid>] [HasTags(Scope = "marketing")] public partial class Product { ... }[Entity<Guid>] [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”var page = await queryExecutor.ExecuteAsync( new ListArticleTagsQuery { ParentId = articleId, Page = 1, PageSize = 20 });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