Entity projections
Entity projections turn a Vox entity into an application-facing .NET type. They are useful as the content-delivery model for a frontend, as the representation of an entity referenced by rich text or another projection, and as the document published to a search index, feed, cache, or export.
A projection does not change or get stored on its source entity. It is deliberately different from a cached reference, which stores a small subset of a referenced entity on the entity that owns the relation.
Define projections
One entity type can have several projections. For example, a product can have a full projection for its product page and a smaller card projection for menus, search results, page blocks, and rich text:
public record ProductPage(
string Id,
string Name,
string Description,
decimal Price);
[VoxEntityProjection("product-page", "1", IsDefault = true)]
public class ProductPageProjection
: IVoxEntityProjectionFactory<Product, ProductPage>
{
public Task<ProductPage?> ProjectAsync(
Product product,
IVoxEntityProjectionContext context,
CancellationToken cancellationToken)
{
return Task.FromResult<ProductPage?>(new(
product.Id,
product.Name,
product.Description,
product.Price));
}
}
public record ProductCard(string Id, string Name, string Url);
[VoxEntityProjection("product-card", "1", IsDefaultForReferences = true)]
public class ProductCardProjection
: IVoxEntityProjectionFactory<Product, ProductCard>
{
public Task<ProductCard?> ProjectAsync(
Product product,
IVoxEntityProjectionContext context,
CancellationToken cancellationToken)
{
return Task.FromResult<ProductCard?>(new(
product.Id,
product.Name,
$"/products/{product.Id}"));
}
}
The name is stable and unique within the source entity type. The version describes the output contract and mapping;
change it when either changes. A factory returns one value or null. A collection of entities in different contexts
is composed outside the factory by invoking it once for each entity and context.
Projection output may contain ordinary values, collections, nested output models, and VoxRichText, but not Vox
entity types. Map entities to explicit output types so a projection cannot accidentally expose the complete stored
model.
Use context.Calculations to read other entities, relations, memberships, or custom dependencies. Those reads are
tracked when the projection is used by a durable publication. context.Contexts contains the caller's context
selection. To project another entity with the same calculation and application context, use context.ProjectAsync:
var category = await context.ProjectAsync(
categoryType,
product.Category.Id,
VoxEntityProjectionSelection.Named("menu-item"),
cancellationToken);
Omit the selection to use the referenced entity's reference default. IVoxEntityProjectionEvaluator provides the
same operation to application code outside a factory.
Defaults and explicit selection
Root requests and references have separate defaults because a full page model is rarely a safe nested model:
IsDefault = trueselects the projection for a direct/root request.IsDefaultForReferences = trueselects the projection when another projection or rich-text value references the entity.- An explicit projection name or output CLR type always wins.
- If an entity has only one projection, it is used for both cases.
- If no reference default is declared and exactly one projection is not the root default, Vox uses that projection for references. This avoids accidentally nesting the full projection.
- Any remaining ambiguity is an error. Vox never guesses between several full or compact representations.
There can be at most one root default and one reference default for an entity type.
Direct content delivery
Entity projections can be the API model consumed directly by an application:
GET /api/products/p123/projection
GET /api/products/p123/projection/product-card
The first route selects the root default. The second selects a projection by name. The response identifies the source
entity, selected projection and version, content scope and layer, and contains the projected value. Normal entity
read permissions apply.
Projection reads accept the same content and context query parameters as entity reads, together with
richTextFormat=Html|Json and expandReferences=true|false. Direct projection responses expand rich-text references
by default. Set AllowContentDelivery = false on VoxEntityProjectionAttribute for a projection that must only be
used internally or published to a sink.
Direct evaluation is ephemeral. It reads the requested published, draft, or release content but does not persist dependencies or publication state.
References and projection hints
An ordinary Vox relation can carry a projection hint without becoming a different kind of relation. This is useful for ordered CMS structures such as a page sidebar whose blocks can also be used by other pages:
public interface IPageBlock : IVoxEntityBase
{
}
public class Page : IVoxEntity
{
public virtual List<VoxEntityProjectionReference<IPageBlock>> Sidebar { get; set; } = [];
}
page.Sidebar.Add(new VoxEntityProjectionReference<IPageBlock>(
"summer-hero",
"HeroBlock",
"page-section"));
The entity type and ID are the relation identity. Projection is only a delivery hint. A projection factory or other
consumer can honor it when calling the projection evaluator; the relation remains queryable and indexed like any
other Vox relation. Rich text uses the same projections and selection rules; see
Rich text and entity references.
Optional publication
Add VoxEntityProjectionPublication when the same projection should also be maintained in an external system:
public record ProductSearchDocument(
string Id,
string Sku,
IReadOnlyDictionary<string, string> Names);
[VoxEntityProjection("product-search", "1", IsDefault = true)]
[VoxEntityProjectionPublication]
public class ProductSearchProjection
: IVoxEntityProjectionFactory<Product, ProductSearchDocument>
{
public Task<ProductSearchDocument?> ProjectAsync(
Product product,
IVoxEntityProjectionContext context,
CancellationToken cancellationToken)
{
return Task.FromResult<ProductSearchDocument?>(new(
product.Id,
product.Sku,
product.Names));
}
}
Exactly one IVoxEntityProjectionPublicationSink<TProjection> is required for a published projection. Its session
receives pages of upserts and deletes through WriteAsync; CompleteAsync is the external commit point. Delivery is
at least once, so sinks should be idempotent by source entity ID. A failed sink leaves the source pending for retry
without blocking entity saves, settlement, or events.
Durable publication consumes published content only. Draft and release content remains available through direct delivery without leaking into an outward-facing index or feed.
Context-scoped publication
The publication orchestrator can evaluate the same factory once for each selected context:
[VoxEntityProjection("channel-products", "1")]
[VoxEntityProjectionPublication(
ContextType = typeof(Channel),
ContextIds = ["app", "web"])]
public class ChannelProductProjection
: IVoxEntityProjectionFactory<Product, ChannelProductDocument>
{
public Task<ChannelProductDocument?> ProjectAsync(
Product product,
IVoxEntityProjectionContext context,
CancellationToken cancellationToken)
{
var channelId = context.Contexts.GetContextId(typeof(Channel))!;
return Task.FromResult<ChannelProductDocument?>(new(
product.Id,
product.Sku,
channelId));
}
}
Omit ContextIds to create a target for every stored entity of the context type. The matching sink implements
IVoxContextEntityProjectionPublicationSink<TContext, TProjection>. Each context target has independent versions,
dependencies, pending work, and errors. Contexts are orchestration around a single-value factory; there is no
collection projection factory.
Incremental and full builds
Published entity, relation, membership, and dependency changes append to the shared dependency journal. Matching source IDs are coalesced before the generated Nexus job evaluates them, so several changed dependencies cause one evaluation of a source rather than one evaluation per change. See Calculations.
A full build visits every current source and removes stale outputs. The first build must be full. Changing the projection version requires another successful full build before incremental publication resumes, preventing an external target from mixing old and new contracts.
Inspect registered publications and their state with:
GET /api/entity-projection-publications
Start Vox entity projection publication build in the Nexus UI for an explicit build:
ProjectionName: Product/product-search
Kind: Full
ContextId: app
The publication key combines the source entity type and projection name because names are scoped to an entity type.
Leave ContextId empty to build every configured context sequentially. The manual job and automatic incremental job
share the publication's Nexus mutex, so they cannot overlap.