Custom Storage Providers
The default LocalDiskFileStorage writes files to a local directory. For production deployments (cloud, CDN, S3), implement IFileStorage with your own backend.
IFileStorage Interface
Section titled “IFileStorage Interface”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).
Built-in: LocalDiskFileStorage
Section titled “Built-in: LocalDiskFileStorage”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.
Example: Azure Blob Storage Provider
Section titled “Example: Azure Blob Storage Provider”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); }}Registration
Section titled “Registration”// Via IPragmaticBuilderapp.UseStorage<AzureBlobFileStorage>();
// Or via DI directlyservices.AddSingleton(new BlobServiceClient(connectionString));services.AddFileStorage<AzureBlobFileStorage>();Example: AWS S3 Provider
Section titled “Example: AWS S3 Provider”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); }}Integration with Endpoints
Section titled “Integration with Endpoints”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; }}Provider Selection at Composition Time
Section titled “Provider Selection at Composition Time”The storage provider is chosen once at startup via IPragmaticBuilder. The same IFileStorage is injected everywhere:
// Development: local diskif (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).