Skip to content

Architecture and Core Concepts

This guide explains why Pragmatic.Storage exists, how its pieces fit together, and how to choose the right provider for each environment. Read this before diving into the getting-started guide.


Every non-trivial application stores files — user avatars, PDF invoices, CSV imports, document attachments. The storage backend varies by environment: local disk in development, Azure Blob in staging, S3 in production. Without an abstraction, file storage logic leaks into business code.

Direct provider coupling: vendor lock-in at the domain level

Section titled “Direct provider coupling: vendor lock-in at the domain level”
public class UploadDocument : DomainAction<DocumentDto>
{
private readonly BlobServiceClient _blobClient; // Azure SDK dependency
public required IFormFile File { get; init; }
public override async Task<Result<DocumentDto, IError>> Execute(CancellationToken ct)
{
var container = _blobClient.GetBlobContainerClient("documents");
await container.CreateIfNotExistsAsync(cancellationToken: ct);
var blobName = $"{Guid.NewGuid():N}{Path.GetExtension(File.FileName)}";
var blob = container.GetBlobClient(blobName);
await using var stream = File.OpenReadStream();
await blob.UploadAsync(stream, overwrite: true, cancellationToken: ct);
var document = Document.Create(File.FileName, blob.Uri, File.Length, "documents");
// ...
}
}

The domain action is now coupled to Azure.Storage.Blobs. Switching to S3 requires rewriting every action that touches files. Running locally requires an Azure Storage emulator (Azurite) or conditional logic branches. Unit testing requires mocking the entire Azure SDK surface.

Conditional branching: environment-specific code in business logic

Section titled “Conditional branching: environment-specific code in business logic”
public override async Task<Result<DocumentDto, IError>> Execute(CancellationToken ct)
{
Uri fileUri;
if (_environment.IsDevelopment())
{
// Local disk
var dir = Path.Combine(_webRoot, "files", "documents");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, $"{Guid.NewGuid():N}{ext}");
await using var fs = System.IO.File.Create(path);
await stream.CopyToAsync(fs, ct);
fileUri = new Uri($"/files/documents/{Path.GetFileName(path)}", UriKind.Relative);
}
else if (_config["Storage:Provider"] == "Azure")
{
// Azure Blob
// ...
}
else
{
// S3
// ...
}
}

Three implementations interleaved with business logic. Each new provider adds another branch. The action’s cyclomatic complexity grows with the number of storage backends, and testing requires covering every branch.

File storage is an infrastructure concern. The business logic only needs two operations: save a file (get back a URI) and delete a file (by URI). The physical location — local disk, Azure, S3, MinIO — is a deployment decision that should be made at composition time, not in domain code.


Pragmatic.Storage defines a minimal IFileStorage interface with two methods. Domain actions depend on the abstraction; the provider is selected once at startup via IPragmaticBuilder or DI registration.

public interface IFileStorage
{
Task<Uri> SaveAsync(
Stream content,
string fileName,
string container,
CancellationToken ct = default);
Task DeleteAsync(Uri fileUri, CancellationToken ct = default);
}

The same upload action, decoupled from the provider:

public class UploadDocument : DomainAction<DocumentDto>
{
private IFileStorage _storage = null!; // Injected by Actions SG
public required IFormFile File { get; init; }
public override async Task<Result<DocumentDto, IError>> Execute(CancellationToken ct)
{
await using var stream = File.OpenReadStream();
var uri = await _storage.SaveAsync(stream, File.FileName, "documents", ct);
var document = Document.Create(File.FileName, uri, File.Length, "documents");
// ...
}
}

The provider is selected once in Program.cs:

await PragmaticApp.RunAsync(args, app =>
{
// Development: local disk
app.UseStorage(sp => new LocalDiskFileStorage(
basePath,
sp.GetRequiredService<ILogger<LocalDiskFileStorage>>()));
});

Swap to Azure for production:

app.UseStorage<AzureBlobFileStorage>();

Zero changes to the domain action. Zero conditional branches. The business logic is identical regardless of whether files land on a local disk or in a cloud bucket.


The core abstraction has intentionally two methods. Every file storage operation reduces to “save content and get a URI” or “delete content by URI.”

