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

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]
[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. 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 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.


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


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.