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][Resource("articles")][HasTags(Scope = "content", MaxPerEntity = 25)]public partial class Article { ... }The source generator emits, in the consuming boundary:
| Artifact | Responsibility |
|---|---|
{Boundary}Tag entity (derived from TagBase) |
Shared tag storage for this boundary |
ArticleTagLink entity (junction) |
Many-to-many link between Article and the tag |
Article.Tags navigation |
EF Core collection property on the parent |
AddArticleTagAction |
Normalise, upsert, add junction row, increment usage |
RemoveArticleTagAction |
Remove the junction row and decrement UsageCount. With AllowCustom = true a tag nothing links any more is deleted; with AllowCustom = false (a curated taxonomy) it is kept at zero usage |
ArticleTagDto |
Read model: the tag (Value, DisplayValue, Scope) plus the link audit (AddedAt, AddedBy) |
ListArticleTagsQuery |
Paged list of tags on a given article, projecting ArticleTagDto |
HTTP endpoints under /articles/{id}/tags |
add, list, remove, 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 once the limit is reached: the Result carries a ForbiddenError
whose Resource names the limit (HTTP 403). Re-applying a tag the entity already carries is
idempotent and is checked first, so being at the limit never blocks it.
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 tagValue) => tagValue.Trim().ToLowerInvariant().Replace(' ', '-');
public Task<bool> IsAllowedAsync(string tagValue, CancellationToken ct = default) => Task.FromResult(tagValue.Length <= 32);
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;}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
