ObjectSpace, ORM or Serializer: Which Problem Does Each Solve?

"Persistence" covers several separate jobs. Something has to turn objects into bytes, something has to find one object without reading all of them, and something has to decide when a group of changes becomes durable. A serializer, an ORM over a relational database and an ObjectSpace each take on a different subset of those jobs.

This article compares the three by the responsibilities they own and the ones they leave to your code. It then shows where Sphere10 Framework's ObjectSpace sits between the other two, using the file-backed store from the embedded object database tutorial .

Different storage responsibilities represented by an object store, mappings and serialized records.
Different storage responsibilities represented by an object store, mappings and serialized records.
💡 In short. Compare a serializer, an ORM over a relational database and ObjectSpace by the responsibilities each owns: byte representation, keyed lookup, relationships, transactions and object identity.

1. A first look

Here is the ObjectSpace side of the comparison in code. An ObjectSpace stores CLR objects directly, one dimension per registered type, and you register each type and its access paths with ObjectSpaceBuilder.

C#
builder.AddDimension<Purchase>(ignoreAnnotations: true)
	.WithIdentifier(item => item.Id)
	.WithUniqueIndexOn(item => item.Reference)
	.WithIndexOn(item => item.CustomerId)
	.OptimizeAssumingAverageItemSize(256)
	.Done();

Identifier and unique indexes answer Get and TryGet by member. A nonunique index returns the slots that share a key, which the sample wraps in a small helper named IndexQueries.By.

C#
var customer = reopened.Get((Customer item) => item.Email, "[email protected]");
var purchases = IndexQueries.By(reopened, (Purchase item) => item.CustomerId, customer.Id);

The declaration lists the members that can be looked up, and each lookup then goes through one of them. The sections that follow set this against a serializer and an ORM before returning to ObjectSpace in detail.

2. Three layers, three sets of responsibilities

All three start from the same application objects. They differ in what they produce and in what sits underneath them.

The table lists who owns each job.

Job Serializer ORM + database ObjectSpace
Byte format Yes Database engine Yes, through its serializers
Lookup by key Your code Database indexes Declared member indexes
Ad hoc queries and joins Your code SQL Your code
Links between records Your code Foreign keys and cascades Your code
Atomic commit of several changes Your code Database transaction Commit and rollback on a file store
Object identity while loaded Your code ORM change tracker Built-in identity tracking
Ships as A library A database engine, embedded or server A library and one file

3. A serializer defines the bytes

A serializer converts an object graph to a byte sequence and back. That is its whole contract. System.Text.Json is the familiar example, and the Framework's own binary serializers perform the same role with a different encoding.

C#
File.WriteAllText(path, JsonSerializer.Serialize(snapshot));
var loaded = JsonSerializer.Deserialize<Snapshot>(File.ReadAllText(path));

Everything after the bytes exist is application code. Finding a record means loading the document and searching it. Changing one record means writing the whole document again. Making that write safe means choosing a replacement, backup and locking policy yourself.

For a small document that is loaded and saved as a unit, this is often the right design and nothing more is needed. It stops fitting when the data grows past what you want to load at once, or when lookups by key become frequent enough that you start maintaining your own indexes.

4. An ORM works with a relational database

A relational database stores rows in tables and adds a query engine, constraints and transactions. An ORM maps application objects onto those tables and usually adds change tracking and query construction. SQLite shows that a relational engine can itself be embedded, so "embedded" does not distinguish the options.

The database owns the transaction boundary. Every command that belongs to one unit of work has to run inside it.

C#
using var transaction = connection.BeginTransaction();
try {
	UpdateParent(connection, transaction);
	InsertChild(connection, transaction);
	transaction.Commit();
} catch {
	transaction.Rollback();
	throw;
}

Choose this path when the workload needs joins, reporting, declarative constraints or an existing SQL estate. The cost is the mapping layer between the object model and the relational schema, plus the database engine as a dependency.

5. An ObjectSpace retains indexed typed records

An ObjectSpace stores CLR objects directly, one dimension per registered type, and maintains an index for each member you declare. There is no mapping layer and no query language. The first look showed both halves of this, a dimension registered with its indexes and lookups made by member.

Each lookup names the index it uses. This is an explicit access path chosen by the developer, in contrast to a query planner that selects one for you. The space tracks the objects it has loaded, so the same record always resolves to the same instance and a later Save updates it in place.

C#
var first = space.Get((Purchase item) => item.Reference, "PO-0100");
var second = space.Get((Purchase item) => item.Id, 100L);
SampleChecks.Require(ReferenceEquals(first, second), "Two lookups returned different instances.");

Internally an ObjectSpace uses serializers for the byte representation of each object. Done() registers an automatic one for a type that has none. The serializer question therefore does not go away. It is answered once, in the schema.

6. Where the transaction boundary lives

A FileObjectSpace writes through a transactional stream. Save stages changes in pending pages, Commit() publishes them and Rollback() discards them, for every dimension at once.

C#
// Save stages the objects, and Commit publishes them.
space.Save(new Customer { Id = 1, Email = "[email protected]", Region = 7, Name = "Ana" });
space.Save(new Purchase { Id = 100, Reference = "PO-0100", CustomerId = 1, Total = 125m });
space.Save(new Purchase { Id = 101, Reference = "PO-0101", CustomerId = 1, Total = 40m });
space.Commit();

A change that has been saved but not committed can still be discarded.

C#
// Stage a change, then discard it.
first.Total = 999m;
space.Save(first);
space.Rollback();

// Rollback reloads the space, so fetch the object again.
var restored = space.Get((Purchase item) => item.Reference, "PO-0100");
SampleChecks.Require(restored.Total == 125m, "Rollback kept the staged change.");

This is the capability a bare serializer lacks and a relational database supplies in its own form. Three details of the ObjectSpace version matter in practice.

  • Disposing a file space does not commit. Uncommitted changes are discarded.
  • Rollback() reloads the space, so every object fetched before it is stale and must be fetched again.
  • Commit and rollback are synchronous, and a store must be created, used and disposed on one thread.

The commit and rollback tutorial works through these in code.

7. Choose the responsibilities you want to own

Requirement Starting point
Encode a message, or a small document saved as a whole Serializer
Joins, reporting, declarative constraints, existing SQL Relational database, optionally with an ORM
Typed objects in a local file with keyed lookup and atomic commit ObjectSpace

The options combine. A service can keep shared records in a relational database and use an ObjectSpace for a local working set, and an ObjectSpace always contains serializers.

â„šī¸ ObjectSpace does not provide foreign keys, cascading deletes, LINQ or SQL queries, or automatic schema migration. A member such as CustomerId is a plain value, and any rule that relates two records is application code.

8. Get the package and run the sample

Add the package to a .NET 10 project.

Bash
dotnet add package Sphere10.Framework

The complete MIT-licensed sample is the file-backed store used in these excerpts. It targets .NET 10 and the Sphere10.Framework package.

Bash
dotnet run -c Release -- ./fresh-data
Plain text
Same record returns the same tracked instance: passed
Rollback discarded the staged change: passed
Indexed lookup after reopen: 2 purchases for [email protected]

The ObjectSpace guide documents the full API, and the indexed-query tutorial covers the lookup helper.


sample.zip
Download the complete worked example and build instructions (ZIP).

For the broader Sphere 10 development toolkit, see the Sphere10 Framework overview .

Author: Herman Schoenfeld