Task<Uri> SaveAsync(
Stream content,
string fileName,
string container,
CancellationToken ct = default);
ParameterDescription
contentThe file content as a Stream. Typically from IFormFile.OpenReadStream().
fileNameOriginal file name. Used for extension and content-type detection. The provider does not use this as the storage name — files are stored with unique names (GUIDs) to prevent collisions.
containerLogical grouping (e.g., "photos", "invoices", "imports"). Maps to folders (local disk), blob containers (Azure), or key prefixes (S3).

Returns: A Uri pointing to the stored file. The URI type depends on the provider:

ProviderURI TypeExample
LocalDiskFileStorageRelative/files/photos/a1b2c3d4e5f6.jpg
Azure BlobAbsolutehttps://account.blob.core.windows.net/photos/a1b2c3d4.jpg
S3Absolute or customs3://bucket/photos/a1b2c3d4.jpg
Task DeleteAsync(Uri fileUri, CancellationToken ct = default);
ParameterDescription
fileUriThe URI returned by SaveAsync. The provider uses this to locate and delete the file.

Deleting a non-existent file is a no-op (does not throw). This makes cleanup operations idempotent.

Why only two methods? The interface covers the two operations that domain code needs. Reading a file is handled by the URI itself — the client or browser fetches it directly (via static files, CDN, or presigned URL). Listing files, moving files, and metadata queries are provider-specific concerns that do not belong in a domain-level abstraction.

Why Stream instead of byte[]? Streams avoid loading the entire file into memory. For large files (uploads > 100 MB), this is critical. The content stream is read once and written to the destination. Callers must not assume the stream is seekable.

Why Uri instead of string? URIs enforce a structured format and distinguish relative from absolute paths. A string return value invites inconsistency (“files/photos/abc.jpg” vs. “/files/photos/abc.jpg” vs. “https://…”).


The built-in implementation for development and demo environments. Ships with the Pragmatic.Storage package.

Files are saved to {basePath}/files/{container}/{guid}{extension}:

wwwroot/
files/
photos/
a1b2c3d4e5f6.jpg
f7a8b9c0d1e2.png
invoices/
9e8d7c6b5a4f.pdf
BehaviorDetail
GUID prefixPrevents filename collisions. Each file gets a Guid.NewGuid():N prefix.
Preserves extensionThe original file’s extension (.jpg, .pdf) is appended to the GUID.
Returns relative URIse.g., /files/photos/a1b2c3d4e5f6.jpg
Creates directories{basePath}/files/{container}/ is created automatically if it does not exist.
Delete is path-basedConverts the relative URI back to a filesystem path and deletes the file. No-op if the file does not exist.
public LocalDiskFileStorage(string basePath, ILogger<LocalDiskFileStorage> logger)
ParameterDescription
basePathRoot directory (e.g., IHostEnvironment.WebRootPath). A files/ subdirectory is created automatically.
loggerLogger for save/delete operations. Logs the original file name and stored path.

Combine with ASP.NET Core’s static file middleware to serve stored files:

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

The relative URIs returned by SaveAsync (e.g., /files/photos/abc.jpg) map directly to the static files path (wwwroot/files/photos/abc.jpg).

Implement IFileStorage for any backend. The interface has only two methods, making custom providers straightforward.

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 blobName = $"{Guid.NewGuid():N}{Path.GetExtension(fileName)}";
var blobClient = containerClient.GetBlobClient(blobName);
await blobClient.UploadAsync(content, overwrite: false, cancellationToken: ct);
return blobClient.Uri;
}
public async Task DeleteAsync(Uri fileUri, CancellationToken ct)
{
var blobClient = new BlobClient(fileUri);
await blobClient.DeleteIfExistsAsync(cancellationToken: ct);
}
}
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 key = $"{container}/{Guid.NewGuid():N}{Path.GetExtension(fileName)}";
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);
}
}
PackageBackendStatus
Pragmatic.Storage.AzureAzure Blob StoragePlanned
Pragmatic.Storage.S3Amazon S3 / Cloudflare R2Planned

All providers (built-in and custom examples) follow the same naming strategy:

  1. The original file name is not used as the storage name.
  2. A GUID (Guid.NewGuid():N) provides a unique, collision-free name.
  3. The original file’s extension is preserved for content-type detection and display.

This means report.pdf is stored as a1b2c3d4e5f67890a1b2c3d4e5f67890.pdf. Two users uploading files named photo.jpg at the same time get different storage names.


