Pragmatic.Storage
Provider-agnostic file storage for the Pragmatic.Design ecosystem.
The Problem
Section titled “The Problem”Every non-trivial app stores files — avatars, PDFs, CSV imports — and the backend changes by environment: local disk in dev, Azure Blob in staging, S3 in production. Without an abstraction, domain code couples to a cloud SDK: switching providers means rewriting business logic, running locally needs an emulator, and unit tests must mock the whole SDK surface.
// Without Pragmatic.Storage: coupled to the Azure Blob SDK — can't run/test without Azurevar blob = _container.GetBlobClient($"{Guid.NewGuid()}{ext}");await blob.UploadAsync(file.OpenReadStream(), cancellationToken: ct);The Solution
Section titled “The Solution”A minimal IFileStorage interface — SaveAsync, GetAsync, ExistsAsync, DeleteAsync. Domain code
depends on the abstraction; the physical backend is chosen once in Program.cs. Ships with
LocalDiskFileStorage for development; swap to Azure or S3 with one line — zero changes to domain code.
public partial class UploadPhotoAction : DomainAction<Uri>{ private IFileStorage _storage = null!; // injected public required IFormFile File { get; init; }
public override async Task<Result<Uri, IError>> Execute(CancellationToken ct) { await using var stream = File.OpenReadStream(); Uri uri = await _storage.SaveAsync(stream, File.FileName, container: "photos", ct); return uri; }}Features
Section titled “Features”IFileStorage— four methods:SaveAsync(returns the fileUri),GetAsync(read stream,nullif missing),ExistsAsync,DeleteAsync(idempotent — no-op if already gone).- Result-based contract —
SaveAsResultAsync/GetAsResultAsync/ExistsAsResultAsync/DeleteAsResultAsyncreturnResult<T, IError>(VoidResult<IError>for delete) with typed errors —FileTooLargeError(413),StorageFileNotFoundError(404),StorageWriteError(500). Composes into an action/mutation with notry/catch. The throwing surface stays for direct use. - File metadata —
IFileInfoProvider.GetInfoAsync(uri)returns aStoredFileInfo(size, content type, last-modified) without downloading the file. Implemented by every provider. - Signed download URLs —
ISignedUrlProvider.GetDownloadUrlAsync(uri, expiry)mints a temporary pre-authenticated URL so a private file is served straight from the backend, no proxying. Azure (SAS), S3/R2 (pre-signed), and Google Cloud (signed URL) implement it. LocalDiskFileStorage— saves under{basePath}/files/{container}/with GUID file names and relative URIs; optionalmaxFileSizeBytesupload cap (enforced for seekable and non-seekable streams); atomic writes (temp file + rename); path-traversal protection on both write (container) and read/delete (URI).- Cloud & network providers — Azure Blob (
AzureBlobFileStorage), S3 / Cloudflare R2 / MinIO / Wasabi / Backblaze B2 (S3FileStorage), Google Cloud Storage (GoogleCloudFileStorage), SFTP (SftpFileStorage), and FTP / FTPS (FtpFileStorage) — each with size limits, URI/key validation, and idempotent delete. InMemoryFileStorage— thePragmatic.Storage.InMemorypackage for tests and local development;AddInMemoryStorage(), no filesystem or network.MimeTypes— content-type from file extension (common web/office types, fallbackapplication/octet-stream). Extension-based only — no content sniffing.LimitedReadStream— read-only wrapper that enforces a byte cap on non-seekable streams (used by the S3 provider; reusable in custom providers).- Registration —
AddLocalDiskStorage(basePath[, maxFileSizeBytes]),AddInMemoryStorage(),AddAzureBlobStorage,AddS3Storage,AddGoogleCloudStorage,AddSftpStorage,AddFtpStorage, andAddFileStorage<T>()onIServiceCollection;UseStorage(factory)/UseStorage<T>()onIPragmaticBuilder. - Observability — providers log via
[LoggerMessage](zero-allocation structured logging).
Installation
Section titled “Installation”dotnet add package Pragmatic.Storagedotnet add package Pragmatic.Storage.Azure # or .S3 / .GoogleCloud / .Sftp / .Ftp / .InMemory| Package | Purpose |
|---|---|
Pragmatic.Storage | The IFileStorage contract, LocalDiskFileStorage, Result-based surface and typed errors, IFileInfoProvider / ISignedUrlProvider, DI/builder extensions, MimeTypes, LimitedReadStream |
Pragmatic.Storage.Azure | Azure Blob Storage provider (AzureBlobFileStorage + AzureBlobStorageOptions) — metadata + SAS signed URLs |
Pragmatic.Storage.S3 | Amazon S3 / Cloudflare R2 / MinIO / Wasabi / Backblaze B2 provider (S3FileStorage + S3StorageOptions) — metadata + pre-signed URLs |
Pragmatic.Storage.GoogleCloud | Google Cloud Storage provider (GoogleCloudFileStorage + GoogleCloudStorageOptions) — metadata + signed URLs |
Pragmatic.Storage.Sftp | SFTP provider (SftpFileStorage + SftpStorageOptions) — metadata; no signed URLs |
Pragmatic.Storage.Ftp | FTP / FTPS provider (FtpFileStorage + FtpStorageOptions) — metadata; no signed URLs |
Pragmatic.Storage.InMemory | InMemoryFileStorage for tests and local development (AddInMemoryStorage()) |
Register the backend in Program.cs (app.UseStorage(...)) — LocalDisk for dev, Azure/S3 in
production. Domain code is unchanged across environments.
Status
Section titled “Status”Stable within 1.0.0-alpha — the IFileStorage contract, LocalDiskFileStorage, and the Azure/S3
providers are settled. See the roadmap.
| Concepts | The abstraction, container organization, entity file-reference pattern |
| Getting Started | Save/get/delete a file, wire a backend, environment switching |
| Custom Providers | Implement IFileStorage for a new backend |
| Common Mistakes | The most frequent storage pitfalls |
| Troubleshooting | Problem/solution guide |
Requirements
Section titled “Requirements”- .NET 10.0+
License
Section titled “License”Part of the Pragmatic.Design ecosystem — see Licensing. Pragmatic.Storage is MIT-licensed.