Skip to main content

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 = true selects the projection for a direct/root request.
  • IsDefaultForReferences = true selects 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.