Skip to content

Granit.BlobStorage & Imaging

Granit.BlobStorage provides a multi-provider blob storage layer with a unified IBlobStorage API. Cloud providers (S3, Azure Blob, Google Cloud Storage) use native presigned URLs for Direct-to-Cloud uploads where the application server never touches file bytes. Server-side providers (FileSystem, Database) use Granit.BlobStorage.Proxy to expose token-based upload/download endpoints with identical client-side flow. A post-upload validation pipeline (magic bytes, size) ensures integrity before marking blobs as valid. Granit.Imaging adds a fluent image processing pipeline (resize, crop, compress, format conversion, EXIF stripping) powered by Magick.NET.

Traditional file upload architectures stream bytes through the application server, which creates several problems at scale:

ProblemTraditional uploadGranit Direct-to-Cloud
Server memoryEntire file buffered in RAMServer handles only metadata (< 1 KB)
Bandwidth costsBytes transit through your servers twice (in + out)Client uploads directly to storage; server bandwidth near zero
Horizontal scalingUpload throughput limited by server capacityUnlimited — storage provider handles all I/O
Timeout riskLarge uploads blocked by server timeoutsUpload goes directly to storage with its own timeout window
Infrastructure costNeed large instances for file processingMinimal compute — only metadata and validation

For cloud providers (S3, Azure Blob), bytes never touch the application server. For server-side providers (FileSystem, Database), Granit.BlobStorage.Proxy streams bytes through thin proxy endpoints without RAM buffering, keeping the architecture consistent while minimizing server resource usage.

  • DirectoryGranit.BlobStorage/ Core abstractions, validation pipeline, domain model
    • Granit.BlobStorage.S3 S3 client, native presigned URLs
    • Granit.BlobStorage.AzureBlob Azure Blob Storage client, SAS tokens
    • Granit.BlobStorage.GoogleCloud Google Cloud Storage client, signed URLs
    • Granit.BlobStorage.FileSystem Local file system storage
    • Granit.BlobStorage.Database EF Core database storage (small files)
    • Granit.BlobStorage.Proxy Token-based proxy endpoints for server-side providers
    • Granit.BlobStorage.EntityFrameworkCore Isolated DbContext, EF persistence (metadata)
  • DirectoryGranit.Imaging/ Image processing abstractions, fluent pipeline API
    • Granit.Imaging.MagickNet Magick.NET implementation (JPEG/PNG/WebP/AVIF/GIF/BMP/TIFF)
PackageRoleDepends on
Granit.BlobStorageIBlobStorage, IBlobValidator, BlobDescriptor domainGranit.Core
Granit.BlobStorage.S3S3 native presigned URLs, PrefixBlobKeyStrategyGranit.BlobStorage
Granit.BlobStorage.AzureBlobAzure Blob SAS tokens, Managed Identity supportGranit.BlobStorage
Granit.BlobStorage.GoogleCloudGoogle Cloud Storage signed URLs, Workload Identity supportGranit.BlobStorage
Granit.BlobStorage.FileSystemLocal file system storage, path traversal protectionGranit.BlobStorage
Granit.BlobStorage.DatabaseEF Core database storage (small files, 10 MB default)Granit.BlobStorage, Granit.Persistence
Granit.BlobStorage.ProxyToken-based proxy endpoints for FileSystem/DatabaseGranit.BlobStorage
Granit.BlobStorage.EntityFrameworkCoreBlobStorageDbContext, EfBlobDescriptorStoreGranit.BlobStorage, Granit.Persistence
Granit.ImagingIImageProcessor, IImagePipeline fluent APIGranit.Core
Granit.Imaging.MagickNetMagickNetImageProcessor (singleton)Granit.Imaging
ProviderPresigned URLsMax file sizeUse case
S3Native (AWS SigV4)UnlimitedProduction cloud storage
Azure BlobNative (SAS tokens)UnlimitedAzure-hosted deployments
Google CloudNative (V4 signed URLs)UnlimitedGCP-hosted deployments
FileSystemVia ProxyDisk capacityDevelopment, on-premise
DatabaseVia Proxy10 MB (configurable)Small files, regulated environments

Cloud providers (S3, Azure, Google Cloud) generate native presigned/SAS/signed URLs — the application server never touches file bytes. Server-side providers (FileSystem, Database) require Granit.BlobStorage.Proxy which exposes ephemeral token-based endpoints that stream bytes through to the underlying storage. The client-side upload/download flow is identical regardless of provider.

