How to Build a Self-Describing Object Store
Persisting an object is only part of reopening it later. A storage engine also needs to know the serializer registrations, dimensions, indexes and settings that give the bytes meaning. Keeping that information entirely outside the file can make a portable data container awkward because the application must carry a matching construction recipe wherever the data goes.
Sphere10 Framework can store supported ObjectSpace construction metadata alongside its data. A self-describing store records the configuration needed to reconstruct the normal builder and loading path. In this example, we create a memory-backed Product store, save and flush a record, dispose the original store, reset the stream and reopen it without supplying a fresh ObjectSpace definition. The program retains the Product type and supplies the original byte order because metadata reconstruction still depends on those reader-side contracts.
Contents
Prepare the Example
Use the .NET 10 SDK and Sphere10 Framework 3.1.3. In a new console project, add the core package:
dotnet new console --framework net10.0 --name SelfDescribingStore
cd SelfDescribingStore
dotnet add package Sphere10.Framework --version 3.1.3
Replace the generated source with the named files below, or download the complete example at the end. The package supplies the storage, serialization and collection mechanisms, while the small model and workflow belong to this application. This example uses an isolated in-memory dataset, so running it does not alter an existing application store.
Retain the Application Type
Save this model as Product.cs:
namespace ArticleSample;
public sealed class Product {
public long Id { get; set; }
public string Sku { get; set; }
public string Name { get; set; }
public int Category { get; set; }
public decimal Price { get; set; }
}
The file's metadata can describe the registered type and its serializers, but it does not make arbitrary application code appear in another process. The process that opens this sample must be able to resolve the Product CLR type and the supported serializer implementations. Keep the matching application or data-contract assembly with the data's intended reader.
That distinction is useful when designing an export format. A portable store can retain its index and serialization recipe, while the application still owns the compatibility policy for its model versions. Decide which readers may open the data and how future model revisions will be handled, including any explicit schema migration.
Enable Metadata When Creating the Store
Save the complete round-trip example as Program.cs:
using System;
using System.IO;
using Sphere10.Framework;
using Sphere10.Framework.ObjectSpaces;
namespace ArticleSample;
public static class Program {
public static void Main() {
using var raw = new MemoryStream();
var builder = new ObjectSpaceBuilder()
.StoreMetaData()
.UseMemoryStream(raw)
.WithEndianness(Endianness.LittleEndian);
builder.AddDimension<Product>(ignoreAnnotations: true)
.WithIdentifier(item => item.Id)
.WithUniqueIndexOn(item => item.Sku)
.Done();
using (var created = builder.Build()) {
created.Save(new Product { Id = 101, Sku = "KB-101", Name = "Portable keyboard", Category = 7, Price = 89m });
created.Flush();
}
raw.Position = 0;
if (!ObjectSpace.TryOpenFromStream(raw, Endianness.LittleEndian, out var reopened))
throw new InvalidOperationException("Self-describing metadata could not be opened.");
using (reopened) {
var product = reopened.Get((Product item) => item.Sku, "KB-101");
if (product.Id != 101 || product.Name != "Portable keyboard")
throw new InvalidOperationException("Reconstructed store returned different data.");
Console.WriteLine($"Reconstructed product: {product.Name}");
Console.WriteLine("Definition supplied at reopen: no");
Console.WriteLine("Self-describing round trip: passed");
}
Console.WriteLine($"Caller still owns stream: {raw.CanRead}");
}
}
StoreMetaData() enables the metadata trait before the new store is created. The ObjectSpace stores its serializer factory once, and its dimension metadata records the relevant item and index settings. The generic clustered-stream layer holds an opaque metadata allocation, while the application owns the Product schema.
The example uses a caller-owned expandable MemoryStream. It explicitly flushes the created ObjectSpace before disposing it. Setting raw.Position = 0 is also deliberate because the opener starts at the selected stream origin, rather than assuming that every caller is positioned at the start of a file.
Reconstruct and Verify the Saved Record
Run the sample:
dotnet run -c Release
The expected output is:
Reconstructed product: Portable keyboard
Definition supplied at reopen: no
Self-describing round trip: passed
Caller still owns stream: True
TryOpenFromStream scans the supported metadata, reconstructs the builder configuration and returns a loaded store. Notice that the reopen call has no new AddDimension<Product>() chain and no separately supplied ObjectSpace definition. The program verifies the restored product through its unique SKU index, showing that the construction metadata and the data work together.
The caller still supplies Endianness.LittleEndian. The opener validates that contract rather than probing different byte orders until one looks plausible. An unavailable or explicitly invalid metadata header can return false. I/O and deserializer failures can still propagate as exceptions. Design error handling around the actual failure categories instead of treating every failure as an empty store.
Choose a Metadata Budget Deliberately
The default metadata capacity is the exact encoded record size. A positive capacity reserves an explicit budget when the supported format and application need it. Unused reserved space is padding. Reserved capacity is not permission to add arbitrary dimensions later or rewrite an old file with a new schema. Preserve the original contract, and make migration or replacement an explicit operation.
When returning bytes from a memory store, also distinguish stream capacity from stored content. new MemoryStream(bytes) has a fixed backing buffer that can serve reads but cannot grow with mutable reopened data. Use an expandable stream for a mutable reopened store. Dispose the returned ObjectSpace before reopening or disposing the borrowed stream.
Finally, self-description is a convenience for known data formats, not a trust boundary. A reader should accept only the file sources, types and serializer registrations appropriate to its application. This tutorial verifies a controlled local round trip. Untrusted input still needs an application-specific validation and trust policy.
Continue Building
The complete sample project includes the project file, source, expected console output and MIT license notice. Code excerpts on this page are supplied under that same license. Keep the package version with your application and retain the sample's observable checks when adapting it because compilation alone does not prove persistence or correct query results.
Sphere10 Framework lets you compose this capability with the rest of your application rather than adopt an unrelated application shell. Start with the working example, change one schema or workload decision at a time, and verify the resulting behavior with your own records.
Continue with Merkleized storage when the application also needs an integrity checkpoint, or clustered streams when it needs a container of separately addressable payloads.
Useful references:
-
Self-describing ObjectSpaces and ObjectStreams
-
ObjectSpace construction guide
-
Sphere10 Framework on NuGet
-
Explore Sphere10 Framework
Continue the Series
How to Build an Embedded Object Database in C#
How to Add Cryptographic Integrity to an Object Store
How to Store Many Independent Streams Inside One File