Skip to content

Getting Started

Three concrete scenarios showing how [HasTags] fits different taxonomy styles.


Terminal window
dotnet add package Pragmatic.Tags

Typical companion packages (already present in most Pragmatic hosts):

Terminal window
dotnet add package Pragmatic.Persistence.EFCore
dotnet add package Pragmatic.Endpoints

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} — remove
  • GET /articles/{id}/tags — list

Usage:

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


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:

Terminal window
curl -X POST /articles/42/tags -d '{"value": "breaking"}' # 200 OK
curl -X POST /articles/42/tags -d '{"value": "nonsense"}' # 400 — not in taxonomy

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 total
FROM Tags
WHERE Scope = 'marketing' AND Value = 'launch'

Or per-entity counts:

SELECT t.Value, COUNT(DISTINCT at.ParentEntityId) AS articles, COUNT(DISTINCT pt.ParentEntityId) AS products
FROM Tags t
LEFT JOIN ArticleTags at ON at.TagId = t.Id
LEFT JOIN ProductTags pt ON pt.TagId = t.Id
WHERE t.Scope = 'marketing'
GROUP BY t.Value

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);
var page = await queryExecutor.ExecuteAsync(
new ListArticleTagsQuery { ParentId = articleId, Page = 1, PageSize = 20 });
[HasTags(CaseSensitive = true)] // "CSS" and "css" are different tags
[HasTags(CaseSensitive = false)] // default — they collapse

Flip this on for technology tag sets (C# vs c#, iOS vs ios) where casing carries meaning.

[HasTags(SubBoundary = "ArticleLabels")]

By default the generated sub-boundary is {Parent}Tags. Override when the naming feels unnatural in your domain.


Related: