Elarion

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.

Package split

PackageUse it inContains
Elarion.BlobsApplication codeIBlobStore, BlobRef, BlobUploadRequest, BlobDownload, BlobMetadata, BlobContent, and BlobStoreExtensions. No provider dependency.
Elarion.Blobs.PostgreSqlInfrastructure / hostPostgreSqlBlobStore<TDbContext>, DI registration, and EF Core model configuration for PostgreSQL.
<ItemGroup>
  <PackageReference Include="Elarion.Blobs" Version="0.1.0" />
  <PackageReference Include="Elarion.Blobs.PostgreSql" Version="0.1.0" />
</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:

MethodPurpose
SaveAsync(BlobUploadRequest, Stream)Streams content in and returns a BlobRef.
OpenReadAsyncOpens a disposable BlobDownload carrying metadata plus an open content stream.
GetMetadataAsyncLoads metadata without content.
ExistsAsyncChecks whether a reference exists.
DeleteAsyncDeletes a referenced blob.

BlobStoreExtensions layers the ergonomic call styles over those primitives, so every backend gets them for free:

ExtensionPurpose
SaveAsync(BlobUploadRequest, byte[])Stores an in-memory byte array.
SaveFromFileAsyncStores content from a local file path (opened as a stream, no backend file assumption).
DownloadContentAsyncLoads metadata plus bytes into a BlobContent.
ReadAllBytesAsyncLoads just the content bytes.
DownloadToAsyncCopies content to a destination stream (for example an HTTP response body).

The PostgreSQL implementation replaces an existing blob when container and name match, preserving the blob id while updating content type, size, timestamp, and bytes.

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.UsePostgreSqlBlobStorage();
    }
}

Then register the store in the host:

using Elarion.Blobs.PostgreSql;

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Database")));

builder.Services.AddPostgreSqlBlobStore<AppDbContext>();

UsePostgreSqlBlobStorage() adds two tables to the EF Core model:

TablePurpose
stored_blobsMetadata: id, container, name, content type, size, and created timestamp.
blob_contentsContent bytes keyed by blob id, with cascade delete from stored_blobs.

The blob entities are plain EF Core types configured by UsePostgreSqlBlobStorage(); they are not part of Elarion's [DbEntity] / [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.

Schema migration

After adding UsePostgreSqlBlobStorage() to your context, create and apply a migration the usual way:

dotnet ef migrations add AddBlobStorage
dotnet ef database update

The 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. Writes stream a seekable source (a file, an in-memory buffer) straight into the bytea column without buffering; reads are buffered for now, with a documented NpgsqlDataReader.GetStream upgrade path for true read streaming.

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.

On this page