Schema Design Patterns
This page covers common patterns for structuring your entity kinds effectively.
Multi-service field ownership
Section titled “Multi-service field ownership”When multiple services need to write to the same entity kind, each service should own a distinct set of fields and use PATCH to update only its fields. This avoids overwrites and lets subscribers see the fully merged state.
[ConvergenceEntity("OrderView")]public partial struct OrderView{ // Order service owns these (SourceId = 1) [Field(0)] public ulong OrderId { get; set; } [Field(1)] public ulong CustomerId { get; set; } [Field(2)] public uint ItemCount { get; set; }
// Payment service owns these (SourceId = 2) [Field(3, MaxLength = 16)] public string PaymentStatus { get; set; } [Field(4)] public ulong AmountCents { get; set; }
// Shipping service owns these (SourceId = 3) [Field(5, MaxLength = 16)] public string ShippingStatus { get; set; } [Field(6, MaxLength = 64)] public string TrackingNumber { get; set; }
public ReadOnlyMemory<byte> EntityId { get; init; }}The order service uses AssertAsync to write the full entity. The payment and shipping services use PatchAsync to update only their fields:
// Payment serviceawait orders.PatchAsync(new OrderViewPatch{ EntityId = orderId, PaymentStatus = "charged", AmountCents = 4999,});Each service uses a different SourceId, so their assertions are tracked independently. See Multi-Source Convergence for details.
Entity ID strategies
Section titled “Entity ID strategies”Entity IDs are 32 bytes. The EntityId helper class provides several derivation strategies:
| Method | Use when |
|---|---|
EntityId.FromGuid(guid) | You have a natural GUID identifier (user IDs, session IDs). |
EntityId.FromLong(n) | You have a numeric identifier (database row IDs, sequence numbers). |
EntityId.FromString(s) | You want to derive an ID deterministically from a string key (SHA-256). |
Deterministic IDs are useful when multiple services must independently produce the same entity ID for the same logical entity without coordination. For example, EntityId.FromString("order-12345") always produces the same 32-byte ID.
Denormalization
Section titled “Denormalization”ConvergeDB has no joins. If you need data from multiple entity kinds in a single view, denormalize the data into a single entity kind.
For example, instead of a separate User kind and Order kind linked by a foreign key, create an OrderView kind that includes the user’s name directly:
[ConvergenceEntity("OrderView")]public partial struct OrderView{ [Field(0)] public ulong OrderId { get; set; } [Field(1, MaxLength = 64)] public string CustomerName { get; set; } [Field(2)] public uint ItemCount { get; set; } [Field(3)] public ulong TotalCents { get; set; }
public ReadOnlyMemory<byte> EntityId { get; init; }}Use PATCH to update the customer name when it changes, so subscribers always see the current name alongside the order data.
Arrays vs. separate entities
Section titled “Arrays vs. separate entities”When modelling a one-to-many relationship, you have two options:
Use an array field when:
- The collection is small (under the 1024-element limit).
- The collection is always read and written together.
- You want subscribers to receive the full collection in each notification.
Use a separate entity kind when:
- The collection can grow beyond 1024 elements.
- Individual items are updated independently by different sources.
- Subscribers care about changes to individual items, not the entire collection.
For example, a character’s inventory with up to 50 slots fits well as an array field. A marketplace with thousands of listings should be a separate entity kind.
Planning for field limits
Section titled “Planning for field limits”Each entity kind supports a maximum of 64 top-level fields. This is a hard constraint imposed by the u64 presence mask used for PATCH operations and change tracking.
If you anticipate needing many fields:
- Group related data into
[ConvergenceStruct]fields. Each struct counts as one top-level field, regardless of how many sub-fields it contains. - Use array fields for collections instead of individual fields per item.
- Split the entity into multiple kinds if the fields represent distinct concerns with different update patterns.
In-memory vs. disk-backed storage
Section titled “In-memory vs. disk-backed storage”By default, all entity kinds are stored entirely in memory. This delivers the best latency (sub-5us point reads) but means memory usage scales linearly with entity count.
For kinds with large populations and a small hot working set, disk-backed storage trades latency on cold reads for dramatically lower memory consumption.
Use in-memory (default) when:
- The kind has fewer than a few hundred thousand entities.
- Low-latency reads are critical (real-time game state, live dashboards).
- Most entities are read or written frequently.
Use disk-backed when:
- The kind has millions of entities but only a fraction are active at any time.
- You can tolerate 50-200us latency on cache misses.
- Memory savings are more important than worst-case read latency.
- The kind is write-heavy with infrequent reads (e.g., terrain data, historical records, configuration entries).
Example: game terrain
Section titled “Example: game terrain”A game world with 10 million terrain chunks but only ~50K visible to players at any time:
[ConvergenceEntity("TerrainChunk", DiskBacked = true, CacheCapacity = 100_000)]public partial struct TerrainChunk : IConvergenceEntity<TerrainChunk>{ [Field(0)] public uint ChunkX { get; set; } [Field(1)] public uint ChunkY { get; set; } [Field(2)] public byte BiomeType { get; set; } [Field(3)] public ushort Elevation { get; set; }
public ReadOnlyMemory<byte> EntityId { get; init; }}Without disk-backed storage, 10M terrain chunks would consume several GB of RAM. With DiskBacked = true and a 100K cache, only the active chunks are in memory. The cache adapts automatically if more chunks become active.
The cache grows, it does not shrink. If a burst of activity temporarily expands the working set, the cache grows to accommodate it and stays at that size. This prevents thrashing (repeatedly evicting and reloading the same entities) at the cost of not reclaiming memory after the burst subsides. If memory reclamation is important, restart the server to reset cache sizes.
Storage mode is permanent. Once a kind is registered as disk-backed (or in-memory), it cannot be changed without re-creating the kind. Plan your storage mode at design time.