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; }
}
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();
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; }
}
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>();
}
}
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);
}
}
Registration
services.AddDbContext<TagContext>(options =>
options.UseSqlServer("YourConnectionString"));
services.AddMemoryCache();
services.AddSingleton<TagCacheManager>();
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
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";
});
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
}
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)