The container parameter groups files logically. Use descriptive names that match your domain:

ContainerContent
"photos"User-uploaded images
"documents"PDF/Word documents
"imports"CSV/Excel import files
"invoices"Generated invoice PDFs
"avatars"User profile pictures
"product-photos"Product catalog images

Containers map to different physical structures depending on the provider:

ProviderContainer Maps To
LocalDiskFilesystem directory: files/{container}/
Azure BlobBlob container with the same name
S3Key prefix: {container}/ within the bucket

Direct IServiceCollection registration for scenarios where IPragmaticBuilder is not available:

// Register LocalDiskFileStorage as singleton
services.AddLocalDiskStorage(basePath);
// Register a custom implementation as singleton
services.AddFileStorage<AzureBlobFileStorage>();

Registration via IPragmaticBuilder (recommended for Pragmatic.Composition hosts):

// Factory-based registration (access to IServiceProvider for resolving dependencies)
app.UseStorage(sp => new LocalDiskFileStorage(
basePath,
sp.GetRequiredService<ILogger<LocalDiskFileStorage>>()));
// Type-based registration
app.UseStorage<AzureBlobFileStorage>();

Both UseStorage overloads return IPragmaticBuilder for fluent chaining.

All registration methods register IFileStorage as a singleton. This is appropriate because:

  • LocalDiskFileStorage has no per-request state (it only holds a basePath and ILogger).
  • Cloud providers hold a client instance (e.g., BlobServiceClient) that is designed for reuse across requests.
  • File storage operations are stateless: each SaveAsync/DeleteAsync call is independent.

IFileStorage.SaveAsync accepts a Stream, not a byte[]. This enables efficient handling of large files without loading the entire content into memory.

// IFormFile → Stream → IFileStorage
await using var stream = formFile.OpenReadStream();
var uri = await _storage.SaveAsync(stream, formFile.FileName, "photos", ct);
  1. Do not assume the stream is seekable. Not all streams support Position or Seek(). Read the stream forward once.
  2. Dispose the stream after use. The caller owns the stream lifetime. Use await using for IFormFile streams.
  3. Do not read the stream before passing it. If you call stream.ReadToEndAsync() before SaveAsync, the provider receives an empty stream. If you need to inspect the content, save to a MemoryStream first, reset position, then pass to SaveAsync.

When a domain action declares a private IFileStorage field with = null!, the Actions SG generates a SetDependencies() method that injects the storage service from the DI container:

public class UploadPhoto : DomainAction<Uri>
{
private IFileStorage _storage = null!; // Injected automatically
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);
}
}

File upload endpoints use IFileStorage in combination with [FromForm] for IFormFile binding:

[Endpoint(HttpVerb.Post, "/api/products/{productId}/photo")]
public partial class UploadProductPhoto : Endpoint<Uri>
{
private IFileStorage _storage = null!;
[FromRoute]
public required Guid ProductId { get; init; }
[FromForm]
[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();
return await _storage.SaveAsync(stream, Photo.FileName, "product-photos", ct);
}
}

Storage follows the Pragmatic builder pattern for module strategy configuration (Tier 2). The provider is chosen once in Program.cs, and the same IFileStorage is injected everywhere:

await PragmaticApp.RunAsync(args, app =>
{
if (app.Environment.IsDevelopment())
app.UseStorage(sp => new LocalDiskFileStorage(
webRootPath,
sp.GetRequiredService<ILogger<LocalDiskFileStorage>>()));
else
app.UseStorage<AzureBlobFileStorage>();
});

For tests, create an in-memory implementation:

public class InMemoryFileStorage : IFileStorage
{
private readonly Dictionary<Uri, byte[]> _files = new();
public async Task<Uri> SaveAsync(
Stream content, string fileName, string container, CancellationToken ct = default)
{
using var ms = new MemoryStream();
await content.CopyToAsync(ms, ct);
var uri = new Uri($"/files/{container}/{Guid.NewGuid():N}{Path.GetExtension(fileName)}",
UriKind.Relative);
_files[uri] = ms.ToArray();
return uri;
}
public Task DeleteAsync(Uri fileUri, CancellationToken ct = default)
{
_files.Remove(fileUri);
return Task.CompletedTask;
}
}

Register as singleton in test setup: services.AddSingleton<IFileStorage>(new InMemoryFileStorage());