Skip to content

Pragmatic.Storage

Provider-agnostic file storage abstraction for the Pragmatic.Design ecosystem.

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 SDK
public 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
}
}

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-agnostic
public 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
}
}

FeatureDescription
IFileStorageMinimal interface: SaveAsync + DeleteAsync
LocalDiskFileStorageBuilt-in implementation for local file system
Stream-basedWorks with IFormFile.OpenReadStream() directly
Container supportLogical grouping of files into folders
IPragmaticBuilder integrationConfigure via app.UseStorage(...) in Program.cs
DI extensionsAddLocalDiskStorage(), AddFileStorage<T>()
Terminal window
dotnet add package Pragmatic.Storage

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);
}
ParameterDescription
contentThe file content as a Stream
fileNameOriginal file name (used for extension / content-type detection)
containerLogical 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.


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>();
});
using Pragmatic.Storage;
// Local disk with convenience method
builder.Services.AddLocalDiskStorage(builder.Environment.WebRootPath);
// Or a custom implementation
builder.Services.AddFileStorage<AzureBlobFileStorage>();
[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;
}
}
[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;
}
}

The built-in implementation for development and demo environments.

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)

Combine with UseStaticFiles() to serve stored files:

app.UseStaticFiles(); // Serves from wwwroot/ by default

The relative URIs returned by SaveAsync map directly to the static files path.

public LocalDiskFileStorage(string basePath, ILogger<LocalDiskFileStorage> logger)
ParameterDescription
basePathRoot directory (e.g., IHostEnvironment.WebRootPath). A files/ sub-directory is created automatically.
loggerLogger for save/delete operations.

LocalDiskFileStorage logs all operations:

LevelMessageWhen
InformationStored {OriginalName} -> files/{Container}/{StoredName}File saved successfully
InformationDeleted {Path}File deleted

// Register LocalDiskFileStorage as singleton
services.AddLocalDiskStorage(basePath);
// Register a custom implementation as singleton
services.AddFileStorage<MyCloudStorage>();
// Factory-based registration (access to IServiceProvider)
app.UseStorage(sp => new LocalDiskFileStorage(
basePath,
sp.GetRequiredService<ILogger<LocalDiskFileStorage>>()));
// Type-based registration
app.UseStorage<AzureBlobFileStorage>();

Both UseStorage overloads return IPragmaticBuilder for fluent chaining.


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

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;
}
});

[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);
}

Use containers to logically separate different types of files:

// Photos -- user-uploaded images
await _storage.SaveAsync(photoStream, "avatar.jpg", "photos", ct);
// Invoices -- generated PDFs
await _storage.SaveAsync(pdfStream, "INV-2026-001.pdf", "invoices", ct);
// Imports -- CSV uploads for batch processing
await _storage.SaveAsync(csvStream, "guests-march.csv", "imports", ct);
// Exports -- generated reports
await _storage.SaveAsync(reportStream, "report-q1.xlsx", "exports", ct);

Each container maps to a subdirectory (LocalDisk) or a separate blob container (Azure) or prefix (S3).


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);
}
}

ProblemSolution
Domain code coupled to cloud SDKIFileStorage abstraction
Different backends per environmentUseStorage() swap in Program.cs
Unit tests need emulatorMock IFileStorage directly
File name collisionsGUID-prefixed storage names
No container/folder organizationcontainer parameter in SaveAsync
Extension/content-type detectionOriginal fileName preserved
No development storage optionLocalDiskFileStorage built-in

src/Pragmatic.Storage/
IFileStorage.cs Core interface
Local/
LocalDiskFileStorage.cs Built-in local disk implementation
StorageServiceCollectionExtensions.cs AddLocalDiskStorage(), AddFileStorage<T>()
PragmaticBuilderStorageExtensions.cs UseStorage() on IPragmaticBuilder

PackageBackend
Pragmatic.Storage.AzureAzure Blob Storage
Pragmatic.Storage.S3Amazon 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 |

With ModuleIntegration
Pragmatic.ActionsIFileStorage auto-injected as private field dependency
Pragmatic.EndpointsIFormFile binding in generated endpoints
Pragmatic.CompositionIPragmaticBuilder.UseStorage() for strategy configuration

See samples/Pragmatic.Storage.Samples/ for runnable scenarios: IFileStorage interface, LocalDiskFileStorage save/delete demo, multi-container isolation, and integration patterns (DI, endpoints, IPragmaticBuilder).

  • .NET 10.0+

Part of the Pragmatic.Design ecosystem.