Architecture and Core Concepts
Pragmatic.Tags is a trait package. You mark an entity with [HasTags] and the source generator produces the entity extensions, a normalised tag entity, the junction table, CRUD actions, endpoints, and a Tags navigation — all at compile time.
This guide covers what it generates, why it’s relational instead of JSON, and how to configure it for curated vs free-form taxonomies.
The Problem
Section titled “The Problem”Tagging looks trivial until the requirements arrive:
- Normalisation —
"URGENT","urgent"," urgent "should be the same tag - Deduplication — adding the same tag twice shouldn’t double-up on the entity
- Usage counts — admins want “most-used tags”, “unused tags”, “top 10 by usage”
- Rename / merge — rename “urgent” → “priority” everywhere at once
- Analytics — “how many articles have tag X”, “co-occurrence of tags A and B”
- SQL-native filtering —
WHERE t.Value IN ('urgent', 'review')needs proper joins - Scope — product tags and content tags should live in separate namespaces
- Curation — some taxonomies are closed sets (moderated), others are free-form
JSON-column tagging (public string[] Tags { get; set; }) fails most of these. You can’t rename, you can’t analytics, you can’t count, you can’t query efficiently on any database.
The Approach
Section titled “The Approach”Pragmatic.Tags models tagging as a real many-to-many relationship:
- One shared
Tagentity per boundary — storesValue,DisplayValue,Scope,UsageCount, audit fields - One junction entity per parent type (
ArticleTag,ProductTag) — storesParentEntityId,TagId,AddedAt,AddedBy - A collection navigation on the parent entity (
Article.Tags) that resolves through the junction
All three are generated by the SG from a single [HasTags] attribute. You don’t write the entity, the config, or the endpoints.
┌────────────────────┐ │ Article (parent) │ │ [HasTags] │ └─────────┬──────────┘ │ Tags (navigation) ▼ ┌───────────────────────────┐ │ ArticleTag (junction) │ │ { ParentEntityId, TagId }│ └─────────┬─────────────────┘ │ ▼ ┌───────────────────────────┐ │ Tag (shared) │ │ { Value, Scope, │ │ UsageCount, ... } │ └───────────────────────────┘What gets generated
Section titled “What gets generated”From this declaration:
[Entity<Guid>][Resource("articles")][HasTags(Scope = "content", MaxPerEntity = 25)]public partial class Article { ... }The source generator emits, in the consuming boundary:
| Artifact | Responsibility |
|---|---|
Tag entity (derived from TagBase) | Shared tag storage for this boundary |
ArticleTag entity (junction) | Many-to-many link between Article and Tag |
Article.Tags navigation | EF Core collection property on the parent |
AddTagToArticle action | Normalise, upsert, add junction row, increment usage |
RemoveTagFromArticle action | Remove junction row, decrement usage, optionally delete unused tag |
ListArticleTagsQuery | Paged list of tags on a given article |
HTTP endpoints under /articles/{id}/tags | add, remove, list — respecting the module’s endpoint conventions |
All of these live inside the generated sub-boundary ArticleTags (or the name set via SubBoundary = "...").
Scope namespaces tags so different domains don’t collide.
- Scope = entity type name (default) — every entity type has its own tag set.
Articletags are separate fromProducttags even if the values are the same. - Scope = shared string across entities —
[HasTags(Scope = "content")]on bothArticleandVideomakes them share a tag pool: adding"urgent"to an article and then to a video uses the same underlyingTagrow. - Scope-less never exists — scope is always set explicitly or derived from the type name.
Think of scope as a tenant for tag identity: two tags with the same Value but different Scope are different tags.
Normalisation
Section titled “Normalisation”CaseSensitive = false(default) —"Urgent","URGENT", and"urgent"collapse to the sameValue.DisplayValuepreserves the original casing for the UI.CaseSensitive = true— casing matters."Urgent"and"urgent"are distinct tags.
Whitespace is always trimmed. Custom normalisation (strip accents, enforce slug format) goes in ITagPolicy<TEntityId>.Normalize — see below.
Curated vs free-form
Section titled “Curated vs free-form”AllowCustom = true(default) — adding a new value automatically creates theTagrow if it doesn’t exist. Free-form tagging.AllowCustom = false— adding a value that doesn’t exist rejects the add. Use this for closed taxonomies. Create the tags up-front via seeding or an admin flow.
Curated taxonomies pair well with ITagPolicy<TEntityId> to enforce validation rules (allowed characters, max length, forbidden words).
Per-entity limit
Section titled “Per-entity limit”MaxPerEntity caps how many tags a single entity instance can have. Defaults to 50; 0 means unlimited.
The action refuses to add a tag if the limit would be exceeded — the Result carries a LimitReachedError so the caller can show a friendly message.
Usage count
Section titled “Usage count”The generated Tag.UsageCount increments on each add and decrements on each remove. This enables:
- “most-used tags this month” queries
- Tag cloud UIs without live aggregation
- Cheap “unused tags” cleanup (
WHERE UsageCount = 0)
If you need stricter accuracy, add a periodic reconciliation job that recomputes from the junction table — the counter is best-effort.
Policy hook
Section titled “Policy hook”ITagPolicy<TEntityId> is the extensibility point for:
- Normalisation — beyond case and trim (strip accents, convert to slug)
- Validation — reject tags matching a blacklist, enforce length limits, require a specific regex
- Lifecycle hooks — raise a domain event when a tag is added, log when a curated tag is attempted but rejected
public sealed class ArticleTagPolicy : ITagPolicy<Guid>{ public string Normalize(string raw) => raw.Trim().ToLowerInvariant().Replace(' ', '-');
public Result<Unit, IError> Validate(string value, Guid entityId) { if (value.Length > 32) return Result.Failure(new ValidationError("Tag too long")); 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;}Register in Program.cs:
services.AddScoped<ITagPolicy<Guid>, ArticleTagPolicy>();The generated actions call the policy at each step. Without a registered policy, the default policy normalises (trim + case per CaseSensitive) and allows everything.
Comparison with Comments
Section titled “Comparison with Comments”Pragmatic.Tags and Pragmatic.Comments are sibling trait packages with similar architecture (shared entity + junction + actions + endpoints + policy hook). The differences:
| Tags | Comments | |
|---|---|---|
| Shape | Normalised many-to-many | Denormalised one-to-many |
| Primary key | Value + Scope | Id |
| Moderation | Through AllowCustom / policy | Explicit Status field + Moderate action |
| Threading | N/A | Reply hierarchy supported |
| Usage counter | Yes | No |
Use Tags for short, normalised labels. Use Comments for rich user-authored content.
Related
Section titled “Related”- getting-started.md — attaching the trait in 3 scenarios
- common-mistakes.md — scope / case sensitivity pitfalls
- troubleshooting.md — why duplicate tags appear