Skip to content

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.


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 filteringWHERE 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.

Pragmatic.Tags models tagging as a real many-to-many relationship:

  • One shared Tag entity per boundary — stores Value, DisplayValue, Scope, UsageCount, audit fields
  • One junction entity per parent type (ArticleTag, ProductTag) — stores ParentEntityId, 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, ... } │
└───────────────────────────┘

From this declaration:

[Entity<Guid>]
[Resource("articles")]
[HasTags(Scope = "content", MaxPerEntity = 25)]
public partial class Article { ... }

The source generator emits, in the consuming boundary:

ArtifactResponsibility
Tag entity (derived from TagBase)Shared tag storage for this boundary
ArticleTag entity (junction)Many-to-many link between Article and Tag
Article.Tags navigationEF Core collection property on the parent
AddTagToArticle actionNormalise, upsert, add junction row, increment usage
RemoveTagFromArticle actionRemove junction row, decrement usage, optionally delete unused tag
ListArticleTagsQueryPaged list of tags on a given article
HTTP endpoints under /articles/{id}/tagsadd, 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. Article tags are separate from Product tags even if the values are the same.
  • Scope = shared string across entities[HasTags(Scope = "content")] on both Article and Video makes them share a tag pool: adding "urgent" to an article and then to a video uses the same underlying Tag row.
  • 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.


  • CaseSensitive = false (default)"Urgent", "URGENT", and "urgent" collapse to the same Value. DisplayValue preserves 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.


  • AllowCustom = true (default) — adding a new value automatically creates the Tag row 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).


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.


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.


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.


Pragmatic.Tags and Pragmatic.Comments are sibling trait packages with similar architecture (shared entity + junction + actions + endpoints + policy hook). The differences:

TagsComments
ShapeNormalised many-to-manyDenormalised one-to-many
Primary keyValue + ScopeId
ModerationThrough AllowCustom / policyExplicit Status field + Moderate action
ThreadingN/AReply hierarchy supported
Usage counterYesNo

Use Tags for short, normalised labels. Use Comments for rich user-authored content.