graph TD
    BS[Granit.BlobStorage] --> CO[Granit.Core]
    S3[Granit.BlobStorage.S3] --> BS
    AZ[Granit.BlobStorage.AzureBlob] --> BS
    GC[Granit.BlobStorage.GoogleCloud] --> BS
    FS[Granit.BlobStorage.FileSystem] --> BS
    DB[Granit.BlobStorage.Database] --> BS
    DB --> P[Granit.Persistence]
    PX[Granit.BlobStorage.Proxy] --> BS
    EF[Granit.BlobStorage.EntityFrameworkCore] --> BS
    EF --> P
    IM[Granit.Imaging] --> CO
    MN[Granit.Imaging.MagickNet] --> IM
[DependsOn(
typeof(GranitBlobStorageEntityFrameworkCoreModule),
typeof(GranitImagingMagickNetModule))]
public class AppModule : GranitModule { }
builder.AddGranitBlobStorageS3();
builder.AddGranitBlobStorageEntityFrameworkCore(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("BlobStorage")));
{
"BlobStorage": {
"ServiceUrl": "https://s3.eu-west-1.amazonaws.com",
"AccessKey": "<from-vault>",
"SecretKey": "<from-vault>",
"Region": "eu-west-1",
"DefaultBucket": "myapp-blobs",
"ForcePathStyle": false,
"TenantIsolation": "Prefix",
"UploadUrlExpiry": "00:15:00",
"DownloadUrlExpiry": "00:05:00"
}
}

Cloud providers (S3, Azure) use native presigned URLs. The application server never touches file bytes:

sequenceDiagram
    participant C as Client
    participant API as Application Server
    participant ST as Storage (S3 / Azure)
    participant V as Validation Pipeline

    C->>API: POST /upload (fileName, contentType, maxBytes)
    API->>API: Create BlobDescriptor (Pending)
    API->>ST: Generate presigned/SAS URL
    ST-->>API: PresignedUploadTicket
    API-->>C: { blobId, uploadUrl, headers, expiresAt }
    C->>ST: PUT file (presigned URL + required headers)
    ST-->>C: 200 OK
    C->>API: POST /upload/{blobId}/confirm
    API->>ST: HEAD object (size, metadata)
    API->>V: Run validators (magic bytes, size)
    alt All validators pass
        V-->>API: Valid
        API->>API: BlobDescriptor → Valid
        API-->>C: 200 OK
    else Validation fails
        V-->>API: Rejected
        API->>ST: DELETE object
        API->>API: BlobDescriptor → Rejected
        API-->>C: 422 (rejection reason)
    end

Server-side providers use Granit.BlobStorage.Proxy which generates ephemeral token-based URLs. The upload is streamed through the proxy endpoint:

sequenceDiagram
    participant C as Client
    participant API as Application Server
    participant PX as Proxy Endpoints
    participant ST as Storage (FS / DB)

    C->>API: POST /upload (fileName, contentType, maxBytes)
    API->>API: Create BlobDescriptor (Pending)
    API->>PX: Generate proxy token
    PX-->>API: PresignedUploadTicket (token-based URL)
    API-->>C: { blobId, uploadUrl, headers, expiresAt }
    C->>PX: PUT /api/blobs/upload/{token} (stream body)
    PX->>ST: SaveAsync (stream-through, no RAM buffering)
    ST-->>PX: Saved
    PX-->>C: 204 No Content

Proxy tokens are single-use, TTL-bound, and backed by IDistributedCache.

The primary entry point for blob operations. All operations are scoped to the current tenant resolved via ICurrentTenant.

public interface IBlobStorage
{
Task<PresignedUploadTicket> InitiateUploadAsync(
string containerName,
BlobUploadRequest request,
CancellationToken cancellationToken = default);
Task<PresignedDownloadUrl> CreateDownloadUrlAsync(
string containerName,
Guid blobId,
DownloadUrlOptions? options = null,
CancellationToken cancellationToken = default);
Task<BlobDescriptor?> GetDescriptorAsync(
string containerName,
Guid blobId,
CancellationToken cancellationToken = default);
Task DeleteAsync(
string containerName,
Guid blobId,
string? deletionReason = null,
CancellationToken cancellationToken = default);
}
public class PatientPhotoService(IBlobStorage blobStorage)
{
public async Task<PresignedUploadTicket> InitiatePhotoUploadAsync(
string fileName,
CancellationToken cancellationToken)
{
var request = new BlobUploadRequest(
FileName: fileName,
ContentType: "image/jpeg",
MaxAllowedBytes: 10 * 1024 * 1024); // 10 MB
return await blobStorage.InitiateUploadAsync(
"patient-photos", request, cancellationToken)
.ConfigureAwait(false);
}
}

