How to Build an Embedded Object Database in C#
An application often needs more than a saved settings file but less than a separate database service. Consider a small inventory utility that must retain products, reject ambiguous identifiers and find a product by SKU after the application closes. Writing a JSON file is straightforward, but maintaining indexed records and a durable update boundary quickly becomes another storage project.
Sphere10 Framework's ObjectSpace provides a typed embedded store over ordinary streams. You register the application types, declare the indexes that matter and select a memory or file backend. In this tutorial, we build a two-product catalogue, commit it, dispose the first store and retrieve a product from a newly opened instance. That final reopen is important because it proves that the result came back from committed storage rather than an object that remained in memory.
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 ObjectSpaceCatalogue
cd ObjectSpaceCatalogue
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. Use a new writable output directory. The sample refuses to overwrite an existing store rather than silently clearing earlier data.
Define the Product Records
The application needs a business identifier, a unique SKU, a name, a category and a price. The application supplies the business identifier. Use a public parameterless model with readable and writable members so the automatic serializer can assemble its layout.
Save this 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; }
}
An ObjectSpace dimension is a registered CLR type and its storage configuration. It is not a SQL table with an arbitrary query translator attached. Here, the Product dimension has an identifier index, a unique SKU index and a nonunique category index. Those declarations make the supported lookup paths explicit before we write data.
Configure a File-Backed ObjectSpace
Save the storage factory as CatalogueStore.cs:
using System.IO;
using Sphere10.Framework;
using Sphere10.Framework.ObjectSpaces;
namespace ArticleSample;
public static class CatalogueStore {
public static FileObjectSpace Open(string directory) {
Directory.CreateDirectory(directory);
Directory.CreateDirectory(Path.Combine(directory, "pending-pages"));
var builder = new ObjectSpaceBuilder()
.UseFile(Path.Combine(directory, "catalogue.s10"))
.KeepUncommittedPagesAt(Path.Combine(directory, "pending-pages"))
.WithClusterSize(4096)
.WithPageSize(64 * 1024)
.WithMaxMemory(4 * 1024 * 1024)
.WithIndexBTreeOrder(64)
.WithEndianness(Endianness.LittleEndian)
.AutoLoad();
builder.AddDimension<Product>(ignoreAnnotations: true)
.WithIdentifier(item => item.Id)
.WithUniqueIndexOn(item => item.Sku)
.WithIndexOn(item => item.Category)
.OptimizeAssumingAverageItemSize(256)
.Done();
return (FileObjectSpace)builder.Build();
}
}
The file backend uses a staging directory for transactional pages. Create that directory as well as the catalogue directory. The cluster size, index order, byte order and serialized model form part of the storage contract, so the same factory is used when opening the file again. The page size and cache budget shown here are demonstration settings, not a benchmark result or a universal optimum.
ignoreAnnotations: true tells this example to use the fluent declarations rather than combine them with attributes on the model. Done() completes the dimension registration. AutoLoad() loads the file backend when it is built. Business-data saving and transaction commit remain explicit operations.
Save, Commit and Open It Again
The following Program.cs inserts two products and explicitly commits the file. It then creates a second ObjectSpace using the same factory and performs a unique SKU lookup:
using System;
using System.IO;
using Sphere10.Framework.ObjectSpaces;
namespace ArticleSample;
public static class Program {
public static void Main(string[] args) {
var directory = args.Length == 0 ? Path.Combine(Environment.CurrentDirectory, "sample-data") : args[0];
if (File.Exists(Path.Combine(directory, "catalogue.s10")))
throw new InvalidOperationException("Choose a fresh output directory for this insertion example.");
using (var space = CatalogueStore.Open(directory)) {
space.Save(new Product { Id = 101, Sku = "KB-101", Name = "Compact keyboard", Category = 7, Price = 89m });
space.Save(new Product { Id = 102, Sku = "MS-102", Name = "Vertical mouse", Category = 7, Price = 65m });
space.Commit();
}
using (var reopened = CatalogueStore.Open(directory)) {
var product = reopened.Get((Product item) => item.Sku, "KB-101");
var count = reopened.Count<Product>();
if (count != 2 || product.Id != 101 || product.Price != 89m)
throw new InvalidOperationException("Reopened catalogue differs from committed data.");
Console.WriteLine($"Persisted products: {count}");
Console.WriteLine($"SKU KB-101: {product.Name}");
Console.WriteLine("Reopen check: passed");
}
}
}
Run the application against a fresh directory:
dotnet run -c Release -- ./fresh-catalogue
The expected output is:
Persisted products: 2
SKU KB-101: Compact keyboard
Reopen check: passed
The sample checks both the saved record count and fields retrieved through the SKU index. Every record in this tiny example has been saved, so the count is an appropriate check. In a larger application, distinguish saved records from provisional tracked instances and distinguish live records from physical slots that have been recycled. Do not assume storage positions are always the integers from zero to the live count minus one.
Make the Storage Boundary Explicit
Use Save to record the object's current persisted members, then call Commit to publish the file transaction. Closing an editor window or seeing a changed object through the identity tracker is not the same evidence as reopening committed bytes. The companion transaction tutorial makes that distinction visible with an accepted price and a rejected edit.
ObjectSpace is most useful when an embedded, application-owned object store fits the problem. Relational joins, an SQL server's operational features or unrestricted multi-process access are different requirements. Current ObjectSpace sessions also have a lifetime thread-affinity contract that requires you to create, use and dispose of each session on its owner thread. A desktop application can dispatch commands to a dedicated worker instead of moving the same live store between unrelated thread-pool calls.
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.
Next, use the indexed-query tutorial to retrieve all products in a category, then add explicit commit and rollback to an editing workflow.
Useful references:
-
ObjectSpace guide
-
Core storage and collections
-
Sphere10 Framework on NuGet
-
Explore Sphere10 Framework
Continue the Series
How to Query Objects Without Scanning Every Record
How to Give Your C# Objects Commit and Rollback
How to Build a Self-Describing Object Store