Pragmatic.Storage
Provider-agnostic file storage abstraction for the Pragmatic.Design ecosystem.
The Problem
Section titled “The Problem”Every non-trivial application stores files — user avatars, PDF invoices, CSV imports. The storage backend varies by environment: local disk in development, Azure Blob in staging, S3 in production. Without an abstraction, domain actions couple directly to the cloud SDK. Switching providers means rewriting business logic. Running locally requires an emulator. Unit testing requires mocking the entire SDK surface.
// Without Pragmatic.Storage: coupled to Azure Blob SDKpublic class UploadPhotoAction{ private readonly BlobContainerClient _container;
public async Task<Uri> Execute(IFormFile file, CancellationToken ct) { var blob = _container.GetBlobClient($"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}"); await blob.UploadAsync(file.OpenReadStream(), cancellationToken: ct); return blob.Uri; // Problem: this code cannot run without Azure credentials // Problem: unit tests need Azurite emulator // Problem: switching to S3 means rewriting this class }}The Solution
Section titled “The Solution”Pragmatic.Storage defines a minimal IFileStorage interface with two methods: SaveAsync and DeleteAsync. Domain actions depend on the abstraction; the physical backend is decided once at composition time. The package ships with LocalDiskFileStorage for development. Swap to Azure or S3 with a single line change in Program.cs — zero changes to domain code.
// With Pragmatic.Storage: backend-agnosticpublic class UploadPhotoAction : DomainAction<Uri>{ private IFileStorage _storage = null!; // Injected by Actions SG
public required IFormFile File { get; init; }
public override async Task<Result<Uri, IError>> Execute(CancellationToken ct) { await using var stream = File.OpenReadStream(); return await _storage.SaveAsync(stream, File.FileName, "photos", ct); // Works with local disk, Azure Blob, S3 -- no code change }}Features
Section titled “Features”| Feature | Description |
|---|---|
IFileStorage | Minimal interface: SaveAsync + DeleteAsync |
LocalDiskFileStorage | Built-in implementation for local file system |
| Stream-based | Works with IFormFile.OpenReadStream() directly |
| Container support | Logical grouping of files into folders |
| IPragmaticBuilder integration | Configure via app.UseStorage(...) in Program.cs |
| DI extensions | AddLocalDiskStorage(), AddFileStorage<T>() |
Installation
Section titled “Installation”dotnet add package Pragmatic.StorageIFileStorage Interface
Section titled “IFileStorage Interface”The core abstraction has two methods:
public interface IFileStorage{ Task<Uri> SaveAsync( Stream content, string fileName, string container, CancellationToken ct = default);
Task DeleteAsync(Uri fileUri, CancellationToken ct = default);}| Parameter | Description |
|---|---|
content | The file content as a Stream |
fileName | Original file name (used for extension / content-type detection) |
container | Logical folder (e.g., "photos", "imports", "invoices") |
SaveAsync returns a Uri — relative for local disk, absolute for cloud providers. DeleteAsync accepts the same Uri returned by SaveAsync.
Quick Start
Section titled “Quick Start”Option 1: IPragmaticBuilder (recommended)
Section titled “Option 1: IPragmaticBuilder (recommended)”Configure storage in Program.cs via the Pragmatic builder:
using Pragmatic.Storage;
await PragmaticApp.RunAsync(args, app =>{ // Local disk storage (development) app.UseStorage(sp => new LocalDiskFileStorage( basePath, sp.GetRequiredService<ILogger<LocalDiskFileStorage>>()));
// Or a custom implementation // app.UseStorage<AzureBlobFileStorage>();});Option 2: Direct DI registration
Section titled “Option 2: Direct DI registration”using Pragmatic.Storage;
// Local disk with convenience methodbuilder.Services.AddLocalDiskStorage(builder.Environment.WebRootPath);
// Or a custom implementationbuilder.Services.AddFileStorage<AzureBlobFileStorage>();Use in a domain action
Section titled “Use in a domain action”[DomainAction][Endpoint(HttpVerb.Post, "api/v1/photos")]public partial class UploadPhoto : DomainAction<Uri>{ private IFileStorage _storage = null!; // Injected by Actions SG
public required IFormFile File { get; init; }
public override async Task<Result<Uri, IError>> Execute(CancellationToken ct = default) { await using var stream = File.OpenReadStream(); var uri = await _storage.SaveAsync(stream, File.FileName, "photos", ct); return uri; }}Delete a stored file
Section titled “Delete a stored file”[DomainAction]public partial class DeletePhoto : VoidDomainAction{ private IFileStorage _storage = null!; private IRepository<Photo, Guid> _photos = null!;
public required Guid PhotoId { get; init; }
public override async Task<VoidResult<IError>> Execute(CancellationToken ct = default) { var photo = await _photos.GetByIdAsync(PhotoId, ct); if (photo is null) return new NotFoundError("Photo", PhotoId);
await _storage.DeleteAsync(photo.FileUri, ct); _photos.Remove(photo); return Success; }}LocalDiskFileStorage
Section titled “LocalDiskFileStorage”The built-in implementation for development and demo environments.
Storage Layout
Section titled “Storage Layout”Files are saved to {basePath}/files/{container}/{guid}{extension}:
wwwroot/ files/ photos/ a1b2c3d4e5f6.jpg f7a8b9c0d1e2.png invoices/ 9e8d7c6b5a4f.pdf imports/ d2e3f4a5b6c7.csv- GUID prefix prevents filename collisions
- Preserves extension from the original filename
- Returns relative URIs (e.g.,
/files/photos/a1b2c3d4e5f6.jpg)
Serving Files
Section titled “Serving Files”Combine with UseStaticFiles() to serve stored files:
app.UseStaticFiles(); // Serves from wwwroot/ by defaultThe relative URIs returned by SaveAsync map directly to the static files path.
Constructor
Section titled “Constructor”public LocalDiskFileStorage(string basePath, ILogger<LocalDiskFileStorage> logger)| Parameter | Description |
|---|---|
basePath | Root directory (e.g., IHostEnvironment.WebRootPath). A files/ sub-directory is created automatically. |
logger | Logger for save/delete operations. |
Logging
Section titled “Logging”LocalDiskFileStorage logs all operations:
| Level | Message | When |
|---|---|---|
Information | Stored {OriginalName} -> files/{Container}/{StoredName} | File saved successfully |
Information | Deleted {Path} | File deleted |
DI Registration Methods
Section titled “DI Registration Methods”StorageServiceCollectionExtensions
Section titled “StorageServiceCollectionExtensions”// Register LocalDiskFileStorage as singletonservices.AddLocalDiskStorage(basePath);
// Register a custom implementation as singletonservices.AddFileStorage<MyCloudStorage>();PragmaticBuilderStorageExtensions
Section titled “PragmaticBuilderStorageExtensions”// Factory-based registration (access to IServiceProvider)app.UseStorage(sp => new LocalDiskFileStorage( basePath, sp.GetRequiredService<ILogger<LocalDiskFileStorage>>()));
// Type-based registrationapp.UseStorage<AzureBlobFileStorage>();Both UseStorage overloads return IPragmaticBuilder for fluent chaining.
Implementing a Custom Provider
Section titled “Implementing a Custom Provider”Create a class that implements IFileStorage:
public sealed class AzureBlobFileStorage : IFileStorage{ private readonly BlobServiceClient _client; private readonly ILogger<AzureBlobFileStorage> _logger;
public AzureBlobFileStorage( BlobServiceClient client, ILogger<AzureBlobFileStorage> logger) { _client = client; _logger = logger; }
public async Task<Uri> SaveAsync( Stream content, string fileName, string container, CancellationToken ct = default) { var containerClient = _client.GetBlobContainerClient(container); await containerClient.CreateIfNotExistsAsync(cancellationToken: ct);
var blobName = $"{Guid.NewGuid():N}{Path.GetExtension(fileName)}"; var blobClient = containerClient.GetBlobClient(blobName);
await blobClient.UploadAsync(content, overwrite: true, cancellationToken: ct);
_logger.LogInformation("Uploaded {FileName} to {BlobUri}", fileName, blobClient.Uri); return blobClient.Uri; }
public async Task DeleteAsync(Uri fileUri, CancellationToken ct = default) { var blobClient = new BlobClient(fileUri); await blobClient.DeleteIfExistsAsync(cancellationToken: ct); _logger.LogInformation("Deleted {BlobUri}", fileUri); }}Register it:
app.UseStorage(sp => new AzureBlobFileStorage( sp.GetRequiredService<BlobServiceClient>(), sp.GetRequiredService<ILogger<AzureBlobFileStorage>>()));S3 Provider Example
Section titled “S3 Provider Example”public sealed class S3FileStorage(IAmazonS3 s3, string bucketName) : IFileStorage{ public async Task<Uri> SaveAsync( Stream content, string fileName, string container, CancellationToken ct = default) { var key = $"{container}/{Guid.NewGuid():N}{Path.GetExtension(fileName)}"; await s3.PutObjectAsync(new PutObjectRequest { BucketName = bucketName, Key = key, InputStream = content }, ct); return new Uri($"https://{bucketName}.s3.amazonaws.com/{key}"); }
public async Task DeleteAsync(Uri fileUri, CancellationToken ct = default) { var key = fileUri.AbsolutePath.TrimStart('/'); await s3.DeleteObjectAsync(bucketName, key, ct); }}Environment-Based Provider Switching
Section titled “Environment-Based Provider Switching”Use the builder pattern to switch providers based on configuration:
await PragmaticApp.RunAsync(args, app =>{ var storageProvider = app.Configuration["Storage:Provider"]; switch (storageProvider) { case "azure": app.UseStorage(sp => new AzureBlobFileStorage( sp.GetRequiredService<BlobServiceClient>(), sp.GetRequiredService<ILogger<AzureBlobFileStorage>>())); break; case "s3": app.UseStorage(sp => new S3FileStorage( sp.GetRequiredService<IAmazonS3>(), app.Configuration["Storage:S3:BucketName"]!)); break; default: app.UseStorage(sp => new LocalDiskFileStorage( app.Environment.WebRootPath, sp.GetRequiredService<ILogger<LocalDiskFileStorage>>())); break; }});Testing
Section titled “Testing”Unit Tests — Mock IFileStorage
Section titled “Unit Tests — Mock IFileStorage”[Fact]public async Task UploadPhoto_ReturnsUri(){ var storage = Substitute.For<IFileStorage>(); storage.SaveAsync(Arg.Any<Stream>(), Arg.Any<string>(), "photos", Arg.Any<CancellationToken>()) .Returns(new Uri("/files/photos/test.jpg", UriKind.Relative));
// ... assert result.IsSuccess and URI matches}Integration Tests — LocalDiskFileStorage
Section titled “Integration Tests — LocalDiskFileStorage”[Fact]public async Task SaveAsync_WritesFile_ReturnsRelativeUri(){ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); var storage = new LocalDiskFileStorage(tempDir, NullLogger<LocalDiskFileStorage>.Instance);
using var stream = new MemoryStream("hello"u8.ToArray()); var uri = await storage.SaveAsync(stream, "test.txt", "docs");
uri.ToString().Should().StartWith("/files/docs/"); File.Exists(Path.Combine(tempDir, uri.ToString().TrimStart('/'))).Should().BeTrue();
// Cleanup Directory.Delete(tempDir, recursive: true);}Multi-Container Organization
Section titled “Multi-Container Organization”Use containers to logically separate different types of files:
// Photos -- user-uploaded imagesawait _storage.SaveAsync(photoStream, "avatar.jpg", "photos", ct);
// Invoices -- generated PDFsawait _storage.SaveAsync(pdfStream, "INV-2026-001.pdf", "invoices", ct);
// Imports -- CSV uploads for batch processingawait _storage.SaveAsync(csvStream, "guests-march.csv", "imports", ct);
// Exports -- generated reportsawait _storage.SaveAsync(reportStream, "report-q1.xlsx", "exports", ct);Each container maps to a subdirectory (LocalDisk) or a separate blob container (Azure) or prefix (S3).
Entity File Reference Pattern
Section titled “Entity File Reference Pattern”Store the returned URI on the entity for later retrieval or deletion:
[Entity<Guid>]public partial class Photo : IEntity<Guid>{ public Uri FileUri { get; private set; } = null!; public string OriginalFileName { get; private set; } = ""; public long FileSizeBytes { get; private set; }
internal void SetFileUri(Uri value) => FileUri = value; internal void SetOriginalFileName(string value) => OriginalFileName = value; internal void SetFileSizeBytes(long value) => FileSizeBytes = value;}
// In a mutation[Mutation(Mode = MutationMode.Create)]public partial class UploadPhotoMutation : Mutation<Photo>{ private IFileStorage _storage = null!;
public required IFormFile File { get; init; }
public override async Task ApplyAsync(Photo entity, CancellationToken ct) { await using var stream = File.OpenReadStream(); var uri = await _storage.SaveAsync(stream, File.FileName, "photos", ct);
entity.SetFileUri(uri); entity.SetOriginalFileName(File.FileName); entity.SetFileSizeBytes(File.Length); }}Feature Summary
Section titled “Feature Summary”| Problem | Solution |
|---|---|
| Domain code coupled to cloud SDK | IFileStorage abstraction |
| Different backends per environment | UseStorage() swap in Program.cs |
| Unit tests need emulator | Mock IFileStorage directly |
| File name collisions | GUID-prefixed storage names |
| No container/folder organization | container parameter in SaveAsync |
| Extension/content-type detection | Original fileName preserved |
| No development storage option | LocalDiskFileStorage built-in |
Project Structure
Section titled “Project Structure”src/Pragmatic.Storage/ IFileStorage.cs Core interface Local/ LocalDiskFileStorage.cs Built-in local disk implementation StorageServiceCollectionExtensions.cs AddLocalDiskStorage(), AddFileStorage<T>() PragmaticBuilderStorageExtensions.cs UseStorage() on IPragmaticBuilderPlanned Providers
Section titled “Planned Providers”| Package | Backend |
|---|---|
Pragmatic.Storage.Azure | Azure Blob Storage |
Pragmatic.Storage.S3 | Amazon S3 / Cloudflare R2 |
| Architecture and Core Concepts | The Problem, The Solution, IFileStorage, providers, streaming | | Getting Started | Step-by-step file upload and download | | Custom Providers | Implement IFileStorage for Azure Blob, S3, or other backends | | Common Mistakes | Wrong/Right/Why for the most frequent issues | | Troubleshooting | Problem/checklist format for runtime issues |
Cross-Module Integration
Section titled “Cross-Module Integration”| With Module | Integration |
|---|---|
| Pragmatic.Actions | IFileStorage auto-injected as private field dependency |
| Pragmatic.Endpoints | IFormFile binding in generated endpoints |
| Pragmatic.Composition | IPragmaticBuilder.UseStorage() for strategy configuration |
Samples
Section titled “Samples”See samples/Pragmatic.Storage.Samples/ for runnable scenarios: IFileStorage interface, LocalDiskFileStorage save/delete demo, multi-container isolation, and integration patterns (DI, endpoints, IPragmaticBuilder).
Requirements
Section titled “Requirements”- .NET 10.0+
License
Section titled “License”Part of the Pragmatic.Design ecosystem.