Skip to main content

Singleton entities

Most entity types have an arbitrary number of identities. A singleton entity type has one identity owned by Vox. It is useful for settings, feature flags, calculation parameters, and other configuration that must not accidentally be created more than once.

A singleton is otherwise an ordinary Vox-owned entity. It has actions, field groups, validation, optimistic concurrency, history, permissions, relations, calculations, events, search indexing, read projections, and the same choice of SQL or Redis storage.

Declare one by implementing IVoxSingletonEntity instead of IVoxEntity:

[VoxHistory]
public class CommerceSettings : IVoxSingletonEntity
{
// The ordinary IVoxEntity built-in properties are omitted here.

[Range(0, 4)]
public virtual int RatingDecimalPlaces { get; set; } = 1;
public virtual bool RecommendationsEnabled { get; set; }
}

The generated create command has no Id. Vox assigns the entity type's canonical identity, and simultaneous create attempts target the same storage key, so the storage provider's atomic version check prevents duplicates.

var settings = await updater.CreateAsync(
new CommerceSettingsActions.Create
{
RatingDecimalPlaces = 1,
RecommendationsEnabled = true,
},
cancellationToken: cancellationToken);

A singleton type contains zero or one entity. Use GetAsync when the application requires it to exist, or TryGetAsync during initialization or when it is optional:

public class Checkout(IVoxSingletonStore<CommerceSettings> settings)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
var configuration = await settings.GetAsync(cancellationToken);
// GetAsync throws VoxEntityNotFoundException if it hasn't been created.
}
}

if (await settings.TryGetAsync(cancellationToken) == null)
{
await updater.CreateAsync(new CommerceSettingsActions.Create
{
RatingDecimalPlaces = 1,
RecommendationsEnabled = true,
}, cancellationToken: cancellationToken);
}

Updates and deletion use ID-less overloads. Version and field-group concurrency work exactly as for other Vox-owned entities:

settings = await updater.UpdateAsync(
settings.Version,
new CommerceSettingsActions.SetRecommendationsEnabled
{
RecommendationsEnabled = false,
},
cancellationToken: cancellationToken);

await updater.DeleteAsync<CommerceSettings>(
settings.Version,
cancellationToken: cancellationToken);

Add [VoxNoDelete] when configuration must not be deleted after it is created.

Calculations and dependencies​

The calculation context has matching parameterless methods:

var settings = await context.GetAsync<CommerceSettings>(cancellationToken);
var optional = await context.TryGetAsync<OptionalSettings>(cancellationToken);

Both track the singleton like any other entity: its existence and the properties actually read. TryGetAsync also records the missing entity, so creating it later schedules the calculation or read projection again. Changing a property schedules only consumers that read it, while deletion schedules every consumer of that singleton.

API​

A singleton is a singular resource and does not expose its internal ID in its routes:

GET /api/commerce-settings
POST /api/commerce-settings
POST /api/commerce-settings/actions/{actionName}
DELETE /api/commerce-settings?version=3

History and relations also omit the ID, for example /api/commerce-settings/versions and /api/commerce-settings/relations/{relationName}. /api/entity-types reports isSingleton: true, allowing a generic admin UI to open the editor directly or show the create form when the entity is absent.

The entity still contains its built-in Id in stored data, events, projections, and serialized entity JSON. That is an implementation identity used by Vox infrastructure; application code should use the ID-less APIs.

Import-owned field groups​

A typed import may own a field group of a singleton just as it may on any Vox-owned entity. Such an import must not declare [VoxImportEntityId], because Vox already knows its target:

public class ErpSettingsImport : IVoxImport<CommerceSettings, CommerceErpSettings>
{
public virtual decimal DefaultVatRate { get; set; }
}

Concurrent changes to other field groups do not prevent the import from being retried and applied to its own group.