The returned PresignedUploadTicket contains everything the frontend needs:

PropertyDescription
BlobIdStable identifier for polling status and requesting downloads
UploadUrlPresigned URL (S3/Azure) or proxy token URL (FileSystem/Database)
HttpMethodAlways "PUT"
ExpiresAtUTC expiry of the presigned URL
RequiredHeadersHeaders the client must include verbatim (e.g. Content-Type)
public async Task<PresignedDownloadUrl> GetPhotoDownloadUrlAsync(
Guid blobId,
CancellationToken cancellationToken)
{
return await blobStorage.CreateDownloadUrlAsync(
"patient-photos",
blobId,
new DownloadUrlOptions(DownloadFileName: "patient-photo.jpg"),
cancellationToken)
.ConfigureAwait(false);
}

Setting DownloadFileName injects a Content-Disposition: attachment; filename="..." header into the presigned URL, forcing the browser to download rather than preview.

await blobStorage.DeleteAsync(
"patient-photos",
blobId,
deletionReason: "GDPR Art. 17 erasure request",
cancellationToken);

The storage object is physically deleted (crypto-shredding). The BlobDescriptor record is retained in the database for the ISO 27001 3-year audit trail.

stateDiagram-v2
    [*] --> Pending : InitiateUploadAsync
    Pending --> Uploading : Upload notification
    Uploading --> Valid : All validators pass
    Uploading --> Rejected : Validation fails
    Valid --> Deleted : DeleteAsync (GDPR erasure)
StatusStorage objectDB recordDescription
PendingNot yet uploadedExistsUpload ticket issued, waiting for client
UploadingUploadedExistsValidation pipeline running
ValidPresentExistsReady for download
RejectedDeletedRetainedMagic bytes or size check failed
DeletedDeletedRetained (3 years)GDPR Art. 17 crypto-shredding

Blob descriptor persistence follows the CQRS pattern with separate reader and writer interfaces. Always inject the specific interface matching your intent:

// Read-only: querying blob metadata
public class BlobQueryHandler(IBlobDescriptorReader reader) { }
// Write-only: updating blob state
public class BlobCommandHandler(IBlobDescriptorWriter writer) { }

IBlobDescriptorReader also provides specialized queries for background jobs:

MethodPurpose
FindAsync(blobId)Single descriptor by ID
FindOrphanedAsync(cutoff, batchSize)Uploads stuck in Pending/Uploading (orphan cleanup)
FindByContainerBeforeAsync(container, cutoff, batchSize)GDPR retention cleanup

Validators run after the file lands on storage. The BlobValidationContext provides partial access (range read for magic bytes, HEAD for metadata) without buffering the full file in the application server.

ValidatorOrderCheck
MagicBytesValidator10Reads first 261 bytes via range read; compares magic-byte signature against declared Content-Type. Detects PDF, JPEG, PNG, GIF, TIFF, DICOM, ZIP.
MaxSizeValidator20Compares actual size against MaxAllowedBytes declared at upload time.

The pipeline is fail-fast: processing stops at the first failing validator.

Register application-specific validators (e.g. antivirus scanning) via DI:

public sealed class AntivirusValidator(IAntivirusClient av) : IBlobValidator
{
public int Order => 30;
public async Task<BlobValidationResult> ValidateAsync(
BlobValidationContext context,
CancellationToken cancellationToken = default)
{
bool isSafe = await av.ScanAsync(
context.Descriptor.ObjectKey, cancellationToken)
.ConfigureAwait(false);
return isSafe
? BlobValidationResult.Success()
: BlobValidationResult.Failure("Malware detected by antivirus scan.");
}
}
builder.Services.AddSingleton<IBlobValidator, AntivirusValidator>();

All providers use the same tenant-prefixed object key strategy:

{tenantId}/{containerName}/{yyyy}/{MM}/{blobId}

