Blob storage
Store binary content behind provider-neutral contracts, with an optional PostgreSQL-backed implementation.
Elarion's blob packages give application handlers a small, provider-neutral storage contract while
keeping database-specific concerns in the host or infrastructure layer. Application code depends on
Elarion.Blobs; the host chooses a provider such as Elarion.Blobs.PostgreSql.
Use blob storage for files that should be addressed by reference from your domain model: generated
documents, uploads, exports, attachments, and other binary content. Store the returned BlobRef.Value
on your own entities rather than embedding file bytes in handler responses or domain rows.
To accept uploads from a browser — pre-upload a file, reference it when creating an entity, and reclaim it automatically if the entity is never created — see Blob uploads, which adds the pending/commit/TTL lifecycle and the tus and direct-upload HTTP transports on top of this storage contract.
Package split
| Package | Use it in | Contains |
|---|---|---|
Elarion.Blobs | Application code | IBlobStore, BlobRef, BlobUploadRequest, BlobDownload, BlobMetadata, BlobContent, listing (BlobListRequest/BlobListing), and BlobStoreExtensions. No provider dependency. |
Elarion.Blobs.PostgreSql | Infrastructure / host | PostgreSqlBlobStore<TDbContext>, DI registration, and EF Core model configuration for PostgreSQL. |
<ItemGroup>
<PackageReference Include="Elarion.Blobs" Version="0.2.6" />
<PackageReference Include="Elarion.Blobs.PostgreSql" Version="0.2.6" />
</ItemGroup>If your solution separates application and infrastructure projects, put Elarion.Blobs in the
application project and Elarion.Blobs.PostgreSql only where the concrete DbContext is configured.
Application contract
IBlobStore is streaming-first: content flows in and out as a Stream, so neither callers nor
backends are forced to buffer a whole blob in memory. The shape mirrors the major blob SDKs (AWS S3,
Azure Blob, Google Cloud Storage) so an alternative backend slots in cleanly. Inject IBlobStore
into handlers or services that need binary storage:
using Elarion.Abstractions;
using Elarion.Blobs;
public sealed class UploadAttachment(IBlobStore blobs)
: IHandler<UploadAttachment.Command, Result<UploadAttachment.Response>> {
public sealed record Command(string FileName, string ContentType, byte[] Data);
public sealed record Response(string BlobId);
public async ValueTask<Result<Response>> HandleAsync(Command command, CancellationToken ct) {
var blobRef = await blobs.SaveAsync(
new BlobUploadRequest {
Container = "attachments",
Name = command.FileName,
ContentType = command.ContentType
},
command.Data,
ct);
return new Response(blobRef.Value);
}
}The identity and metadata of a blob ride in a BlobUploadRequest (mirroring S3 PutObjectRequest /
Azure BlobUploadOptions), and the content is supplied separately. The core interface takes a
Stream; the byte[] overload above is one of the conveniences in BlobStoreExtensions. When you
already hold a stream — an incoming upload, for example — pass it straight to the core method:
await blobs.SaveAsync(request, uploadStream, ct);BlobUploadRequest.ContentLength is an optional hint; the recorded BlobMetadata.Size is always the
actual number of bytes written, so you can leave it null for unknown-length sources. A store may use
the hint to optimize the write — the PostgreSQL store streams a non-seekable source straight into
bytea when the hint is present (verifying it against the actual bytes), and only buffers to learn the
length when it is absent.
The core IBlobStore is intentionally small:
| Method | Purpose |
|---|---|
SaveAsync(BlobUploadRequest, Stream) | Streams content in and returns a BlobRef. |
OpenReadAsync | Opens a disposable BlobDownload carrying metadata plus an open content stream. |
GetMetadataAsync | Loads metadata without content. |
ExistsAsync | Checks whether a reference exists. |
DeleteAsync | Deletes a referenced blob. |
ListAsync(BlobListRequest) | Lists one page of blobs — optionally rolled up into virtual directories by a delimiter (see Listing). |
ListContainersAsync | Lists the containers known to the store. |
BlobStoreExtensions layers the ergonomic call styles over those primitives, so every backend gets
them for free:
| Extension | Purpose |
|---|---|
SaveAsync(BlobUploadRequest, byte[]) | Stores an in-memory byte array. |
SaveFromFileAsync | Stores content from a local file path (opened as a stream, no backend file assumption). |
DownloadContentAsync | Loads metadata plus bytes into a BlobContent. |
ReadAllBytesAsync | Loads just the content bytes. |
DownloadToAsync | Copies content to a destination stream (for example an HTTP response body). |
ListAllAsync | Enumerates every blob under a prefix as an IAsyncEnumerable, walking ListAsync page by page — the flat enumeration migration/backup tooling wants. |
The PostgreSQL implementation replaces an existing blob when container and name match, preserving
the blob id while updating content type, size, timestamp, and bytes.
Listing: prefixes as virtual directories
Listing follows the industry model shared by S3 (ListObjectsV2), Azure (GetBlobsByHierarchy), and
GCS: a flat namespace with prefix + delimiter emulation of hierarchy. Pass a Delimiter
(typically "/") and names containing it beyond the Prefix roll up into BlobListing.Prefixes —
one delimiter-inclusive entry per virtual directory — while only blobs at the current level appear in
BlobListing.Blobs:
var root = await blobs.ListAsync(new BlobListRequest { Container = "uploads", Delimiter = "/" }, ct);
// root.Prefixes: ["user-1/", "user-2/"] root.Blobs: blobs directly in the container
var level = await blobs.ListAsync(
new BlobListRequest { Container = "uploads", Prefix = "user-1/", Delimiter = "/" }, ct);Entries come back in lexicographic (ordinal) name order, paged via an opaque, store-specific
ContinuationToken (null = exhausted). A missing container yields an empty page. BlobMetadata
carries the blob's lifecycle State, and BlobListRequest.State filters on it, so a browse surface
can hide half-finished pending uploads — on Azure that filter is applied per page after listing
(metadata cannot be filtered server-side), so a filtered page may hold fewer than PageSize items
while more remain; loop on the token, not on page fill.
Directories are never real objects — there are no empty folders and no subtree renames. An application
that needs true folder semantics (rename, per-folder permissions) models folders as entities in its
own database and keeps blobs flat. Listing is a browse/ops surface (admin UIs, migration and
backup tooling): an application answers "which files belong to this contract" from its own tables,
which hold the BlobRefs — it does not enumerate the store. For the same reason, never derive
ownership from a name prefix (names are client-influenced); ownership is the exact-match
BlobMetadata.OwnerId.
PostgreSQL setup
Configure the provider in the concrete EF Core context:
using Elarion.Blobs.PostgreSql;
using Microsoft.EntityFrameworkCore;
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options) {
protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder);
modelBuilder.UseElarionBlobStorage();
}
}Then register the store in the host:
using Elarion.Blobs.PostgreSql;
var connectionString = builder.Configuration.GetConnectionString("Database")!;
builder.Services.AddDbContext<AppDbContext>(options => options.UseNpgsql(connectionString));
builder.Services.AddElarionPostgreSqlBlobStore<AppDbContext>();The context registration is the only connection wiring. The store's streaming reads — each
OpenReadAsync draws a dedicated connection whose lifetime is owned by the returned BlobDownload,
which the scoped DbContext connection cannot provide — clone that dedicated connection from the
context's own connection, so it comes from the same pool, with the same type mapping and auth
callbacks, and targets the same database by construction. Every EF connection shape works unchanged:
UseNpgsql(connectionString), UseNpgsql(dataSource), or AddNpgsqlDataSource + UseNpgsql()
(ADR-0041).
One shared connection pool
The blob registrations take no connection argument at all — not
AddElarionPostgreSqlBlobStore<TDbContext>(), not
AddElarionPostgreSqlBlobLifecycle<TDbContext>(configure?), not
AddElarionPostgreSqlStagedUploads<TDbContext>(configureGc?, configureBlobGc?). Every one of them draws
from the context's own connection, so they add no pool of their own. What does multiply pools is the
company blobs keep: several Elarion PostgreSQL registrations accept a connection string and open
connections from a source of their own — AddElarionPostgreSql(connectionString) on the EF-free SQL
tier TryAddSingletons an NpgsqlDataSource, and AddElarionPostgreSqlClientEvents(connectionString),
AddElarionPostgreSqlSettingsChanges(connectionString), and
AddElarionPostgreSqlHandlerCaching(connectionString) each build one for themselves. Pair any of those with an
AddDbContext<AppDbContext>(o => o.UseNpgsql(connectionString)) and the host runs two pools against one
database — each sized independently, each counting separately against max_connections.
Build one data source, register it, and hand it explicitly to everything that would otherwise pool for itself:
using Npgsql;
var connectionString = builder.Configuration.GetConnectionString("Database")!;
// One pool for the whole host. Registered through a factory delegate, so the container disposes it.
var dataSource = NpgsqlDataSource.Create(connectionString);
builder.Services.AddSingleton(_ => dataSource);
// The parameterless UseNpgsql() resolves the registered NpgsqlDataSource — no second pool.
builder.Services.AddDbContext<AppDbContext>(options => options.UseNpgsql());
// Blob registrations need no connection argument: they draw from the context's connection.
builder.Services.AddElarionPostgreSqlBlobLifecycle<AppDbContext>();
builder.Services.AddElarionPostgreSqlStagedUploads<AppDbContext>();
// These would each build their own pool from a connection string — pass the shared source instead.
builder.Services.AddElarionPostgreSqlClientEvents(dataSource);
builder.Services.AddElarionPostgreSqlSettingsChanges(dataSource);
builder.Services.AddElarionPostgreSqlHandlerCaching(options => options.DataSource = dataSource);Sharing is not uniform across the packages, so read the registration you are calling:
| Registration | Connection-string form | How it shares the host's pool |
|---|---|---|
AddElarionPostgreSql (EF-free SQL tier) | TryAddSingletons an NpgsqlDataSource built from the string | Automatically — a source the host registered first wins, because TryAdd does not overwrite it. |
AddElarionPostgreSqlClientEvents | Calls NpgsqlDataSource.Create(connectionString) inside its factory | Only through the explicit NpgsqlDataSource overload; the string form never consults DI. |
AddElarionPostgreSqlSettingsChanges | Calls NpgsqlDataSource.Create(connectionString) inside its factory | Only through the explicit NpgsqlDataSource overload; the string form never consults DI. |
AddElarionPostgreSqlHandlerCaching | Sets the cache's ConnectionString option, and the cache builds its own source | Only through the options overload, setting options.DataSource. |
AddElarionPostgreSql*Blob* / *StagedUploads | No connection argument at all | Always — they clone from the DbContext connection. |
If your host only pairs EF with the blob packages, builder.Services.AddNpgsqlDataSource(connectionString)
is the shorter equivalent of the first two lines: nothing else needs the instance in hand.
The NpgsqlDataSource/options overloads all borrow the source you pass and never dispose it, which is
why the container has to own it. Weigh the LISTEN/NOTIFY pair deliberately, though: a listen connection
is held open for the life of the process, so pointing client events or settings changes at the application
pool permanently consumes one of its connections — size the pool for it, or leave those two on their own
source.
Register the data source through a factory delegate — AddNpgsqlDataSource(connectionString), or
services.AddSingleton(_ => dataSource) as in the recipe above — and never through the instance
overload (services.AddSingleton(NpgsqlDataSource.Create(connectionString))). The container disposes
what it hands out from a factory, but never an instance it was simply given, so an instance-registered
source survives its host's shutdown and leaks its entire pool.
One long-lived process hides that; an integration suite does not — a WebApplicationFactory per test
class leaks one pool per host until PostgreSQL answers 53300: too many clients already.
Model and tables
UseElarionBlobStorage() adds two tables to the EF Core model:
| Table | Purpose |
|---|---|
stored_blobs | Metadata: id, container, name, content type, size, and created timestamp. |
blob_contents | Content bytes keyed by blob id, with cascade delete from stored_blobs. |
UseElarionBlobStorage takes optional tableName and contentTableName parameters to rename either
table, a schema, and a snakeCase toggle (default true; false switches to PascalCase names — the
defaults become StoredBlobs/BlobContents), for example
modelBuilder.UseElarionBlobStorage("app_blobs", "app_blob_contents", "app").
The blob entities are plain EF Core types configured by UseElarionBlobStorage(); they are
not part of Elarion's [EntityConfiguration] / [GenerateDbSets] source generation. Because the
configuration runs inside your own OnModelCreating, the tables become part of your context's model
and ride along with your normal migrations — there is no separate "blob migration" to manage.
On a [GenerateDbSets] context you can skip the hand-written call: annotate the context with
[GenerateElarionBlobStorage] (optionally with SnakeCase/TableName/ContentTableName/Schema)
and the bundled generator emits the DbSet<StoredBlob> (the content row type is internal — mapped,
but no DbSet) and applies the same model configuration through the EF generator's model-config seam
(ELBLB001 if [GenerateDbSets] is missing).
Schema migration
After adding UseElarionBlobStorage() to your context, create and apply a migration the usual way:
dotnet ef migrations add AddBlobStorage
dotnet ef database updateThe generated migration includes stored_blobs, blob_contents, the unique (container, name)
index, and the cascade foreign key. For throwaway or test databases you can call
dbContext.Database.EnsureCreated() instead of migrating, but use migrations for any schema that
will evolve.
The store talks to PostgreSQL directly for content bytes (it casts the context's connection to
NpgsqlConnection), so the owning context must be configured with UseNpgsql — see the
registration above. That raw SQL is built from the EF model (ISqlGenerationHelper plus the resolved
table/column names), so table, schema, and naming overrides apply to the raw Npgsql content path too.
Writes stream a seekable source (a file, an in-memory buffer) straight into the
bytea column without buffering. Reads stream too: OpenReadAsync opens a dedicated connection
cloned from the context's connection and reads through CommandBehavior.SequentialAccess +
NpgsqlDataReader.GetStream, so the content flows from the wire without materializing the blob in
memory; the reader, command, and connection are owned by the returned BlobDownload and released on
its disposal (double-dispose safe). The one exception is a read inside a caller-owned ambient
transaction, which must share that transaction's connection to see the caller's own uncommitted
writes and therefore stays buffered.
Reading and deleting
Store only blob ids in your own entities or DTOs, then reconstruct BlobRef at read time. When the
whole blob fits comfortably in memory, DownloadContentAsync buffers metadata plus bytes in one call:
var blob = await blobs.DownloadContentAsync(new BlobRef { Value = attachment.BlobId }, ct);
if (blob is null) {
return AppError.NotFound($"Attachment {attachment.Id} was not found.");
}
return new DownloadAttachment.Response(
blob.Name,
blob.ContentType,
blob.Data);To avoid materializing large content, stream instead. DownloadToAsync copies straight to a
destination such as the HTTP response body — the store opens, copies, and disposes the source for you:
response.ContentType = "application/octet-stream";
var found = await blobs.DownloadToAsync(blobRef, response.Body, ct);
if (!found) {
return Results.NotFound();
}OpenReadAsync is the lower-level pull primitive: it hands you the open stream plus metadata. You own
the BlobDownload, so dispose it once you have finished reading — keep the read inside the using
scope so the stream is not disposed out from under a lazy consumer:
await using var download = await blobs.OpenReadAsync(blobRef, ct);
if (download is null) {
return;
}
response.ContentType = download.Metadata.ContentType;
await download.Content.CopyToAsync(response.Body, ct);Use GetMetadataAsync when you only need filename, content type, or size for a listing. Use
DeleteAsync when the owning record is removed and the blob should not be retained.
Boundaries
Elarion.Blobs deliberately does not mention PostgreSQL, EF Core, ASP.NET Core, HTTP uploads, or any
application-specific file categories. Provider packages own storage schema and I/O details; handlers
should depend only on IBlobStore unless they are part of infrastructure composition.
Time series
Store telemetry on the PostgreSQL you already run — a TimescaleDB recipe composing bulk insert, keyset paging, scheduled retention, and client events.
Blob uploads
Pre-upload files over an open, resumable transport (tus) or a minimal direct endpoint, reference them when creating an entity, and reclaim abandoned uploads automatically with a pending/commit/TTL lifecycle.