Home Provider Architecture
The Home Provider pattern enables O(1) re-homing of navigation subtrees through indirection.
Overview: For high-level concepts, see Functional Principles #3-5. This document explains the implementation.
When building assembled documentation sites, we need to:
- Build navigation for individual repositories in isolation
- Combine them into a single site with custom URL prefixes
- Update all URLs in a subtree efficiently
Naive approach:
// Traverse entire subtree to update URLs
void UpdateUrlPrefix(INavigationItem root, string newPrefix)
{
// O(n) - visit every node
foreach (var item in TraverseTree(root))
{
item.UrlPrefix = newPrefix;
}
}
Issues:
- O(n) traversal for every prefix change
- URL prefix stored at every node
- URLs calculated at construction time
- Changes require tree reconstruction
Instead of storing URL information at each node, use indirection through a provider:
// The provider defines the URL context for a scope
public interface INavigationHomeProvider
{
string PathPrefix { get; }
IRootNavigationItem<INavigationModel, INavigationItem> NavigationRoot { get; }
string Id { get; }
}
// Nodes access their provider through an accessor
public interface INavigationHomeAccessor
{
INavigationHomeProvider HomeProvider { get; set; }
}
- For cache invalidation
Nodes reference a provider instead of storing URL information:
public class FileNavigationLeaf<TModel>
{
private readonly INavigationHomeAccessor _homeAccessor;
public string Url
{
get
{
// Calculate from current provider
var prefix = _homeAccessor.HomeProvider.PathPrefix;
return $"{prefix}/{_relativePath}/";
}
}
}
Re-homing becomes a single assignment:
// Change the provider → all descendants use new prefix
docsetNavigation.HomeProvider = new NavigationHomeProvider("/guide", siteNav);
Navigation types that can be re-homed implement INavigationHomeProvider:
public class DocumentationSetNavigation<TModel>
: INavigationHomeProvider, INavigationHomeAccessor
{
private string _pathPrefix;
// Provider properties
public string PathPrefix => HomeProvider == this
? _pathPrefix
: HomeProvider.PathPrefix;
public IRootNavigationItem<...> NavigationRoot =>
HomeProvider == this
? this
: HomeProvider.NavigationRoot;
// Accessor property
public INavigationHomeProvider HomeProvider { get; set; }
// Initially self-referential
public DocumentationSetNavigation(...)
{
_pathPrefix = pathPrefix ?? "";
HomeProvider = this;
}
}
Child nodes receive their parent's accessor:
// Creating a child node
var fileNav = new FileNavigationLeaf<TModel>(
model,
fileInfo,
new FileNavigationArgs(
path,
relativePath,
hidden,
index,
parent,
homeAccessor: this
)
);
- Pass down the accessor
Leaf nodes use the accessor to calculate URLs:
public class FileNavigationLeaf<TModel>
{
private readonly FileNavigationArgs _args;
public string Url
{
get
{
// Get prefix from current provider
var rootUrl = _args.HomeAccessor.HomeProvider.PathPrefix.TrimEnd('/');
// Determine path based on context
var relativeToContainer =
_args.HomeAccessor.HomeProvider.NavigationRoot.Parent is SiteNavigation;
var relativePath = relativeToContainer
? _args.RelativePathToTableOfContents
: _args.RelativePathToDocumentationSet;
return BuildUrl(rootUrl, relativePath);
}
}
}
In assembler builds, SiteNavigation replaces the provider:
// CreateSiteTableOfContentsNavigation(...):
// Calculate new path prefix for this subtree
var pathPrefix = $"{_sitePrefix}/{tocRef.PathPrefix}".Trim('/');
// Create new provider with custom prefix
var newProvider = new NavigationHomeProvider(pathPrefix, root);
// Replace provider - this is the magic! ⚡
homeAccessor.HomeProvider = newProvider;
// All descendants now use the new prefix
What happens:
homeAccessor.HomeProvideris assigned a new provider- Provider has
PathPrefix = "/guide"andNavigationRoot = SiteNavigation - Every URL calculation in that subtree now uses the "/guide" prefix
- No tree traversal needed
DocumentationSetNavigation (elastic-docs)
├─ HomeProvider: self
├─ PathPrefix: ""
├─ NavigationRoot: self
│
└─ TableOfContentsNavigation (api/)
├─ HomeProvider: inherited from parent = DocumentationSetNavigation
├─ PathPrefix: "" (from provider)
├─ NavigationRoot: DocumentationSetNavigation (from provider)
│
└─ FileNavigationLeaf (api/rest.md)
├─ HomeAccessor.HomeProvider: DocumentationSetNavigation
└─ URL calculation:
prefix = HomeProvider.PathPrefix = ""
path = "api/rest.md"
url = "/api/rest/"
SiteNavigation
├─ HomeProvider: self
├─ PathPrefix: ""
├─ NavigationRoot: self
│
└─ DocumentationSetNavigation (elastic-docs)
├─ HomeProvider: NEW NavigationHomeProvider("/guide", SiteNavigation) ⚡
├─ PathPrefix: "/guide" (from new provider)
├─ NavigationRoot: SiteNavigation (from new provider)
│
└─ TableOfContentsNavigation (api/)
├─ HomeProvider: inherited = new provider ⚡
├─ PathPrefix: "/guide" (from new provider)
├─ NavigationRoot: SiteNavigation (from new provider)
│
└─ FileNavigationLeaf (api/rest.md)
├─ HomeAccessor.HomeProvider: new provider ⚡
└─ URL calculation:
prefix = HomeProvider.PathPrefix = "/guide"
path = "api/rest.md"
url = "/guide/api/rest/" ✨
The re-homing happened at lines marked with ⚡ - a single assignment!
// This updates ALL URLs in the subtree - regardless of size!
node.HomeProvider = new NavigationHomeProvider("/new-prefix", newRoot);
Time complexity: O(1)
This isn't marketing - it's a fact. Whether the subtree has 10 nodes or 10,000 nodes, re-homing takes the same amount of time because it's a single reference assignment.
Compare to naive approach:
- Naive: O(n) - must visit every node
- Provider: O(1) - single assignment
URLs calculated on-demand:
- Not calculated until accessed
- Always reflects current provider state
- Memory efficient - no stored URL strings
private string? _homeProviderCache;
private string? _urlCache;
public string Url
{
get
{
// Check if provider changed
if (_homeProviderCache != null &&
_homeProviderCache == _args.HomeAccessor.HomeProvider.Id &&
_urlCache != null)
{
return _urlCache;
}
// Recalculate and cache
_homeProviderCache = _args.HomeAccessor.HomeProvider.Id;
_urlCache = DetermineUrl();
return _urlCache;
}
}
Caching strategy:
- First access: O(depth) calculation
- Subsequent accesses: O(1) cache lookup
- Cache invalidates automatically when provider changes (via Id comparison)
Each provider creates an isolated scope:
- Changes to one scope don't affect others
- Clear ownership of URL context
- Enables independent re-homing of subtrees
Each provider has a unique ID for cache invalidation:
public class NavigationHomeProvider : INavigationHomeProvider
{
public string Id { get; } = Guid.NewGuid().ToString("N");
}
When a provider changes, the ID changes, invalidating cached URLs.
Provider: Nodes that create scopes (DocumentationSetNavigation, TableOfContentsNavigation)
Accessor: All nodes that need to calculate URLs
Some nodes implement both:
public class DocumentationSetNavigation<TModel>
: INavigationHomeProvider, INavigationHomeAccessor
{
// Can be a provider AND access a different provider
}
This dual implementation is what enables re-homing.
During construction, accessors flow down the tree:
// Parent creates child, passes its accessor
var childNav = ConvertToNavigationItem(
tocItem,
index,
context,
parent: this,
homeAccessor: this
);
- Pass down accessor
Children inherit their parent's accessor, creating a reference chain back to the scope provider.
In assembler builds, TOCs create isolated providers:
var assemblerBuild = context.AssemblerBuild;
var isolatedHomeProvider = assemblerBuild
? new NavigationHomeProvider(
homeAccessor.HomeProvider.PathPrefix,
homeAccessor.HomeProvider.NavigationRoot
)
: homeAccessor.HomeProvider;
This ensures TOCs can be re-homed independently during site assembly.
See Assembler Process for details on how this flag controls scope creation.
Per Node:
- Provider: ~48 bytes (string, reference, guid)
- Accessor: 8 bytes (reference)
- Cache: ~32 bytes (2 strings) - leaf nodes only
For 10,000 nodes:
- Without caching: ~560 KB
- With cached URLs: ~880 KB
- Naive approach (stored URLs): ~1.5 MB+
URL Calculation:
- Cache hit: O(1) - pointer dereference + string return
- Cache miss: O(depth) - string concatenation + path processing
- Re-homing: O(1) - reference assignment
Access Pattern:
- First access: Calculate and cache
- Subsequent: Return cached value
- After re-homing: Recalculate on next access
Re-homing time is constant regardless of subtree size:
| Subtree Size | Re-homing Time |
|---|---|
| 100 nodes | O(1) |
| 10,000 nodes | O(1) |
| 1,000,000 nodes | O(1) |
This is O(1) because re-homing is a single reference assignment, regardless of how many nodes reference that provider.
public class MyNavigation : INavigationHomeProvider, INavigationHomeAccessor
{
private string _pathPrefix;
public MyNavigation(string pathPrefix)
{
_pathPrefix = pathPrefix;
HomeProvider = this;
}
public string PathPrefix => HomeProvider == this ? _pathPrefix : HomeProvider.PathPrefix;
public IRootNavigationItem<...> NavigationRoot => /* ... */;
public INavigationHomeProvider HomeProvider { get; set; }
}
- Self-referential initially
public class MyLeaf
{
private readonly INavigationHomeAccessor _homeAccessor;
public MyLeaf(INavigationHomeAccessor homeAccessor)
{
_homeAccessor = homeAccessor;
}
public string Url =>
$"{_homeAccessor.HomeProvider.PathPrefix}/{_path}/";
}
void RehomeSubtree(
INavigationHomeAccessor subtree,
string newPrefix,
IRootNavigationItem<...> newRoot)
{
subtree.HomeProvider = new NavigationHomeProvider(newPrefix, newRoot);
// ✅ All URLs updated
}
[Fact]
public void RehomingUpdatesUrlsDynamically()
{
// Create isolated navigation
var docset = new DocumentationSetNavigation<IDocumentationFile>(...);
var leaf = docset.NavigationItems.First() as FileNavigationLeaf<IDocumentationFile>;
// Initial URL
Assert.Equal("/api/rest/", leaf.Url);
// Re-home the docset
docset.HomeProvider = new NavigationHomeProvider("/guide", siteNav);
// URL updated ✨
Assert.Equal("/guide/api/rest/", leaf.Url);
}
The Home Provider pattern provides:
✅ O(1) re-homing - Single reference assignment updates entire subtree ✅ Lazy URL evaluation - URLs calculated on-demand ✅ Automatic cache invalidation - Via provider ID comparison ✅ Memory efficiency - No stored URL strings ✅ Scope isolation - Changes don't leak between scopes
This enables building isolated documentation repositories and efficiently assembling them into a unified site with custom URL prefixes. The O(1) re-homing is what makes the assembler build practical - without it, combining large documentation sites would require expensive tree traversal for every URL prefix change.