Routing and redirects
Vox.Routing is an optional projection-publication add-on. It turns application-defined route documents into an
atomic lookup index with uniqueness, aliases, and historical redirects. Vox core remains unaware of URL structure,
domains, CMS hierarchy, and path conventions.
Slugs, paths, and URLs
A slug is one localized path segment such as coffee-makers. A path is an application-relative route such as
/categories/kitchen/coffee-makers. An absolute URL additionally has a scheme and authority. Routable entities keep
only the segment:
public class Category : IVoxEntity, IVoxRoutable
{
public VoxVarying<IVoxLanguage, string> Slug { get; set; } = new();
public VoxReference<Category>? Parent { get; set; }
}
The default normalizer requires paths to begin with /. It rejects query strings, fragments, backslashes, dot
segments, control characters, and encoded separators; collapses repeated/trailing separators; normalizes Unicode;
and applies the configured case policy. A slug must be one non-empty segment. Applications can replace
IVoxRouteNormalizer, but writes and lookups always use the same instance.
Publish complete route claims
A normal entity projection publishes a VoxRouteDocument:
[VoxEntityProjection("routes", "1")]
[VoxEntityProjectionPublication]
public class CategoryRoutes(IVoxRouteNormalizer normalizer)
: IVoxEntityProjectionFactory<Category, VoxRouteDocument>
{
public async Task<VoxRouteDocument?> ProjectAsync(
Category category,
IVoxEntityProjectionContext context,
CancellationToken cancellationToken)
{
var languages = await context.Calculations.GetAllAsync<Language>(cancellationToken);
var claims = new List<VoxRouteClaim>();
foreach (var language in languages)
{
// Walk category.Parent with context.Calculations.GetAsync<Category>.
// Every read becomes a dependency of this output.
var path = await BuildPathAsync(category, language, context, cancellationToken);
claims.Add(new VoxRouteClaim(
"storefront",
path,
new Dictionary<string, string>
{
[nameof(Language)] = language.Id,
}));
}
return claims.Count == 0 ? null : new VoxRouteDocument(claims);
}
}
This is an unscoped publication: one evaluation publishes all localized routes for the source entity, so
context.Contexts is empty. Each claim's Contexts instead records the exact destination context selection that the
routing endpoint must use later. The key is the concrete registered context type name (Language here), and the value
comes from the language whose path was built. Do not hard-code a language unless the application intentionally
publishes only that language.
Space is an application-defined namespace, normally a site or domain. (Space, NormalizedPath) is unique across
all source entity types and projection publications; the same path can exist in independent spaces. Contexts is
the exact concrete Vox context selection used to deliver the destination. A null Projection selects the entity's
root projection, while a name selects an explicit content-delivery projection.
A document can publish localized routes and aliases. It must contain exactly one canonical claim for each space and context selection. Missing localized slugs produce no route unless the application deliberately implements a fallback in its factory.
Dependency-driven hierarchy updates
Parent discovery is explicit application logic. Read every parent relation, slug, prefix setting, or site entity via
context.Calculations. Vox records those reads. Changing an ancestor slug or moving a branch then republishes each
dependent descendant route without saving the descendants, incrementing their entity versions, or writing unrelated
history.
Full builds atomically replace a publication target and remove owners not seen in the build. Incremental sessions atomically diff individual owners. A collision or failed/disposed session leaves the previous visible index intact, and retrying a completed build ID is idempotent.
Route storage and high-traffic delivery
IVoxRouteStore is the storage boundary used by route publication and lookup. Its publication sessions must expose a
complete build atomically, enforce (Space, NormalizedPath) uniqueness, retain redirects, and make completed build
IDs idempotent. Resolution reads use the same normalized keys written by the publication sink.
The bundled durable implementation is EntityFrameworkVoxRouteStore, registered with
AddEntityFrameworkRouting. It stores routes and redirects in SQL and is the default choice for ordinary
installations. InMemoryVoxRouteStore is also available for tests and ephemeral hosts, but it loses the complete
route index when the process stops.
Applications with a high-volume or geographically distributed delivery tier can replace the SQL store by
registering their own IVoxRouteStore implementation after AddVoxRouting. Register exactly one route store: Vox
validates this when Build() runs. A custom store can use Redis, a dedicated routing database, or another lookup
system as long as it preserves the interface's atomic publication and collision semantics.
vox.AddVoxRouting();
vox.Services.AddScoped<IVoxRouteStore, HighTrafficRouteStore>();
An official Redis-backed IVoxRouteStore implementation is coming.
Aliases and historical redirects
An alias is a current VoxRouteKind.Alias claim owned by the document and resolves to its canonical claim. A
historical redirect is created by the default IVoxRedirectPolicy only when one removed canonical route has exactly
one replacement in the same space and equivalent contexts. Redirect chains are flattened to the latest canonical
destination and loops are rejected.
Deletion, unpublishing, removing a language, and ambiguous split/merge changes do not invent redirects. A new current claim may reclaim a historical redirect source; the current route wins. Redirect delivery uses 308 by default, or 301 when explicitly configured.
Projection delivery
Register the add-on and map an explicit catch-all:
vox.AddVoxRouting()
.AddEntityFrameworkRouting(options => options.ConfigureDbContext = ConfigureRoutes)
.AddVoxRoutingApi(options => options.DefaultSpace = "storefront");
app.MapVoxRoutes("/content/{**path}");
The endpoint resolves the stored entity, contexts, and projection, then uses the same delivery component as the
entity-ID projection API. Read permissions, not-found behavior, sensitive-value redaction, rich-text formatting, and
reference expansion therefore remain consistent. Stored contexts are authoritative; callers cannot override them
with ?context=.
The initial route index contains published content only. Draft and release previews continue to use direct entity projection delivery; they are not silently mixed into public routing. Publication state and failures remain visible through the entity-projection-publication jobs and API.