File storage
Store, download and delete files under a key, and give a time-limited link to one, through one contract over S3, MinIO or Azure Blob Storage.
PouryaKit.Storage gives Feature code one contract, IFileStorage: upload a stream under a key, download it, delete it, and get a presigned link that reads it without credentials until it expires. Every call is cancellable and returns a Result for what a Feature expects, so a missing file is StorageErrors.FileNotFound, an Error, never an exception; a failure of the store itself throws (The calls). A provider package supplies the Host's one implementation: PouryaKit.Storage.S3 for AWS S3 and S3-compatible stores such as MinIO, or PouryaKit.Storage.AzureBlob for Azure Blob Storage. Feature code is the same on either, and never names a cloud SDK.
A file is never part of the commit
No file store joins the Unit of Work's transaction. Store the file first, under a fresh key, and only then record the key in the command's state. A command that fails after the upload leaves an object no record names, which your application's retention removes.
When to use it
- A Feature stores a file, downloads it or deletes it under a key.
- A browser or another system needs a time-limited link to read a file.
- A command records a stored file's key, and a failed command must not leave a record of a missing file.
- You choose and configure the S3, MinIO or Azure Blob provider.
- You test against RustFS or Azurite.
How it works
Register one provider in the Host
Bind the provider's options from configuration and call AddPouryaKitS3Storage or AddPouryaKitAzureBlobStorage. A Host composes exactly one provider: a second one fails when its container is built, naming both, and invalid options stop the Host when it starts.
Upload before you record
In a command's handler, upload under a key the Feature chooses, and record the key only once the upload returned. A recorded key then always names a stored file.
Delete in reverse order
Remove the record in the command, then delete the object once the Operation committed, for example from a Notification Handler, so a rolled-back command never loses a file its record still names.
In the Kit's tests
The S3 provider against a real S3-compatible store, RustFS in a container, through the same contract a Feature uses:
[Fact]
public async Task An_uploaded_file_downloads_with_its_content_and_content_type()
{
var key = Key();
Assert.True(
(
await fixture.Storage.UploadAsync(
key,
new MemoryStream(Report),
"application/pdf",
Cancel
)
).IsSuccess
);
var download = await fixture.Storage.DownloadAsync(key, Cancel);
await using var file = download.Value;
Assert.Equal("application/pdf", file.ContentType);
Assert.Equal(Report, await ReadAll(file.Content));
}Guarantees and failures
| You want to know | Read |
|---|---|
| How each provider is configured, and the build failure of a second one | Registering, One provider |
| How a command records a stored file safely | Store, then record |
| What each call returns, and how long a link may live | The calls |
| What the Kit guarantees, and what it leaves to your retention | What the Kit guarantees |
| What the contract does not offer | Limits |
Next steps
S3 or Azure Blob
Which provider fits where your application runs.
Report Module
Report jobs that store their files through the Host's one provider.
Reference
PouryaKit.Storage: one contract, IFileStorage, that uploads, downloads and deletes a file under a key and grants a time-limited link to read it, every call cancellable and returning a Result for what a Feature expects, so Feature code never names a cloud SDK. Namespace PouryaKit.Storage; it references PouryaKit.Domain, and PouryaKit.Cqrs for the one-provider composition check, and no cloud SDK. A provider package supplies the Host's one implementation: PouryaKit.Storage.S3 (namespace PouryaKit.Storage.S3) over AWSSDK.S3, for AWS S3 and S3-compatible stores such as MinIO, or PouryaKit.Storage.AzureBlob (namespace PouryaKit.Storage.AzureBlob) over Azure.Storage.Blobs, for Azure Blob Storage and Azurite. Feature code is the same on either.
Registering
On the Host, bind the provider's options from configuration, then register it.
S3
using PouryaKit.Storage.S3;
builder.Services.Configure<PouryaKitS3StorageOptions>(builder.Configuration.GetSection("PouryaKit:Storage:S3"));
builder.Services.AddPouryaKitS3Storage();{
"PouryaKit": {
"Storage": {
"S3": { "Bucket": "acme-files", "Region": "eu-central-1" }
}
}
}BucketandRegionare required. On AWS, leaveServiceUrlunset and credentials come from the AWS SDK's default chain (environment, profile, the instance's or task's role). For an S3-compatible store, setServiceUrl(http://minio:9000),ForcePathStyletotruefor MinIO, andAccessKeywithSecretKey;Regionis then the region requests are signed for, such asus-east-1.- The bucket must exist: the Kit creates none.
- The options are validated when the Host starts, which fails naming each problem: no bucket or region, a service URL that is not an absolute HTTP or HTTPS address, or an access key without its secret key or the reverse. The client is built from them when
IFileStorageis first resolved.
Azure Blob Storage
using PouryaKit.Storage.AzureBlob;
builder.Services.Configure<PouryaKitAzureBlobStorageOptions>(builder.Configuration.GetSection("PouryaKit:Storage:AzureBlob"));
builder.Services.AddPouryaKitAzureBlobStorage();{
"PouryaKit": {
"Storage": {
"AzureBlob": { "Container": "acme-files", "ConnectionString": "DefaultEndpointsProtocol=https;AccountName=acme;AccountKey=...;EndpointSuffix=core.windows.net" }
}
}
}Containeris required, and must exist: the Kit creates none.- Reach the account with
ConnectionString, which must carry the account key, or withServiceUrl(https://acme.blob.core.windows.net) andAccountNamewithAccountKey; set one form, never both. The account key signs every presigned link, so a connection string with only a SAS token, or Microsoft Entra credentials, are not options: the package takes noAzure.Identity. - The options are validated when the Host starts, which fails naming each problem: no container, both or neither of the two forms, a connection string that does not parse or holds no account key, a service URL that is not an absolute HTTP or HTTPS address, an account name or key without the service URL or the reverse, or an account key that is not base64, which the
StorageSharedKeyCredentialthe client signs with decodes. The client is built from them whenIFileStorageis first resolved.
One provider
- A Host composes exactly one provider. Each provider registers through
AddPouryaKitFileStorage(factory)ofPouryaKit.Storage, which adds a composition check, so a Host that registers anotherIFileStorage, before or after the call, a second call of its own included, fails when its container is built, naming each registration's type, or the factory that builds it; so does a Host that removes everyIFileStorageafter the call, naming the contract. Registering bothAddPouryaKitS3StorageandAddPouryaKitAzureBlobStorage, in either order, fails the build naming both factories. A Host's own provider registers throughAddPouryaKitFileStoragetoo, to take the same check. A Host whose code needsIFileStorageand that registers none fails when its container is built: a Kit Host validates its container then, andIFileStoragehas no registration to resolve.
Store, then record
No file store joins the Unit of Work's commit, so a file is never transactional. Store the file first, under a key the Feature chooses, and only then record the key in the command's own state:
internal sealed class AttachContractCommandHandler(IFileStorage files, IRepository<Customer, CustomerId> customers)
: CommandHandler<AttachContractCommand>
{
public override async Task<Result> Handle(AttachContractCommand command, CancellationToken cancellationToken)
{
var customer = await customers.FindAsync(new CustomerId(command.CustomerId), cancellationToken);
if (customer is null)
{
return CustomerErrors.NotFound;
}
var key = $"contracts/{command.CustomerId}/{Guid.CreateVersion7()}.pdf";
var stored = await files.UploadAsync(key, command.Content, "application/pdf", cancellationToken);
return stored.IsFailure ? stored : customer.AttachContract(key);
}
}- A recorded key always names a stored file: the command records it only after the upload returned.
- A command that fails or rolls back after the upload leaves its object behind, unnamed by any record. The application's retention removes such objects, for example a recurring command that deletes the keys of attempts it can derive from its own records, or a lifecycle rule of the store on a prefix the application stages files under.
IFileStoragehas no listing. - A fresh key per upload keeps a retried or concurrent command from replacing a file another record names; an upload under an existing key replaces that file.
- Deleting a recorded file follows the same order in reverse: remove the record in the command, then delete the object once the Operation committed, for example from a Notification Handler, so a rolled-back command never loses a file its record still names.
The calls
UploadAsync(key, content, contentType, cancellationToken)stores the stream from its current position to its end. The caller keeps and disposes the stream.DownloadAsync(key, cancellationToken)returns aStoredFilewith itsContentstream andContentType, orStorageErrors.FileNotFound, anErrorKind.NotFoundError, when no file is stored under the key. Dispose theStoredFile: its stream is read from the store as the caller reads it.DeleteAsync(key, cancellationToken)succeeds whether or not a file was stored under the key, so retention can run again.GetPresignedUrlAsync(key, lifetime, cancellationToken)returns a link that reads the file with a plain HTTP GET, without credentials, untillifetimehas passed, and at most the lifetime of the credentials that signed it: a link signed with temporary credentials, such as an instance or task role's from the SDK's default chain, stops working when they expire, whatever lifetime was asked for (AWS documents it). The store is not asked whether the file exists: a link to a missing file answers not found when followed. A token cancelled before the call fails it; one cancelled while the SDK resolves credentials stops the wait, though the SDK may still sign. A lifetime of zero or less, or longer than the provider signs a link for, fails withStorageErrors.LinkLifetimeOutOfRange, anErrorKind.ValidationError, and signs nothing: S3 signs a link for at most seven days, and Azure Blob too: Azure sets no limit on a link signed with the account key, and the Kit bounds it at the seven days of a user delegation SAS, so a lifetime is valid on either provider. An Azure link is a service SAS that reads the one blob.- A blank key or content type is a bug and throws
ArgumentException. A failure of the store itself (a refused credential, a missing bucket, an unreachable endpoint) throws the provider's exception: it is infrastructure, not a business outcome.
What the Kit guarantees
- The contract and the provider's behaviour. Upload, download, delete and the presigned link behave as above against a real S3-compatible store and against Azurite; a missing file is the not-found Error, never an exception.
- One provider, validated before serving. A Host that registers a second provider, in either order, fails when its container is built, naming both, and invalid options stop the Host when it starts.
- Not a transactional file. The Kit never stores a file in the Unit of Work's commit, and never removes an object a failed command left behind: that is store-then-record and the application's retention.
Limits
- No listing, metadata or existence call: a Feature records what it stores.
- The key is the application's: the Kit neither scopes it by tenant nor checks it beyond refusing a blank one. A tenant-scoped Module puts the tenant in its keys, or keeps them in its tenant-scoped records only.
- A presigned link grants anyone who holds it read access until it expires; it cannot be revoked short of deleting the file.
Testing
The Kit's tests run the provider against RustFS, an S3-compatible store with no Testcontainers module, through Testcontainers' generic ContainerBuilder("rustfs/rustfs:1.0.2"): set its credentials with RUSTFS_ACCESS_KEY and RUSTFS_SECRET_KEY, wait for an HTTP 200 from /health/ready on port 9000, create the bucket with those credentials, then compose a Host with ServiceUrl the mapped http://host:port, ForcePathStyle true and Region us-east-1. A presigned link to a local store is HTTP, since the service URL is; fetch it with a plain HttpClient to prove it reads the file without credentials.
The Kit's tests run the Azure provider against Azurite through Testcontainers.Azurite's AzuriteBuilder("mcr.microsoft.com/azure-storage/azurite:3.37.0") with WithCommand("--skipApiVersionCheck"), so Azurite accepts the service version the Azure SDK sends. Create the container with a BlobServiceClient over the container's GetConnectionString(), and bind that string as ConnectionString; the ServiceUrl form takes GetBlobEndpoint() with the emulator's account devstoreaccount1 and its published key.
Caching
Cache a query's successes per Actor, tenant and culture over HybridCache, and evict them by the cache tags a command declares, once its Operation commits.
Send a plain-text mail from a Notification Handler through one contract, on the Host's MailKit sender configured once from validated options.