Skip to main content

Share entities between Vox applications

Vox applications share entity contracts, not their CLR entity assemblies. The application that owns an entity keeps its ordinary IVoxEntity class, while every consumer generates its own IVoxExternalEntity class. Ownership therefore remains explicit and truthful in each application's code.

Export a contract​

An application with the Vox API exposes GET /api/contracts. Pass the stable name by which the consumer identifies the source, the namespace to generate in the consumer, and one or more entity types:

GET /api/contracts?source=pim&namespace=Shop.Cms.Entities&entity=Product&entity=Category

Referenced entity types and context types are included automatically. Save the response in the consuming project with a .vox.json suffix, for example Contracts/pim.vox.json.

The contract describes the serialized entity shape, field groups, enums, relations, varying values and query fields. It does not contain the owner's actions, permissions, drafts, principals or CLR namespaces.

Generate local external entities​

Include the downloaded contract as an AdditionalFiles item:

<ItemGroup>
<AdditionalFiles Include="Contracts\*.vox.json" />
</ItemGroup>

The source generator shipped with CommerceMind.Vox.Abstractions generates classes such as:

namespace Shop.Cms.Entities;

[VoxExternalSource("pim", "Product", "...")]
public partial class Product : IVoxSynchronizedExternalEntity
{
// Built-in, synchronization and contracted properties are generated.
}

AddVox() discovers the generated classes like handwritten entity classes. No reference to the PIM's entity assembly and no handwritten IProduct adapter are needed.

Add consumer-local calculated data​

Generated entity classes are partial. A consumer can add calculated field groups in its own code:

namespace Shop.Cms.Entities;

public partial class Product
{
public ProductPresentation Presentation { get; set; } = new();
}

public class ProductPresentation : VoxFieldGroup
{
public string? PageTitle { get; set; }
}

Register an IVoxCalculation<Product, ProductPresentation> to make the group calculation-owned. Contracted field-group types are generated with [VoxImportOwned], so only synchronized data changes them.

Prefer a separate CMS-owned entity referencing Product when data is editorial rather than derived. This keeps the ownership boundary clear when complete snapshots arrive from the PIM.

Updating a contract​

Contract manifests contain a deterministic revision. Adding a producer property is compatible with an older consumer: the older generated class ignores the unknown JSON property until its contract is refreshed. Type changes can be incompatible and should be deployed by updating consumers before the producer starts sending the changed representation.

Refresh the checked-in manifest when the consumer needs new fields. Because the manifest is source-controlled, its generated API changes are visible and reviewable with the application change that uses them.

Synchronize changes​

Create a subscription in the owning application with:

  • Payload: Entity snapshot for Vox sync
  • Source name: the same stable name used to download the contract, for example pim
  • Entity types: the locally-owned types to synchronize, for example Product and Category
  • Transport: webhook, RabbitMQ or Redis

Snapshot subscriptions intentionally require explicit, locally-owned entity types and consistent events. They don't allow field-group, relation or context filters, because skipping some changes would leave the consumer stale.

Each delivery contains the latest committed entity representation, not merely the version mentioned by the original change event. Retries therefore coalesce intermediate changes and converge on the producer's current state. Deletions are delivered as tombstones. Delivery is at least once, and the consumer ignores duplicate or older snapshots using producer metadata stored separately from its own Vox entity version.

After creating the subscription, choose Sync now in the subscriptions screen, or call:

POST /vox/api/subscriptions/{subscriptionId}/sync

This enqueues the current entities before ongoing changes continue through the same durable delivery path.

Webhooks​

The producer enables webhook subscriptions:

vox.AddWebhookSubscriptions();

Configure the subscription URL as the consumer's Vox endpoint and give it a secret:

https://cms.example/vox/api/entity-sync/pim

The consumer accepts that source and verifies the existing X-Vox-Signature HMAC:

.AddApi(options => options.ReceiveEntitySync("pim", configuration["PimSyncSecret"]!))

An unknown source returns 404, an invalid signature returns 401, and an incompatible snapshot returns 400 so it isn't mistaken for a transient delivery failure.

RabbitMQ​

Both applications configure the RabbitMQ subscription package. The producer creates an entity snapshot subscription whose exchange setting names an existing topic exchange. The consumer adds a durable queue and binding:

.AddRabbitMqSubscriptions(options =>
{
options.ConnectionString = configuration.GetConnectionString("rabbitmq");
options.ReceiveEntitySync(
source: "pim",
exchange: "vox.entity-sync",
queue: "cms.pim.entity-sync",
routingKey: "#");
})

The receiver acknowledges a snapshot only after Vox stores it. Transient failures are requeued; incompatible messages are rejected, allowing a configured RabbitMQ dead-letter exchange to capture them.

Redis​

The producer creates an entity snapshot subscription whose stream setting names the shared stream. The consumer joins its own consumer group:

.AddRedisSubscriptions(options =>
{
options.ConnectionString = configuration.GetConnectionString("redis");
options.ReceiveEntitySync(
source: "pim",
stream: "vox:pim:entity-sync",
consumerGroup: "cms");
})

Every CMS instance uses the same group, so each snapshot is applied once by one instance. Unacknowledged messages are claimed and retried after 30 seconds by default. Give each consuming application a different consumer group.

Contract revisions​

Snapshots include the deterministic revision of the exact entity set selected by the subscription. A different local revision is logged as a warning but doesn't reject the delivery: additive producer fields remain compatible because older consumers ignore unknown JSON properties. A genuinely incompatible body fails deserialization and is rejected or retried according to the transport.