Skip to content

Taxonomy — Cross-Cutting Tags and Categories

Granit.Taxonomy is the framework’s shared tagging and categorisation primitive. Any aggregate root — Document, Party, Activity, Invoice, host-defined types — registers as a taggable entity and gets CRUD, autocomplete, assignment, cross-entity search, and an admin UI for free. One infrastructure, one localisation set, one set of permissions.

The shape is locked by ADR-054, which replaces the per-module *Tag join tables originally specced by ADR-052.

Tagging is universal. Without a shared module, every aggregate that wants tags ships its own Tag entity, its own *Tag join table, its own CRUD endpoints, its own autocomplete, its own admin UI, its own archi tests, and its own localisation. Six consumer modules means six near-identical reimplementations and zero cross-entity search.

A market scan (Odoo, GitHub, Linear, Notion, Asana, Jira) shows that successful products scope tags per domain rather than collapse them into one global pot — “VIP” on a customer means something different from “VIP” on a project, and a single dropdown loses that semantic context. But the per-domain approach trades that UX clarity for missing cross-cutting search.

Granit.Taxonomy is the hybrid: one shared module with one physical table, each tag carries a scope discriminator. Per-domain autocomplete stays scoped by default; cross-entity search opts in via scope=*; hosts that prefer a single global pot collapse everything to scope = "global".

Three packages:

flowchart LR
    Endpoints["Granit.Taxonomy.Endpoints<br/>CRUD · assignment · search"]
    Ef["Granit.Taxonomy.EntityFrameworkCore<br/>TaxonomyDbContext · interceptors"]
    Base["Granit.Taxonomy<br/>Tag · Category · TagAssignment · ITagService"]

    Endpoints --> Ef
    Endpoints --> Base
    Ef --> Base