The date components distribute keys across a wider key-space prefix, reducing hot-spot partitions on large buckets.

StrategyKey formatIsolation levelProvider support
Prefix (default){tenantId}/{container}/{yyyy}/{MM}/{blobId}Key-prefixAll providers
Container{container}/{yyyy}/{MM}/{blobId}One container/bucket per tenantS3, Azure only

Single-tenant deployments (no ICurrentTenant) omit the tenant prefix: {containerName}/{yyyy}/{MM}/{blobId}.

ProviderIsolation mechanism
S3Tenant prefix in S3 object key. Bucket strategy uses one S3 bucket per tenant.
Azure BlobTenant prefix in blob name. Container strategy uses one Azure container per tenant.
Google CloudTenant prefix in GCS object name. Bucket strategy uses one GCS bucket per tenant.
FileSystemTenant prefix in directory path: {BasePath}/{tenantId}/...
DatabaseTenant prefix in ObjectKey column + IMultiTenant EF Core query filter

BlobDescriptor publishes domain events on state transitions:

EventTrigger
BlobValidated(BlobId, ContainerName, VerifiedContentType, SizeBytes)Blob passes all validators
BlobRejected(BlobId, ContainerName, RejectionReason)Validation fails
BlobDeleted(BlobId, ContainerName, DeletionReason)GDPR crypto-shredding

Subscribe via Wolverine handlers to react to blob lifecycle changes (e.g. trigger image processing after validation, notify the user on rejection).

IImageProcessor provides a fluent pipeline for image transformations. The pipeline holds native resources and must be disposed after use.

public class PatientPhotoProcessor(IImageProcessor imageProcessor)
{
public async Task<ImageResult> CreateThumbnailAsync(
Stream sourceImage,
CancellationToken cancellationToken)
{
await using IImagePipeline pipeline = imageProcessor.Load(sourceImage);
return await pipeline
.Resize(200, 200, ResizeMode.Crop)
.StripMetadata()
.Compress(quality: 80)
.ConvertTo(ImageFormat.WebP)
.ToResultAsync(cancellationToken);
}
}
MethodDescription
Resize(width, height, mode)Resize with configurable strategy
Crop(rectangle)Crop to rectangular region
Compress(quality)Set output quality (0-100)
ConvertTo(format)Change output format
Watermark(data, position, opacity)Composite watermark overlay
StripMetadata()Remove EXIF, IPTC, XMP metadata (GDPR)
ToResultAsync()Terminal: encode and return ImageResult
SaveToStreamAsync(stream)Terminal: encode and write to stream
ModeBehavior
MaxFit within bounds, preserving aspect ratio (may be smaller than target)
PadFit within bounds with transparent padding to exact dimensions
CropFill exact dimensions by resizing and center-cropping overflow
StretchStretch to exact dimensions (distorts aspect ratio)
MinResize to minimum bounds covering target dimensions entirely
await pipeline.SaveAsWebPAsync(cancellationToken); // ConvertTo(WebP) + ToResultAsync
await pipeline.SaveAsAvifAsync(cancellationToken); // ConvertTo(AVIF) + ToResultAsync
await pipeline.SaveAsJpegAsync(cancellationToken); // ConvertTo(JPEG) + ToResultAsync
await pipeline.SaveAsPngAsync(cancellationToken); // ConvertTo(PNG) + ToResultAsync
FormatReadWriteNotes
JPEGYesYesLossy, no transparency
PNGYesYesLossless, transparency
WebPYesYesModern lossy/lossless, smaller than JPEG
AVIFYesYesNext-gen, best compression ratio
GIFYesYes256 colors, animation support
BMPYesYesUncompressed bitmap
TIFFYesYesLossless, medical imaging

StripMetadata() removes all EXIF, IPTC, and XMP metadata from images. This is critical for GDPR compliance: uploaded photos often contain GPS coordinates, camera serial numbers, timestamps, and other personally identifiable information.

await using IImagePipeline pipeline = imageProcessor.Load(uploadedPhoto);
ImageResult sanitized = await pipeline
.StripMetadata()
.SaveAsWebPAsync(cancellationToken);

A typical pattern: subscribe to BlobValidated events to post-process images after upload validation:

