Skip to content

Custom Storage Providers

The default LocalDiskFileStorage writes files to a local directory. For production deployments (cloud, CDN, S3), implement IFileStorage with your own backend.


The storage abstraction is intentionally minimal — two methods cover the entire lifecycle of a stored file.

public interface IFileStorage
{
Task<Uri> SaveAsync(
Stream content,
string fileName,
string container,
CancellationToken ct = default);
Task DeleteAsync(Uri fileUri, CancellationToken ct = default);
}
  • SaveAsync — stores the content stream and returns a URI that can be used to retrieve or reference the file later.
  • DeleteAsync — removes the file at the given URI.

The container parameter groups files logically (e.g., "photos", "invoices", "avatars"). Providers map containers to physical locations (folders, blob containers, S3 buckets).


Writes files to {basePath}/files/{container}/{guid}{extension}:

app.UseStorage(sp => new LocalDiskFileStorage(
basePath: Path.Combine(env.ContentRootPath, "wwwroot"),
logger: sp.GetRequiredService<ILogger<LocalDiskFileStorage>>()));

File names are replaced with GUIDs to avoid collisions. The original extension is preserved.


public sealed class AzureBlobFileStorage(
BlobServiceClient client,
ILogger<AzureBlobFileStorage> logger) : IFileStorage
{
public async Task<Uri> SaveAsync(
Stream content, string fileName, string container, CancellationToken ct)
{
var containerClient = client.GetBlobContainerClient(container);
await containerClient.CreateIfNotExistsAsync(cancellationToken: ct);
var extension = Path.GetExtension(fileName);
var blobName = $"{Guid.NewGuid():N}{extension}";
var blobClient = containerClient.GetBlobClient(blobName);
await blobClient.UploadAsync(content, overwrite: false, cancellationToken: ct);
logger.LogInformation("Stored blob {BlobName} in container {Container}", blobName, container);
return blobClient.Uri;
}
public async Task DeleteAsync(Uri fileUri, CancellationToken ct)
{
var blobClient = new BlobClient(fileUri);
await blobClient.DeleteIfExistsAsync(cancellationToken: ct);
logger.LogInformation("Deleted blob at {Uri}", fileUri);
}
}
// Via IPragmaticBuilder
app.UseStorage<AzureBlobFileStorage>();
// Or via DI directly
services.AddSingleton(new BlobServiceClient(connectionString));
services.AddFileStorage<AzureBlobFileStorage>();

public sealed class S3FileStorage(
IAmazonS3 s3Client,
string bucketName,
ILogger<S3FileStorage> logger) : IFileStorage
{
public async Task<Uri> SaveAsync(
Stream content, string fileName, string container, CancellationToken ct)
{
var extension = Path.GetExtension(fileName);
var key = $"{container}/{Guid.NewGuid():N}{extension}";
var request = new PutObjectRequest
{
BucketName = bucketName,
Key = key,
InputStream = content
};
await s3Client.PutObjectAsync(request, ct);
return new Uri($"s3://{bucketName}/{key}");
}
public async Task DeleteAsync(Uri fileUri, CancellationToken ct)
{
var key = fileUri.AbsolutePath.TrimStart('/');
await s3Client.DeleteObjectAsync(bucketName, key, ct);
}
}

File upload endpoints use IFileStorage for persistence:

[Endpoint(HttpVerb.Post, "/api/products/{productId}/photo")]
public partial class UploadProductPhoto : Endpoint<Uri>
{
private IFileStorage _storage;
public required Guid ProductId { get; init; }
[MaxFileSize(5 * 1024 * 1024)]
[AllowedContentTypes("image/jpeg", "image/png")]
public required IFormFile Photo { get; init; }
public override async Task<Result<Uri>> HandleAsync(CancellationToken ct)
{
await using var stream = Photo.OpenReadStream();
var uri = await _storage.SaveAsync(stream, Photo.FileName, "product-photos", ct);
return uri;
}
}

The storage provider is chosen once at startup via IPragmaticBuilder. The same IFileStorage is injected everywhere:

// Development: local disk
if (app.Environment.IsDevelopment())
app.UseStorage(sp => new LocalDiskFileStorage("wwwroot", sp.GetRequiredService<ILogger<LocalDiskFileStorage>>()));
else
app.UseStorage<AzureBlobFileStorage>();

This follows the Pragmatic principle: default always works (local disk for dev), swap at composition (cloud for production).