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.
The Problem
Section titled “The Problem”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.
The fundamental issue
Section titled “The fundamental issue”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.
The Solution
Section titled “The Solution”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.
IFileStorage Interface
Section titled “IFileStorage Interface”The core abstraction has intentionally two methods. Every file storage operation reduces to “save content and get a URI” or “delete content by URI.”
SaveAsync
Section titled “SaveAsync”Task<Uri> SaveAsync( Stream content, string fileName, string container, CancellationToken ct = default);| Parameter | Description |
|---|---|
content | The file content as a Stream. Typically from IFormFile.OpenReadStream(). |
fileName | Original 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. |
container | Logical 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:
| Provider | URI Type | Example |
|---|---|---|
| LocalDiskFileStorage | Relative | /files/photos/a1b2c3d4e5f6.jpg |
| Azure Blob | Absolute | https://account.blob.core.windows.net/photos/a1b2c3d4.jpg |
| S3 | Absolute or custom | s3://bucket/photos/a1b2c3d4.jpg |
DeleteAsync
Section titled “DeleteAsync”Task DeleteAsync(Uri fileUri, CancellationToken ct = default);| Parameter | Description |
|---|---|
fileUri | The 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.
Design Decisions
Section titled “Design Decisions”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://…”).
Providers
Section titled “Providers”LocalDiskFileStorage (built-in)
Section titled “LocalDiskFileStorage (built-in)”The built-in implementation for development and demo environments. Ships with the Pragmatic.Storage package.
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| Behavior | Detail |
|---|---|
| GUID prefix | Prevents filename collisions. Each file gets a Guid.NewGuid():N prefix. |
| Preserves extension | The original file’s extension (.jpg, .pdf) is appended to the GUID. |
| Returns relative URIs | e.g., /files/photos/a1b2c3d4e5f6.jpg |
| Creates directories | {basePath}/files/{container}/ is created automatically if it does not exist. |
| Delete is path-based | Converts the relative URI back to a filesystem path and deletes the file. No-op if the file does not exist. |
Constructor
Section titled “Constructor”public LocalDiskFileStorage(string basePath, ILogger<LocalDiskFileStorage> logger)| Parameter | Description |
|---|---|
basePath | Root directory (e.g., IHostEnvironment.WebRootPath). A files/ subdirectory is created automatically. |
logger | Logger for save/delete operations. Logs the original file name and stored path. |
Serving Files
Section titled “Serving Files”Combine with ASP.NET Core’s static file middleware to serve stored files:
app.UseStaticFiles(); // Serves from wwwroot/ by defaultThe relative URIs returned by SaveAsync (e.g., /files/photos/abc.jpg) map directly to the static files path (wwwroot/files/photos/abc.jpg).
Custom Providers
Section titled “Custom Providers”Implement IFileStorage for any backend. The interface has only two methods, making custom providers straightforward.
Azure Blob Storage
Section titled “Azure Blob Storage”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); }}Amazon S3
Section titled “Amazon S3”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); }}Planned Provider Packages
Section titled “Planned Provider Packages”| Package | Backend | Status |
|---|---|---|
Pragmatic.Storage.Azure | Azure Blob Storage | Planned |
Pragmatic.Storage.S3 | Amazon S3 / Cloudflare R2 | Planned |
File Naming and Collisions
Section titled “File Naming and Collisions”All providers (built-in and custom examples) follow the same naming strategy:
- The original file name is not used as the storage name.
- A GUID (
Guid.NewGuid():N) provides a unique, collision-free name. - 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.
Container Conventions
Section titled “Container Conventions”The container parameter groups files logically. Use descriptive names that match your domain:
| Container | Content |
|---|---|
"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:
| Provider | Container Maps To |
|---|---|
| LocalDisk | Filesystem directory: files/{container}/ |
| Azure Blob | Blob container with the same name |
| S3 | Key prefix: {container}/ within the bucket |
DI Registration
Section titled “DI Registration”StorageServiceCollectionExtensions
Section titled “StorageServiceCollectionExtensions”Direct IServiceCollection registration for scenarios where IPragmaticBuilder is not available:
// Register LocalDiskFileStorage as singletonservices.AddLocalDiskStorage(basePath);
// Register a custom implementation as singletonservices.AddFileStorage<AzureBlobFileStorage>();PragmaticBuilderStorageExtensions
Section titled “PragmaticBuilderStorageExtensions”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 registrationapp.UseStorage<AzureBlobFileStorage>();Both UseStorage overloads return IPragmaticBuilder for fluent chaining.
Singleton Lifetime
Section titled “Singleton Lifetime”All registration methods register IFileStorage as a singleton. This is appropriate because:
LocalDiskFileStoragehas no per-request state (it only holds abasePathandILogger).- Cloud providers hold a client instance (e.g.,
BlobServiceClient) that is designed for reuse across requests. - File storage operations are stateless: each
SaveAsync/DeleteAsynccall is independent.
Streaming
Section titled “Streaming”IFileStorage.SaveAsync accepts a Stream, not a byte[]. This enables efficient handling of large files without loading the entire content into memory.
Typical Upload Flow
Section titled “Typical Upload Flow”// IFormFile → Stream → IFileStorageawait using var stream = formFile.OpenReadStream();var uri = await _storage.SaveAsync(stream, formFile.FileName, "photos", ct);Important Stream Rules
Section titled “Important Stream Rules”- Do not assume the stream is seekable. Not all streams support
PositionorSeek(). Read the stream forward once. - Dispose the stream after use. The caller owns the stream lifetime. Use
await usingforIFormFilestreams. - Do not read the stream before passing it. If you call
stream.ReadToEndAsync()beforeSaveAsync, the provider receives an empty stream. If you need to inspect the content, save to aMemoryStreamfirst, reset position, then pass toSaveAsync.
Ecosystem Integration
Section titled “Ecosystem Integration”Actions
Section titled “Actions”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); }}Endpoints
Section titled “Endpoints”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); }}IPragmaticBuilder
Section titled “IPragmaticBuilder”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>();});Testing
Section titled “Testing”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());
See Also
Section titled “See Also”- Getting Started — Step-by-step file upload and download
- Custom Providers — Implement IFileStorage for Azure Blob, S3, or other backends