Custom Storage¶
Verified by tests
IPerspectiveStoreTests, IPerspectiveStoreDefaultsTests, EFCorePostgresPerspectiveStoreTests, DapperPostgresPerspectiveStoreTests — library CI run #31657041675 (2026-08-13)
Custom storage backends enable alternative data stores beyond PostgreSQL. Implement Redis, MongoDB, Elasticsearch, Cassandra, or custom databases for perspective read models.
:::note Whizbang ships PostgreSQL-backed stores (EF Core and Dapper implementations). Custom storage is for specialized scenarios requiring different persistence strategies. :::
Why Custom Storage?¶
| Backend | Use Case | Benefits |
|---|---|---|
| PostgreSQL (default) | Relational data | ACID, SQL queries |
| Redis | High-speed cache | In-memory, fast reads |
| MongoDB | Document store | Schema flexibility |
| Elasticsearch | Search/analytics | Full-text search |
| Cassandra | Time-series | Horizontal scaling |
When to use custom storage: - ✅ Specialized query patterns - ✅ Extreme performance needs - ✅ Existing infrastructure - ✅ Multi-region replication
IPerspectiveStore<TModel>¶
The storage abstraction is keyed by Guid stream IDs for single-stream perspectives and generic partition keys for multi-stream (global) perspectives. The core members:
IPerspectiveStore
namespace Whizbang.Core.Perspectives;
public interface IPerspectiveStore<TModel> where TModel : class {
// Single-stream perspectives (keyed by stream ID)
Task<TModel?> GetByStreamIdAsync(Guid streamId, CancellationToken cancellationToken = default);
Task UpsertAsync(Guid streamId, TModel model, CancellationToken cancellationToken = default);
// [PhysicalField]/[VectorField] split-column support
Task UpsertWithPhysicalFieldsAsync(
Guid streamId, TModel model,
IDictionary<string, object?> physicalFieldValues,
PerspectiveScope? scope = null,
CancellationToken cancellationToken = default);
// Multi-stream (global) perspectives (keyed by partition key)
Task<TModel?> GetByPartitionKeyAsync<TPartitionKey>(TPartitionKey partitionKey, CancellationToken cancellationToken = default)
where TPartitionKey : notnull;
Task UpsertByPartitionKeyAsync<TPartitionKey>(TPartitionKey partitionKey, TModel model, CancellationToken cancellationToken = default)
where TPartitionKey : notnull;
// Commit pending changes (SaveChangesAsync for EF Core; no-op if auto-committed)
Task FlushAsync(CancellationToken cancellationToken = default);
// Hard deletes (ModelAction.Purge) - idempotent
Task PurgeAsync(Guid streamId, CancellationToken cancellationToken = default);
Task PurgeByPartitionKeyAsync<TPartitionKey>(TPartitionKey partitionKey, CancellationToken cancellationToken = default)
where TPartitionKey : notnull;
// Plus default-implemented overloads: scope-aware upserts (PerspectiveScope,
// forceUpdateScope), metadata-persisting upserts (PerspectiveMetadata - used by
// generated runners for crash-safe idempotency), and GetMetadataByStreamIdAsync.
}
Key behavioral contracts (locked by the built-in stores' tests):
- Upsert semantics: create the row when missing, update when present, increment a version for optimistic concurrency
- Purge is idempotent: purging a non-existent model does not throw
FlushAsyncguarantees data is queryable beforePostPerspectiveInlinereceptors fire- Metadata overloads record the last-applied
EventIdper row so re-runs after a crash skip already-applied events
MongoDB Implementation¶
Pattern 1: MongoDB Perspective Store¶
Pattern 1: MongoDB Perspective Store
using MongoDB.Driver;
using Whizbang.Core.Perspectives;
public class MongoPerspectiveStore<TModel> : IPerspectiveStore<TModel>
where TModel : class {
private readonly IMongoCollection<TModel> _collection;
public MongoPerspectiveStore(IMongoDatabase database, string collectionName) {
_collection = database.GetCollection<TModel>(collectionName);
}
public async Task<TModel?> GetByStreamIdAsync(Guid streamId, CancellationToken cancellationToken = default) {
var filter = Builders<TModel>.Filter.Eq("_id", streamId);
return await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken);
}
public async Task UpsertAsync(Guid streamId, TModel model, CancellationToken cancellationToken = default) {
var filter = Builders<TModel>.Filter.Eq("_id", streamId);
await _collection.ReplaceOneAsync(
filter,
model,
new ReplaceOptions { IsUpsert = true },
cancellationToken
);
}
public Task UpsertWithPhysicalFieldsAsync(
Guid streamId, TModel model,
IDictionary<string, object?> physicalFieldValues,
PerspectiveScope? scope = null,
CancellationToken cancellationToken = default) =>
UpsertAsync(streamId, model, cancellationToken); // documents have no split columns
public async Task<TModel?> GetByPartitionKeyAsync<TPartitionKey>(
TPartitionKey partitionKey, CancellationToken cancellationToken = default)
where TPartitionKey : notnull {
var filter = Builders<TModel>.Filter.Eq("_id", partitionKey.ToString());
return await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken);
}
public async Task UpsertByPartitionKeyAsync<TPartitionKey>(
TPartitionKey partitionKey, TModel model, CancellationToken cancellationToken = default)
where TPartitionKey : notnull {
var filter = Builders<TModel>.Filter.Eq("_id", partitionKey.ToString());
await _collection.ReplaceOneAsync(
filter, model, new ReplaceOptions { IsUpsert = true }, cancellationToken);
}
public Task FlushAsync(CancellationToken cancellationToken = default) =>
Task.CompletedTask; // MongoDB writes are committed per operation
public async Task PurgeAsync(Guid streamId, CancellationToken cancellationToken = default) {
var filter = Builders<TModel>.Filter.Eq("_id", streamId);
await _collection.DeleteOneAsync(filter, cancellationToken); // idempotent
}
public async Task PurgeByPartitionKeyAsync<TPartitionKey>(
TPartitionKey partitionKey, CancellationToken cancellationToken = default)
where TPartitionKey : notnull {
var filter = Builders<TModel>.Filter.Eq("_id", partitionKey.ToString());
await _collection.DeleteOneAsync(filter, cancellationToken);
}
}
Elasticsearch Implementation¶
Pattern 2: Elasticsearch Perspective Store (Excerpt)¶
The same member set applies; here are the stream-keyed members with the Elasticsearch client:
Pattern 2: Elasticsearch Perspective Store
using Elastic.Clients.Elasticsearch;
using Whizbang.Core.Perspectives;
public class ElasticsearchPerspectiveStore<TModel> : IPerspectiveStore<TModel>
where TModel : class {
private readonly ElasticsearchClient _client;
private readonly string _indexName;
public ElasticsearchPerspectiveStore(ElasticsearchClient client, string indexName) {
_client = client;
_indexName = indexName;
}
public async Task<TModel?> GetByStreamIdAsync(Guid streamId, CancellationToken cancellationToken = default) {
var response = await _client.GetAsync<TModel>(
streamId.ToString(), idx => idx.Index(_indexName), cancellationToken);
return response.Found ? response.Source : null;
}
public async Task UpsertAsync(Guid streamId, TModel model, CancellationToken cancellationToken = default) {
await _client.IndexAsync(
model,
idx => idx.Index(_indexName).Id(streamId.ToString()),
cancellationToken
);
}
public async Task PurgeAsync(Guid streamId, CancellationToken cancellationToken = default) {
await _client.DeleteAsync<TModel>(
streamId.ToString(), idx => idx.Index(_indexName), cancellationToken);
}
// ... implement the partition-key members, UpsertWithPhysicalFieldsAsync,
// FlushAsync, and PurgeByPartitionKeyAsync following the MongoDB pattern.
}
Registration¶
Custom Store Registration
// Open-generic registration covers every perspective model type
builder.Services.AddSingleton(typeof(IPerspectiveStore<>), typeof(MongoPerspectiveStore<>));
Further Reading¶
Data Access: - Perspectives Storage - PostgreSQL schema
Extensibility: - Custom Perspectives - Advanced perspective patterns
Version 1.0.0 - Foundation Release | Last Updated: 2026-07-16