PackageRole
Granit.TaxonomyAbstractions: Tag and Category aggregates, TagAssignment / CategoryAssignment entities, ITagService / ICategoryService, options, DI registration, diagnostics.
Granit.Taxonomy.EntityFrameworkCoreIsolated TaxonomyDbContext, EF Core configurations, service implementations.
Granit.Taxonomy.EndpointsTag and category CRUD, assignment endpoints, per-scope autocomplete, cross-entity search.
Granit.Taxonomy.BackgroundJobsNightly orphan-assignment sweep job (T5.2 — PR #1870 / #1897) that prunes dangling *Assignment rows whose target aggregates no longer exist.

Architecture boundaries are pinned by TaxonomyArchitectureTests: no Microsoft.AspNetCore.* in the base package, no EF Core in endpoints, no Wolverine refs in either.

public sealed class Tag : AggregateRoot, IMultiTenant
{
public Guid? TenantId { get; private set; }
/// <summary>
/// Scope discriminator — drives the per-domain autocomplete filter and the
/// admin UI grouping. Examples: "documents", "parties", "activities",
/// "global", or any host-defined string.
/// </summary>
public string Scope { get; private set; }
public string Name { get; private set; } // max 50 chars
public string Color { get; private set; } // hex (#RRGGBB)
/// <summary>
/// Operational tag — visible in admin UI and assignment forms (so it can
/// be applied), but hidden on entity card / list / detail surfaces.
/// </summary>
public bool HideOnEntityCard { get; private set; }
}

Unique constraint: (TenantId, Scope, Name). “VIP” can coexist in scope parties and scope global without conflict.

Notably dropped versus a typical “fully-loaded” tag aggregate:

  • No Description — the name is the entire UX, matching Odoo’s crm.tag.
  • No Active flag — soft-delete via Granit’s Trashed semantic if needed later; the baseline keeps tags either present or hard-deleted.
  • No ParentId — tags are flat by design. Hierarchy belongs to Category.
public sealed class TagAssignment : Entity, IMultiTenant
{
public Guid? TenantId { get; private set; }
public Guid TagId { get; private set; }
/// <summary>
/// Discriminator for the polymorphic target. By convention the
/// assembly-qualified type name of the target aggregate
/// (e.g. "Granit.Documents.Domain.Document").
/// </summary>
public string TargetType { get; private set; }
public Guid TargetId { get; private set; }
public DateTimeOffset AssignedAt { get; private set; }
public Guid AssignedByUserId { get; private set; }
}
  • Unique constraint: (TenantId, TagId, TargetType, TargetId).
  • Composite index (TenantId, TargetType, TargetId) — “tags of entity X”.
  • Composite index (TenantId, TagId) — “things tagged X”.

Categories are hierarchical and single-assignment — same shape as Tag plus a ParentId? and a materialised Path for fast subtree queries (mirrors the folder pattern from ADR-052):

public sealed class Category : AggregateRoot, IMultiTenant
{
public Guid? TenantId { get; private set; }
public string Scope { get; private set; }
public Guid? ParentId { get; private set; } // null = root in the scope
public string Path { get; private set; } // "/electronics/laptops"
public int Depth { get; private set; }
public string Name { get; private set; }
public string? IconName { get; private set; } // Lucide identifier
public bool HideOnEntityCard { get; private set; }
}

CategoryAssignment follows the same polymorphic pattern as TagAssignment but with a unique constraint on (TenantId, TargetType, TargetId) — one category per target, not many.

VerbRoutePurpose
GET/api/v1/taxonomy/tags?scope=documents&q=urgPer-scope autocomplete. Default scope mandatory; cross-scope opt-in via ?scope=*.
POST/api/v1/taxonomy/tagsCreate tag.
PATCH/api/v1/taxonomy/tags/{id}Rename, recolour, toggle HideOnEntityCard.
DELETE/api/v1/taxonomy/tags/{id}Delete tag and cascade assignments.
POST/api/v1/taxonomy/tags/{id}/assignBody: { targetType, targetId }. Idempotent.
DELETE/api/v1/taxonomy/tags/{id}/assign/{targetType}/{targetId}Remove assignment.
GET/api/v1/taxonomy/assignments?targetType=…&targetId=…Tags currently assigned to a target.
GET/api/v1/taxonomy/search?q=urgent&scope=*Cross-entity search — returns assignments grouped by TargetType.

Categories mirror the same shape under /api/v1/taxonomy/categories.

PermissionGrants
Taxonomy.Tags.ReadRead tags and assignments in scopes the caller has access to.
Taxonomy.Tags.ManageCRUD tags, assign and unassign.
Taxonomy.Categories.ReadRead categories and assignments.
Taxonomy.Categories.ManageCRUD categories, assign and unassign.
Taxonomy.Search.ReadCross-entity search across all TargetTypes — separate because it surfaces data the user might not have per-entity read on.

Per-entity tagging additionally requires the consumer module’s own permission — assigning a tag to a Document requires Documents.Documents.Manage. The endpoint enforces this composite check by resolving targetType to the expected permission via a registered ITaggablePermissionResolver.

A consumer module wires up tagging in three steps:

  1. Reference Granit.Taxonomy from the base project:

    <ProjectReference Include="..\Granit.Taxonomy\Granit.Taxonomy.csproj" />
  2. Register the aggregate as a taggable entity with a stable scope:

    services.AddTaggableEntity<Document>(scope: "documents");
  3. (Optional) Expose convenience endpoints on the consumer’s own surface (GET /documents/{id}/tags proxies to /taxonomy/assignments?...) — purely a UX shortcut. The canonical store remains Taxonomy.

The consumer never defines its own *Tag table.

When a target aggregate is permanently deleted, its tag and category assignments become orphans. Cleanup runs through a generic listener subscribed to EntityDeletedEto<T> (emitted by aggregates that opt into IEmitEntityLifecycleEvents):

public class TaxonomyAssignmentCleanupHandler
{
public static async Task HandleAsync(
EntityDeletedEto evt,
ITagAssignmentService tags,
ICategoryAssignmentService categories,
CancellationToken cancellationToken)
{
await tags.RemoveAllAssignmentsAsync(evt.EntityType, evt.EntityId, cancellationToken).ConfigureAwait(false);
await categories.RemoveAllAssignmentsAsync(evt.EntityType, evt.EntityId, cancellationToken).ConfigureAwait(false);
}
}

A nightly background job sweeps for orphan rows that escape the event path (raw SQL deletes, crash-mid-transaction), guaranteeing eventual consistency without DB-level FKs.

Tag and category labels are tenant data — never localised by the framework. A tenant-defined “VIP” stays “VIP” in every language; the administrator owns the wording. Only the permission strings and error messages of Granit.Taxonomy.Endpoints get the standard 18-culture treatment. This matches Odoo’s behaviour and avoids a translation matrix that would be unmanageable for tenant-facing administrators.

GoalScope strategy
Per-domain UX clarity (default)One scope per consumer module: "documents", "parties", "activities".
Single global pot across all entitiesRegister every consumer with scope: "global".
Mixed — some shared, some isolatedUse distinct scopes for isolated domains; "global" for tags that should appear everywhere.
Cross-entity search across all scopesGET /api/v1/taxonomy/search?scope=* — requires Taxonomy.Search.Read.