public static class BlobValidatedHandler
{
public static async Task HandleAsync(
BlobValidated @event,
IBlobStorage blobStorage,
IImageProcessor imageProcessor,
CancellationToken cancellationToken)
{
if (@event.ContainerName != "patient-photos")
return;
// Download, process, re-upload as thumbnail
PresignedDownloadUrl download = await blobStorage
.CreateDownloadUrlAsync("patient-photos", @event.BlobId,
cancellationToken: cancellationToken)
.ConfigureAwait(false);
// ... fetch image from download.Url, process with imageProcessor
}
}
builder.Services.AddHealthChecks()
.AddGranitS3HealthCheck();

Verifies S3 connectivity by listing one object in the default bucket (ListObjectsV2 with MaxKeys=1). Tagged ["readiness", "startup"]. Returns Unhealthy on access denied or unreachable endpoint. Error messages are sanitized — credentials, bucket names, and endpoint URLs are never exposed.

builder.Services.AddHealthChecks()
.AddGranitGoogleCloudStorageHealthCheck();

Verifies GCS connectivity by listing one object in the default bucket. Tagged ["readiness", "startup"]. Returns Unhealthy on access denied or unreachable endpoint.

{
"BlobStorage": {
"ServiceUrl": "https://s3.eu-west-1.amazonaws.com",
"AccessKey": "<from-vault>",
"SecretKey": "<from-vault>",
"Region": "eu-west-1",
"DefaultBucket": "myapp-blobs",
"ForcePathStyle": false,
"TenantIsolation": "Prefix",
"UploadUrlExpiry": "00:15:00",
"DownloadUrlExpiry": "00:05:00"
}
}
PropertyDefaultDescription
ServiceUrlS3-compatible endpoint URL (required)
AccessKeyS3 access key (required, inject from Vault)
SecretKeyS3 secret key (required, inject from Vault)
Region"us-east-1"S3 region for request signing
DefaultBucketS3 bucket name (required)
ForcePathStyletruePath-style URLs (host/bucket/key); required for MinIO
TenantIsolationPrefixPrefix (shared bucket) or Bucket (one per tenant)
PropertyDefaultDescription
UploadUrlExpiry00:15:00Presigned upload URL / proxy token TTL
DownloadUrlExpiry00:05:00Presigned download URL / proxy token TTL
CategoryKey typesPackage
ModuleGranitBlobStorageModule, GranitBlobStorageS3Module, GranitBlobStorageAzureBlobModule, GranitBlobStorageGoogleCloudModule, GranitBlobStorageFileSystemModule, GranitBlobStorageDatabaseModule, GranitBlobStorageProxyModule, GranitBlobStorageEntityFrameworkCoreModule
StorageIBlobStorage, IBlobKeyStrategyGranit.BlobStorage
Persistence (CQRS)IBlobDescriptorReader, IBlobDescriptorWriter, IBlobDescriptorStoreGranit.BlobStorage
DomainBlobDescriptor, BlobStatus, BlobUploadRequestGranit.BlobStorage
Presigned URLsPresignedUploadTicket, PresignedDownloadUrl, DownloadUrlOptionsGranit.BlobStorage
ValidationIBlobValidator, BlobValidationResult, BlobValidationContext, MagicBytesValidator, MaxSizeValidatorGranit.BlobStorage
EventsBlobValidated, BlobRejected, BlobDeletedGranit.BlobStorage
S3 optionsS3BlobOptions, BlobTenantIsolationGranit.BlobStorage.S3
Azure optionsAzureBlobOptionsGranit.BlobStorage.AzureBlob
Google Cloud optionsGoogleCloudStorageOptionsGranit.BlobStorage.GoogleCloud
FileSystem optionsFileSystemBlobOptionsGranit.BlobStorage.FileSystem
Database optionsDatabaseBlobOptionsGranit.BlobStorage.Database
Proxy optionsProxyBlobOptionsGranit.BlobStorage.Proxy
ImagingIImageProcessor, IImagePipeline, ImageResult, ImageFormat, ImageSizeGranit.Imaging
Imaging typesResizeMode, CropRectangle, WatermarkPositionGranit.Imaging
ExtensionsAddGranitBlobStorageS3(), AddGranitBlobStorageAzureBlob(), AddGranitBlobStorageGoogleCloud(), AddGranitBlobStorageFileSystem(), AddGranitBlobStorageDatabase(), AddGranitBlobStorageProxy(), AddGranitBlobStorageEntityFrameworkCore(), AddGranitImagingMagickNet(), MapGranitBlobProxyEndpoints()