DEV Community

Arash Zand
Arash Zand

Posted on • Originally published at Medium

Implementing a Hierarchical Cache for Tagging in C# .NET

Tagging is everywhere — CMS platforms, e-commerce, social media. A flat tagging system works fine at small scale, but as soon as you need hierarchical relationships ("C#" is under "Programming" which is under "Technology"), the basic approach falls apart: multiple queries, redundant data, and cascading maintenance headaches.

This article walks through the problem, then builds a hierarchical cache that organizes tags in a tree structure using IMemoryCache — turning multi-query reads into single in-memory traversals.


The Problem: Basic Tagging

A flat system has three tables — Item, Tag, ItemTag (junction). Simple enough:

public class Tag
{
    [Key] public int Id { get; set; }
    public string Name { get; set; }
    public List<ItemTag> ItemTags { get; set; }
}

public class ItemTag
{
    public int ItemId { get; set; }
    public int TagId  { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

CRUD operations work, but as soon as you want "all items tagged with 'Programming' or any of its children", you need multiple queries:

// Three separate DB hits just to get all programming-related items
var programmingItems = manager.GetItemsForTag(2); // "Programming"
var csharpItems      = manager.GetItemsForTag(3); // "C#"
var pythonItems      = manager.GetItemsForTag(4); // "Python"
var allItems = programmingItems.Union(csharpItems).Union(pythonItems).ToList();
Enter fullscreen mode Exit fullscreen mode

And tagging a new item with "C#" means manually also tagging it with "Programming" and "Technology" to keep things consistent. This breaks down fast.

Root causes:

  • No native hierarchy — relationships are manual
  • Read-heavy queries hit the DB every time
  • Write operations don't propagate up the tree
  • Deleting a parent tag requires careful cascading logic

The Solution: Hierarchical Cache

The idea: load the tag tree into memory as a graph of TagNode objects. Reads are tree traversals (microseconds). Writes update both the DB and the cache. Parent nodes automatically accumulate items from their children.

Tag Model — add ParentId

public class Tag
{
    [Key] public int Id { get; set; }
    public string Name { get; set; }
    public int? ParentId { get; set; }
    public Tag Parent { get; set; }
    public List<Tag> Children { get; set; }
    public List<ItemTag> ItemTags { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

TagNode — the in-memory tree node

public class TagNode
{
    public int Id { get; set; }
    public string Name { get; set; }
    public TagNode Parent { get; set; }
    public List<TagNode> Children { get; set; }
    public List<int> AssociatedItems { get; set; }

    public TagNode(int id, string name)
    {
        Id = id;
        Name = name;
        Children = new List<TagNode>();
        AssociatedItems = new List<int>();
    }
}
Enter fullscreen mode Exit fullscreen mode

TagCacheManager — the full implementation

public class TagCacheManager
{
    private readonly IMemoryCache _cache;
    private readonly TagContext _context;
    private const string CacheKey = "TagHierarchy";

    public TagCacheManager(IMemoryCache cache, TagContext context)
    {
        _cache = cache;
        _context = context;
    }

    // Load entire tag tree from DB into memory
    public void LoadFromDatabase()
    {
        var tags = _context.Tags
            .Include(t => t.Children)
            .Include(t => t.ItemTags)
            .ToList();

        var root = new TagNode(0, "Root");
        var nodeMap = new Dictionary<int, TagNode>();

        foreach (var tag in tags)
        {
            var node = new TagNode(tag.Id, tag.Name);
            node.AssociatedItems = tag.ItemTags.Select(it => it.ItemId).ToList();
            nodeMap[tag.Id] = node;
        }

        foreach (var tag in tags)
        {
            var node = nodeMap[tag.Id];
            if (tag.ParentId.HasValue && nodeMap.ContainsKey(tag.ParentId.Value))
            {
                node.Parent = nodeMap[tag.ParentId.Value];
                nodeMap[tag.ParentId.Value].Children.Add(node);
            }
            else
            {
                root.Children.Add(node);
                node.Parent = root;
            }
        }

        SetCache(root);
    }

    // Create tag — persists to DB, updates cache
    public void CreateTag(int id, string name, int? parentId)
    {
        _context.Tags.Add(new Tag { Id = id, Name = name, ParentId = parentId });
        _context.SaveChanges();

        if (!_cache.TryGetValue(CacheKey, out TagNode root))
        {
            LoadFromDatabase();
            return;
        }

        var newNode = new TagNode(id, name);
        if (parentId.HasValue)
        {
            var parent = FindNode(root, parentId.Value);
            if (parent != null)
            {
                newNode.Parent = parent;
                parent.Children.Add(newNode);
            }
        }
        else
        {
            root.Children.Add(newNode);
            newNode.Parent = root;
        }

        SetCache(root);
    }

    // Associate item — propagates up to all ancestors automatically
    public void AssociateItem(int tagId, int itemId)
    {
        _context.ItemTags.Add(new ItemTag { TagId = tagId, ItemId = itemId });
        _context.SaveChanges();

        if (!_cache.TryGetValue(CacheKey, out TagNode root)) return;

        var node = FindNode(root, tagId);
        if (node == null) return;

        // Add to this node
        if (!node.AssociatedItems.Contains(itemId))
            node.AssociatedItems.Add(itemId);

        // Propagate up the tree — parent and all ancestors get this item
        var current = node.Parent;
        while (current != null)
        {
            if (!current.AssociatedItems.Contains(itemId))
                current.AssociatedItems.Add(itemId);
            current = current.Parent;
        }

        SetCache(root);
    }

    // Read — single in-memory traversal, includes all descendants
    public List<int> GetItemsForTag(int tagId)
    {
        if (!_cache.TryGetValue(CacheKey, out TagNode root))
            return new List<int>();

        var node = FindNode(root, tagId);
        if (node == null) return new List<int>();

        var result = new List<int>();
        CollectItems(node, result);
        return result.Distinct().ToList();
    }

    // Update name — DB + cache
    public void UpdateTag(int id, string newName)
    {
        var tag = _context.Tags.Find(id);
        if (tag == null) return;

        tag.Name = newName;
        _context.SaveChanges();

        if (!_cache.TryGetValue(CacheKey, out TagNode root)) return;
        var node = FindNode(root, id);
        if (node != null) { node.Name = newName; SetCache(root); }
    }

    // Delete — removes from DB, detaches from parent in cache
    public void DeleteTag(int id)
    {
        var tag = _context.Tags
            .Include(t => t.ItemTags)
            .FirstOrDefault(t => t.Id == id);

        if (tag == null) return;

        _context.ItemTags.RemoveRange(tag.ItemTags);
        _context.Tags.Remove(tag);
        _context.SaveChanges();

        if (!_cache.TryGetValue(CacheKey, out TagNode root)) return;
        var node = FindNode(root, id);
        if (node != null)
        {
            node.Parent?.Children.Remove(node);
            SetCache(root);
        }
    }

    private void SetCache(TagNode root) =>
        _cache.Set(CacheKey, root, new MemoryCacheEntryOptions
        {
            SlidingExpiration = TimeSpan.FromMinutes(30)
        });

    private TagNode FindNode(TagNode node, int id)
    {
        if (node.Id == id) return node;
        foreach (var child in node.Children)
        {
            var found = FindNode(child, id);
            if (found != null) return found;
        }
        return null;
    }

    private void CollectItems(TagNode node, List<int> result)
    {
        result.AddRange(node.AssociatedItems);
        foreach (var child in node.Children)
            CollectItems(child, result);
    }
}
Enter fullscreen mode Exit fullscreen mode

Registration

services.AddDbContext<TagContext>(options =>
    options.UseSqlServer("YourConnectionString"));
services.AddMemoryCache();
services.AddSingleton<TagCacheManager>();
Enter fullscreen mode Exit fullscreen mode

Usage Example

manager.LoadFromDatabase();

// Build hierarchy: Technology > Programming > C#, Python
//                             > Hardware > CPUs
manager.CreateTag(1, "Technology",   null);
manager.CreateTag(2, "Programming",  1);
manager.CreateTag(3, "C#",           2);
manager.CreateTag(4, "Python",       2);
manager.CreateTag(5, "Hardware",     1);
manager.CreateTag(6, "CPUs",         5);

// Associate items — propagation happens automatically
manager.AssociateItem(3, 101); // post 101 → "C#" → also "Programming" + "Technology"
manager.AssociateItem(4, 102); // post 102 → "Python" → also "Programming" + "Technology"
manager.AssociateItem(6, 103); // post 103 → "CPUs" → also "Hardware" + "Technology"

Console.WriteLine(string.Join(", ", manager.GetItemsForTag(2))); // Programming: 101, 102
Console.WriteLine(string.Join(", ", manager.GetItemsForTag(1))); // Technology:  101, 102, 103
Enter fullscreen mode Exit fullscreen mode

Performance Comparison

Operation Basic System Hierarchical Cache
Read "Technology" (3 subtrees) ~3 DB queries, ~100ms 1 tree traversal, ~1ms
Tag item with "C#" Manual inserts for each related tag Single insert + in-memory propagation
Delete parent tag Manual cascade logic required Detach node, DB handles the rest

Scaling Beyond In-Memory

Redis for distributed environments:

services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost:6379";
});
Enter fullscreen mode Exit fullscreen mode

Serialize the tag tree to JSON, store in Redis. All app instances share the same cache.

Thread safety for high concurrency:
Replace List<int> in AssociatedItems with ConcurrentBag<int> or use a read-write lock when mutating the tree.

Cache invalidation via message queue:
Publish a TagChanged event to RabbitMQ/Service Bus when the DB changes. Each instance subscribes and refreshes its cache slice.


Unit Test

[Fact]
public void GetItemsForTag_IncludesDescendantItems()
{
    var cache   = new MemoryCache(new MemoryCacheOptions());
    var context = new TagContext(
        new DbContextOptionsBuilder<TagContext>()
            .UseInMemoryDatabase("Test")
            .Options);
    var manager = new TagCacheManager(cache, context);

    manager.CreateTag(1, "Technology",  null);
    manager.CreateTag(2, "Programming", 1);
    manager.CreateTag(3, "C#",          2);
    manager.AssociateItem(3, 101);

    var items = manager.GetItemsForTag(1); // Technology
    Assert.Contains(101, items);           // should include the C# item
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

A hierarchical cache transforms multi-query reads into single tree traversals and makes parent-child propagation automatic. The TagCacheManager keeps the DB as the source of truth while the in-memory tree handles all reads. For production systems, pair it with Redis for distribution and a message queue for cache invalidation.


Originally published on Medium.

Top comments (0)