Overview
The Storage building block provides file storage abstractions with support for local file system and Amazon S3-compatible storage (AWS S3, MinIO, DigitalOcean Spaces, etc.).
Switch between local and cloud storage without changing your code — just update configuration.
Key Components
IStorageService
Main abstraction for file operations:
using FSH . Framework . Shared . Storage ;
using FSH . Framework . Storage . DTOs ;
namespace FSH . Framework . Storage . Services ;
public interface IStorageService
{
Task < string > UploadAsync < T >(
FileUploadRequest request ,
FileType fileType ,
CancellationToken cancellationToken = default ) where T : class ;
Task < FileDownloadResponse ?> DownloadAsync (
string path ,
CancellationToken cancellationToken = default );
Task < bool > ExistsAsync (
string path ,
CancellationToken cancellationToken = default );
Task RemoveAsync ( string path , CancellationToken cancellationToken = default );
}
FileUploadRequest
Request model for file uploads:
namespace FSH . Framework . Shared . Storage ;
public sealed class FileUploadRequest
{
public string Name { get ; set ; } = default ! ;
public string Extension { get ; set ; } = default ! ;
public byte [] Data { get ; set ; } = default ! ;
}
FileType
Supported file type categories:
namespace FSH . Framework . Storage ;
public enum FileType
{
Image ,
Document ,
Video ,
Audio ,
Other
}
FileDownloadResponse
Response model for file downloads:
namespace FSH . Framework . Storage . DTOs ;
public sealed class FileDownloadResponse
{
public string FileName { get ; set ; } = default ! ;
public string ContentType { get ; set ; } = default ! ;
public byte [] Data { get ; set ; } = default ! ;
}
Registration
Program.cs (Local Storage)
Program.cs (S3 or Local via Config)
appsettings.json (Local Storage)
appsettings.json (S3 Storage)
using FSH . Framework . Storage ;
builder . Services . AddHeroLocalFileStorage ();
Usage Examples
Upload File
UploadProductImageHandler.cs
using FSH . Framework . Storage ;
using FSH . Framework . Storage . Services ;
using FSH . Framework . Shared . Storage ;
public sealed class UploadProductImageHandler
: ICommandHandler < UploadProductImageCommand , string >
{
private readonly IStorageService _storage ;
private readonly CatalogDbContext _db ;
public async ValueTask < string > Handle (
UploadProductImageCommand cmd ,
CancellationToken ct )
{
var product = await _db . Products . FindAsync ([ cmd . ProductId ], ct )
?? throw new NotFoundException ( $"Product { cmd . ProductId } not found." );
var uploadRequest = new FileUploadRequest
{
Name = $"product- { cmd . ProductId } " ,
Extension = Path . GetExtension ( cmd . FileName ),
Data = cmd . FileData
};
// Upload and get the file path
var filePath = await _storage . UploadAsync < Product >(
uploadRequest ,
FileType . Image ,
ct );
// Update product with image path
product . UpdateImagePath ( filePath );
await _db . SaveChangesAsync ( ct );
return filePath ;
}
}
Download File
DownloadInvoiceEndpoint.cs
using FSH . Framework . Storage . Services ;
using Microsoft . AspNetCore . Http ;
public static class DownloadInvoiceEndpoint
{
public static RouteHandlerBuilder MapDownloadInvoiceEndpoint (
this IEndpointRouteBuilder endpoints )
{
return endpoints . MapGet ( "/invoices/{id:guid}/download" ,
async ( Guid id , IStorageService storage , InvoiceDbContext db , CancellationToken ct ) =>
{
var invoice = await db . Invoices . FindAsync ([ id ], ct )
?? throw new NotFoundException ( $"Invoice { id } not found." );
if ( string . IsNullOrWhiteSpace ( invoice . FilePath ))
{
throw new NotFoundException ( "Invoice file not found." );
}
var fileResponse = await storage . DownloadAsync ( invoice . FilePath , ct );
if ( fileResponse is null )
{
throw new NotFoundException ( "File not found in storage." );
}
return Results . File (
fileResponse . Data ,
fileResponse . ContentType ,
fileResponse . FileName );
})
. WithName ( "DownloadInvoice" )
. WithSummary ( "Download invoice PDF" );
}
}
Check File Exists
ValidateAttachmentHandler.cs
using FSH . Framework . Storage . Services ;
public sealed class ValidateAttachmentHandler
: IQueryHandler < ValidateAttachmentQuery , bool >
{
private readonly IStorageService _storage ;
public async ValueTask < bool > Handle (
ValidateAttachmentQuery query ,
CancellationToken ct )
{
return await _storage . ExistsAsync ( query . FilePath , ct );
}
}
Delete File
using FSH . Framework . Storage . Services ;
public sealed class DeleteProductHandler : ICommandHandler < DeleteProductCommand >
{
private readonly CatalogDbContext _db ;
private readonly IStorageService _storage ;
public async ValueTask < Unit > Handle ( DeleteProductCommand cmd , CancellationToken ct )
{
var product = await _db . Products . FindAsync ([ cmd . Id ], ct )
?? throw new NotFoundException ( $"Product { cmd . Id } not found." );
// Delete associated image
if ( ! string . IsNullOrWhiteSpace ( product . ImagePath ))
{
await _storage . RemoveAsync ( product . ImagePath , ct );
}
_db . Products . Remove ( product );
await _db . SaveChangesAsync ( ct );
return Unit . Value ;
}
}
Upload from Endpoint
using FSH . Framework . Storage ;
using FSH . Framework . Storage . Services ;
using FSH . Framework . Shared . Storage ;
using Microsoft . AspNetCore . Http ;
public static class UploadFileEndpoint
{
public static RouteHandlerBuilder MapUploadFileEndpoint (
this IEndpointRouteBuilder endpoints )
{
return endpoints . MapPost ( "/upload" ,
async ( IFormFile file , IStorageService storage , CancellationToken ct ) =>
{
if ( file . Length == 0 )
{
return Results . BadRequest ( "No file uploaded." );
}
using var memoryStream = new MemoryStream ();
await file . CopyToAsync ( memoryStream , ct );
var uploadRequest = new FileUploadRequest
{
Name = Path . GetFileNameWithoutExtension ( file . FileName ),
Extension = Path . GetExtension ( file . FileName ),
Data = memoryStream . ToArray ()
};
var filePath = await storage . UploadAsync < object >(
uploadRequest ,
FileType . Document ,
ct );
return Results . Ok ( new { FilePath = filePath });
})
. WithName ( "UploadFile" )
. WithSummary ( "Upload a file" )
. DisableAntiforgery (); // Required for file uploads
}
}
Provider Configuration
Local File Storage
Stores files in the application’s Files directory:
// Files stored at: {AppRoot}/Files/{FileType}/{EntityType}/{FileName}
// Example: /Files/Images/Product/product-123.jpg
Configure Provider
{
"Storage" : {
"Provider" : "local"
}
}
Serve Static Files
app . UseStaticFiles ( new StaticFileOptions
{
FileProvider = new PhysicalFileProvider (
Path . Combine ( Directory . GetCurrentDirectory (), "Files" )),
RequestPath = "/files"
});
Access Files
Files are accessible at: https://yourapp.com/files/Images/Product/product-123.jpg
Amazon S3
Install AWS SDK
dotnet add package AWSSDK.S3
Create S3 Bucket
Create a bucket in AWS S3 console or via CLI: aws s3 mb s3://my-app-files --region us-east-1
Configure Credentials
Set AWS credentials via environment variables or AWS CLI: export AWS_ACCESS_KEY_ID = your-access-key
export AWS_SECRET_ACCESS_KEY = your-secret-key
Update Configuration
{
"Storage" : {
"Provider" : "s3" ,
"S3" : {
"Bucket" : "my-app-files" ,
"Region" : "us-east-1"
}
}
}
MinIO (Self-Hosted S3)
MinIO is S3-compatible and can be used for local development:
services :
minio :
image : minio/minio:latest
ports :
- "9000:9000"
- "9001:9001"
environment :
MINIO_ROOT_USER : minioadmin
MINIO_ROOT_PASSWORD : minioadmin
command : server /data --console-address ":9001"
volumes :
- minio-data:/data
volumes :
minio-data :
appsettings.Development.json
{
"Storage" : {
"Provider" : "s3" ,
"S3" : {
"Bucket" : "dev-files" ,
"ServiceUrl" : "http://localhost:9000" , // MinIO endpoint
"ForcePathStyle" : true // Required for MinIO
}
}
}
# Create bucket in MinIO
mc alias set minio http://localhost:9000 minioadmin minioadmin
mc mb minio/dev-files
File Path Structure
Files are organized by type and entity:
Local Storage:
/Files/{FileType}/{EntityType}/{FileName}
Examples:
/Files/Images/Product/product-123-abc.jpg
/Files/Documents/Invoice/invoice-456-def.pdf
/Files/Videos/Course/course-789-ghi.mp4
S3 Storage:
{Bucket}/{FileType}/{EntityType}/{FileName}
Examples:
s3://my-app-files/Images/Product/product-123-abc.jpg
s3://my-app-files/Documents/Invoice/invoice-456-def.pdf
Best Practices
Validate File Types
Always validate file extensions and MIME types before uploading.
Limit File Sizes
Enforce maximum file size limits (e.g., 10MB for images, 100MB for videos).
Use Unique Names
Append GUIDs or timestamps to prevent filename conflicts.
Store Metadata
Save file paths, sizes, and MIME types in your database.
Handle Cleanup
Delete orphaned files when entities are deleted.
Use CDN for Production
Serve files through a CDN (CloudFront, CloudFlare) for better performance.
Security Considerations
1. Validate File Content
public static class FileValidator
{
private static readonly string [] AllowedImageExtensions = { ".jpg" , ".jpeg" , ".png" , ".gif" };
private static readonly long MaxFileSizeBytes = 10 * 1024 * 1024 ; // 10 MB
public static bool IsValidImage ( string fileName , byte [] data )
{
var extension = Path . GetExtension ( fileName ). ToLowerInvariant ();
if ( ! AllowedImageExtensions . Contains ( extension ))
{
return false ;
}
if ( data . Length > MaxFileSizeBytes )
{
return false ;
}
// Validate image header (magic bytes)
return IsValidImageHeader ( data );
}
private static bool IsValidImageHeader ( byte [] data )
{
if ( data . Length < 4 ) return false ;
// JPEG: FF D8 FF
if ( data [ 0 ] == 0xFF && data [ 1 ] == 0xD8 && data [ 2 ] == 0xFF )
return true ;
// PNG: 89 50 4E 47
if ( data [ 0 ] == 0x89 && data [ 1 ] == 0x50 && data [ 2 ] == 0x4E && data [ 3 ] == 0x47 )
return true ;
return false ;
}
}
2. Sanitize File Names
var sanitizedName = Path . GetFileNameWithoutExtension ( fileName )
. Replace ( " " , "-" )
. Replace ( "_" , "-" )
. ToLowerInvariant ();
var uniqueName = $" { sanitizedName } - { Guid . NewGuid () : N }{ extension } " ;
3. Scan for Malware
Integrate with antivirus services (ClamAV, VirusTotal) for uploaded files.
Testing
Mock Storage Service
using FSH . Framework . Storage . Services ;
using NSubstitute ;
public class ProductServiceTests
{
[ Fact ]
public async Task Should_Upload_Product_Image ()
{
// Arrange
var mockStorage = Substitute . For < IStorageService >();
mockStorage . UploadAsync < Product >( Arg . Any < FileUploadRequest >(), Arg . Any < FileType >(), Arg . Any < CancellationToken >())
. Returns ( "images/product/test.jpg" );
var handler = new UploadProductImageHandler ( mockStorage , _db );
// Act
var result = await handler . Handle ( command , CancellationToken . None );
// Assert
Assert . Equal ( "images/product/test.jpg" , result );
}
}
Troubleshooting
Local Storage: Permission Denied
Ensure the application has write permissions to the Files directory:
S3: Access Denied
Verify AWS credentials are configured
Check IAM permissions for S3 bucket
Ensure bucket policy allows uploads
MinIO: Connection Refused
Verify MinIO is running: docker ps
Check endpoint URL in configuration
Ensure bucket exists: mc ls minio/
Package Reference
< ItemGroup >
< ProjectReference Include = "..\..\BuildingBlocks\Storage\FSH.Framework.Storage.csproj" />
</ ItemGroup >
Jobs Building Block Process large files in background jobs
Web Building Block Upload files via Minimal APIs
AWS S3 Documentation Official AWS S3 documentation
MinIO Documentation Self-hosted S3-compatible storage