Storing Files
Complete developer integration reference for IObjectStorageProvider
The Three Operations
IObjectStorageProvider exposes exactly three methods. Every file workflow in BizFirst AI—whether from a
Flow node, an agent callback, or an API controller—uses these three primitives. Understanding them fully is the
entire integration surface.
| Method | Signature | When to Use | Return Type |
|---|---|---|---|
| StoreAsync | StoreAsync(byte[], string, StoreOptions?, CancellationToken) |
Persisting any file (PDF, image, JSON, binary) to storage. Safe to call multiple times with the same bytes—deduplication is automatic. | Task<string> — the CID |
| GetStreamAsync | GetStreamAsync(string, CancellationToken) |
Retrieving a file by CID. Use to serve downloads, pass bytes to downstream processors, or attach files to responses. | Task<Stream> — a MemoryStream |
| ExistsAsync | ExistsAsync(string, CancellationToken) |
Checking whether a CID is present before retrieval. Use to guard against invalid CIDs, give clean 404s, or verify deduplication results. | Task<bool> |
StoreAsync — Storing a File
Method Signature
Task<string> StoreAsync(
byte[] content,
string contentType,
StoreOptions? options = null,
CancellationToken ct = default)
How It Works
StoreAsync returns a Content ID (CID) — a stable string that uniquely identifies the
bytes you provided. The CID is deterministic: identical bytes always produce the identical CID, regardless of when
or by whom the file was stored. This means:
- Storing the same invoice PDF twice results in exactly one object in storage — zero duplication cost.
- The CID is stable across restores and migrations — it never changes for a given file.
- Any service holding the CID can retrieve the file, without needing to know how it was stored.
Internally, the pipeline executes in this order:
- Capacity guard — checks current disk usage against
WriteStopThresholdPercent. ThrowsStorageCapacityExceptionif the threshold is exceeded. - SHA-256 hash — computes the CID from the raw bytes using
ICidProvider. - ExistsAsync check — looks up the object key in the configured S3 bucket. If it already exists, returns the CID immediately without re-uploading.
- PutObject — if the file is new, streams it to the S3 backend with the specified
contentType, ACL, and storage class. - Return CID — returns the stable content-addressable identifier to the caller.
Example 1 — Basic Store (PDF)
// Inject IObjectStorageProvider via constructor
string cid = await _storage.StoreAsync(
pdfBytes,
"application/pdf",
ct: ct);
// Persist the CID to your entity
invoice.DocumentCid = cid;
Example 2 — Store with Custom StoreOptions
var opts = new StoreOptions(
CannedAcl: "private",
StorageClass: "INTELLIGENT_TIERING");
string cid = await _storage.StoreAsync(
reportBytes,
"application/pdf",
opts,
ct);
Example 3 — Store an Image
// Store a PNG thumbnail — content type drives browser behaviour on download
string thumbnailCid = await _storage.StoreAsync(
imageBytes,
"image/png",
ct: ct);
product.ThumbnailCid = thumbnailCid;
GetStreamAsync — Retrieving a File
Method Signature
Task<Stream> GetStreamAsync(string cid, CancellationToken ct = default)
GetStreamAsync returns a MemoryStream containing the full file bytes for the given CID.
The stream is positioned at the beginning (position 0) and is ready to read. Always dispose the stream when done.
Basic Usage — Dispose Pattern
await using Stream stream = await _storage.GetStreamAsync(cid, ct);
// stream is positioned at 0, ready to read
await stream.CopyToAsync(outputStream, ct);
Convert to byte[]
await using Stream stream = await _storage.GetStreamAsync(cid, ct);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms, ct);
byte[] bytes = ms.ToArray();
Serve as HTTP Response (FileStreamResult)
public async Task<IActionResult> DownloadFile(string cid, CancellationToken ct)
{
if (!await _storage.ExistsAsync(cid, ct))
return NotFound();
Stream stream = await _storage.GetStreamAsync(cid, ct);
// FileStreamResult disposes the stream after the response is sent
return new FileStreamResult(stream, "application/pdf")
{
FileDownloadName = "document.pdf"
};
}
GetStreamAsync is a MemoryStream backed by the full file bytes.
For files over 100 MB, consider using the underlying IS3ObjectService.DownloadStreamAsync directly
to avoid buffering the full file in memory. This streams bytes directly from the S3 backend to the HTTP response
without loading the entire file into heap memory.
ExistsAsync — Checking Existence
Method Signature
Task<bool> ExistsAsync(string cid, CancellationToken ct = default)
ExistsAsync performs a lightweight HeadObject call against the S3 backend — it does not
download the file. Use it in the following situations:
- Before GetStreamAsync — Give the caller a clean
404 Not Foundrather than letting an S3 exception surface. This is the most common use case. - Deduplication verification — After computing a CID yourself (via
ICidProvider), check whether the file already exists before callingStoreAsync. - Pre-flight checks — Verify the asset exists before starting an expensive processing pipeline (PDF rendering, OCR, etc.).
- Soft-delete validation — Confirm a file is still accessible before issuing a download link to the user.
Standard Guard Pattern
if (!await _storage.ExistsAsync(cid, ct))
return NotFound($"File {cid} not found.");
Stream stream = await _storage.GetStreamAsync(cid, ct);
Because ExistsAsync is a HeadObject, it is extremely fast and does not count toward data
transfer costs on any S3-compatible provider. It is safe to call on every download request.
StoreOptions — Per-Request Overrides
Record Definition
public sealed record StoreOptions(
string? CannedAcl = null,
string? StorageClass = null)
StoreOptions allows you to override the global defaults from appsettings.json on a
per-call basis. When a field is null, the global default is used. This is useful when one category
of files (e.g., archival reports) needs different storage behaviour than the rest.
| Field | Type | Null Behaviour | Common Values |
|---|---|---|---|
CannedAcl |
string? |
Uses global FileStorage:CannedAcl from configuration |
private, public-read, authenticated-read |
StorageClass |
string? |
Uses global FileStorage:StorageClass from configuration |
STANDARD, REDUCED_REDUNDANCY, INTELLIGENT_TIERING, GLACIER |
Example 1 — Force INTELLIGENT_TIERING for Archival Items
// Long-term archival reports — let AWS automatically move to cheaper tiers
var archiveOpts = new StoreOptions(StorageClass: "INTELLIGENT_TIERING");
string cid = await _storage.StoreAsync(
yearEndReportBytes,
"application/pdf",
archiveOpts,
ct);
Example 2 — Force private ACL for Sensitive Documents
// Payroll documents must never be publicly accessible
var sensitiveOpts = new StoreOptions(
CannedAcl: "private",
StorageClass: "STANDARD");
string cid = await _storage.StoreAsync(
payrollPdfBytes,
"application/pdf",
sensitiveOpts,
ct);
STANDARD and REDUCED_REDUNDANCY storage classes are supported.
AWS-specific classes (INTELLIGENT_TIERING, GLACIER, DEEP_ARCHIVE) require
AWS S3 as the backend. Passing an unsupported class to MinIO will result in the object being stored with
STANDARD class silently. Always test StorageClass overrides against your configured backend.
Error Handling
IObjectStorageProvider surfaces three categories of errors. Each requires a different response
strategy at the API layer.
StorageCapacityException
Thrown by StoreAsync when disk usage has reached or exceeded WriteStopThresholdPercent
(default: 90%). This is a hard stop — the write is refused before any data is transmitted to the S3 backend.
Callers should return HTTP 507 Insufficient Storage.
This exception will not appear during normal operations. It indicates that the MinIO volume is nearly full and an administrator must take action (expand storage or remove obsolete files).
OperationCanceledException
Thrown when the CancellationToken is signalled (e.g., the HTTP client disconnected). This exception
always propagates — it is never swallowed internally. Handle it at the API layer and return HTTP 499 (or 400).
S3 / Network Exceptions
Any exception other than the above two indicates an S3 connectivity or configuration issue (wrong bucket name, invalid credentials, network timeout). Log the full exception with context (CID, tenant, operation) and return an appropriate 500-level response. Do not expose S3 internals in the API response body.
Complete Controller-Level Error Handling
public async Task<IActionResult> UploadDocument([FromBody] UploadRequest req, CancellationToken ct)
{
try
{
string cid = await _storage.StoreAsync(req.Bytes, req.ContentType, ct: ct);
return Ok(new { cid });
}
catch (StorageCapacityException)
{
return StatusCode(507, "Storage capacity exceeded. Contact your administrator.");
}
catch (OperationCanceledException)
{
return StatusCode(499, "Request cancelled.");
}
catch (Exception ex)
{
_logger.LogError(ex, "StoreAsync failed for tenant {TenantID}", _tenantContext.TenantID);
return StatusCode(500, "File storage temporarily unavailable.");
}
}
Working with Streams (Incoming Files)
In ASP.NET Core controllers, incoming files arrive as IFormFile. The pattern below correctly reads
the uploaded bytes into a MemoryStream, then calls StoreAsync. The returned CID can
be persisted immediately to your entity.
IFormFile Upload Pattern
public async Task<IActionResult> UploadFile(IFormFile file, CancellationToken ct)
{
using var ms = new MemoryStream();
await file.CopyToAsync(ms, ct);
byte[] bytes = ms.ToArray();
string cid = await _storage.StoreAsync(bytes, file.ContentType, ct: ct);
return Ok(new { cid, size = bytes.Length });
}
For multipart uploads with multiple files, loop over Request.Form.Files and call
StoreAsync for each file. Each call is independent and can be awaited in parallel using
Task.WhenAll for throughput:
var storeTasks = Request.Form.Files
.Select(async f =>
{
using var ms = new MemoryStream();
await f.CopyToAsync(ms, ct);
string cid = await _storage.StoreAsync(ms.ToArray(), f.ContentType, ct: ct);
return new { FileName = f.FileName, Cid = cid };
});
var results = await Task.WhenAll(storeTasks);
foreach + await rather than Task.WhenAll to avoid large memory spikes.
CID Storage Patterns
A CID is just a string — a SHA-256 hex hash, typically 64 characters. It is lightweight, stable, and safe to store anywhere you can store a string. Here are the three standard patterns used across the BizFirst AI platform.
Pattern 1 — Single CID Column on an Entity
The simplest pattern: add a nullable string column to your entity for each document slot.
The CID functions as a pointer into the object store.
public class InvoiceEntity
{
public int InvoiceID { get; set; }
public string? DocumentCid { get; set; } // points to the PDF in storage
public string? SignedCopyCid { get; set; } // separate slot for signed version
}
Pattern 2 — Multiple CIDs (Attachments Array)
When an entity has a variable number of attachments, store the CIDs as a serialized array or a related table:
public class SupportTicketEntity
{
public int TicketID { get; set; }
// Option A: JSON column (EF Core 8+ natively supported)
public List<string> AttachmentCids { get; set; } = new();
// Option B: Related table (better for querying individual attachments)
public ICollection<TicketAttachmentEntity> Attachments { get; set; } = new List<TicketAttachmentEntity>();
}
Pattern 3 — CID as a Foreign Key to a Files Table
The most complete pattern. Store a FilesEntity record alongside the CID, capturing the original
filename, content type, upload context, and size. This enables file management UIs without round-tripping to
the storage backend for metadata.
public class FileMetadataEntity
{
public int FileID { get; set; }
public string Cid { get; set; } = ""; // FK into object storage
public string OriginalName { get; set; } = "";
public string ContentType { get; set; } = "";
public long SizeBytes { get; set; }
public DateTime UploadedAt { get; set; }
public int UploadedBy { get; set; } // FK to Users table
}
With this pattern, your entity simply holds a FileID foreign key. File management (rename, delete
metadata, audit who uploaded what) is handled through the FileMetadataEntity without touching the
object store.
FileMetadataEntity, not in the CID itself.
Thread Safety
IObjectStorageProvider is registered as Scoped in the DI container — one instance
per HTTP request. Do not capture it in a singleton, background service, or static field.
| Service | DI Lifetime | Thread Safety | Notes |
|---|---|---|---|
IObjectStorageProvider |
Scoped | One instance per request — safe within a single request context | Do not share across threads or background tasks |
ICidProvider |
Singleton | Fully thread-safe — stateless SHA-256 computation | Safe to use from any context, including background services |
IS3ObjectService |
Transient | Each call gets a fresh instance | Wraps the S3 HTTP client; safe to use concurrently via the provider |
If you need to store files from a background service (e.g., a BackgroundService or
IHostedService), create a new DI scope for each unit of work:
using (var scope = _serviceScopeFactory.CreateScope())
{
var storage = scope.ServiceProvider.GetRequiredService<IObjectStorageProvider>();
string cid = await storage.StoreAsync(bytes, contentType, ct: ct);
}
IObjectStorageProvider into a singleton-lifetime class is a classic ASP.NET Core
anti-pattern that leads to stale state, tenant context bleed, and difficult-to-reproduce bugs. Always inject
IServiceScopeFactory into singletons and create scopes on demand.