How to Build a Disk-Backed C# Collection

A list is a convenient interface until its contents need to survive the process. At that point, applications often grow a second representation: objects in memory, a serialized file on disk, and custom code to keep the two in agreement. A stream-mapped collection gives those responsibilities a defined home. You still add, update and read typed items, while the Framework maps their encoded records onto a backing stream.

In this example we store a small set of readings in a file, update one value and read a selected range. We then close the file completely and construct another collection over it. That final step matters because reading the value from the same live instance would demonstrate an update, but would not prove that the stored representation can be reopened.

A disk-backed collection retaining a small working set in memory.
A disk-backed collection retaining a small working set in memory.

Contents

Choose the Storage Contract

Sphere10 provides several collection families, so choose the one whose semantics fit the job. This article uses StreamMappedFactory.CreateList<int>, which composes an object stream, serializers and clustered storage. It is useful when the application wants normal mutable list behaviour over serialized records. A dictionary is a different choice when application keys, rather than positions, identify records. ObjectSpaces add dimensions and entity identity when the application needs an object-store model.

The backing stream in the example is a FileStream, but the collection is not coupled to a particular filename. A stream-backed container can also be useful inside a larger storage format. The important contract is that the underlying stream supports the operations the selected container requires. Do not substitute a forward-only network stream for seekable storage simply because both implement Stream.

Install the Package

Use the .NET 10 SDK and the package version tested with this example:


dotnet new console -n DiskBackedCollection --framework net10.0
dotnet add DiskBackedCollection package Sphere10.Framework --version 3.1.3

The downloadable project already contains the pinned PackageReference. It uses explicit imports and nullable annotations without requiring nullable-reference flow analysis. Restore the package, replace the program with the example and use a disposable working directory. The sample deliberately recreates its own readings.bin, so it must not be pointed at an application data file.

Write, Update and Reopen


using System;
using System.IO;
using System.Linq;
using Sphere10.Framework;

namespace ArticleSample;

public static class Program {
	public static void Main(string[] args) {
		// Give this example a disposable directory, not an existing data file.
		var folder = args.Length > 0 ? args[0] : "collection-example";
		Directory.CreateDirectory(folder);
		var path = Path.Combine(folder, "readings.bin");
		using (var file = File.Open(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None)) {
			using var readings = StreamMappedFactory.CreateList<int>(file, clusterSize: 128, itemSerializer: PrimitiveSerializer<int>.Instance, autoLoad: true);
			foreach (var value in Enumerable.Range(0, 25))
				readings.Add(value);
			readings.Update(9L, 900);
			Console.WriteLine($"Saved count: {readings.Count}");
			Console.WriteLine($"Selected range: {string.Join(", ", readings.ReadRange(8L, 4L))}");
			file.Flush(flushToDisk: true);
		}

		// Reopen through a fresh object to verify the file rather than cached values.
		using (var file = File.Open(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) {
			using var reopened = StreamMappedFactory.CreateList<int>(file, clusterSize: 128, itemSerializer: PrimitiveSerializer<int>.Instance, autoLoad: true);
			if (reopened.Count != 25 || reopened.Read(9L) != 900)
				throw new InvalidDataException("The reopened collection differs.");
			Console.WriteLine($"Reopened value at 9: {reopened.Read(9L)}");
		}
	}
}

The primitive integer serializer fixes the encoded representation of each reading. clusterSize: 128 is a deliberate small demonstration setting, not a universal performance recommendation. Retain the original serializer, byte order and container construction choices when opening existing data. A different constructor setting is not an automatic migration of previously written bytes.

The first block owns the file and the typed list. The factory arranges the list's container ownership, while the surrounding using block closes the physical file after the list has finished. The second block opens the same file through fresh objects. Only the selected range is materialized for display, without copying every saved record into a new application list.

Check the Result


Saved count: 25
Selected range: 8, 900, 10, 11
Reopened value at 9: 900

The value at logical position nine changes from nine to nine hundred. ReadRange(8L, 4L) returns four values beginning at position eight, and the reopened collection sees the update. The explicit assertions turn an incorrect reopen into a failing run rather than a reassuring console message. Try adding another reading, reopening again and checking both its position and the new count.

Understand the Boundaries

ℹ️ File-backed storage is not the same thing as a transaction. This example has no commit/rollback protocol. Use the Framework's transactional collection or ObjectSpace facilities when a group of changes must be accepted or rejected together.

Likewise, a stream-mapped list is not a process-wide memory limit. Indexes, metadata, active objects, application buffers and the operating system all contribute to memory use. Measure the workload that matters instead of treating a small cluster size as proof of a low-memory application. Keep access serialized unless the selected collection and its surrounding ownership explicitly support the concurrency you intend.

Logical positions also have meaning. Because insertion or removal can affect later positions, an ordinary list position is not a permanent business identifier. If an order must retain its identity across edits, store an order identifier or use an indexed object dimension. Do not persist a list position elsewhere and silently assume it can never change.

Put It to Work

A useful next exercise is a local measurement catalogue that appends readings, displays a selected interval and exports only the range the user requests. Keep unit names and validation in the application, while the Framework owns typed storage and serialization. If the catalogue grows into several record types with identifiers and indexes, move on to the ObjectSpace tutorial rather than adding an improvised entity layer around the list.

Read the collection selection guide and inspect the stream-mapped factory . The Sphere10 Framework overview connects this storage layer to embedded object databases, Merkle integrity and application components.

Download the Example

Download the complete NuGet sample project. The archive contains the project, its model files and run instructions. Keep the pinned package version when reproducing the example.

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

Continue the Series

How to Build an Embedded Object Database in C#

How to Store Many Independent Streams Inside One File

How to Build a Persistent B+ Tree for Range Queries