Architecture Overview
On This Page
Design Philosophy
InfraHub Storage is built around four foundational decisions that govern every design choice in the codebase. Understanding them makes the rest of the architecture self-evident.
Content-Addressable Identity (CID)
Every file is identified by its content, not by a caller-assigned name or a database-generated ID. A CID (Content Identifier) is the SHA-256 hash of the file's raw bytes, rendered as a lowercase hex string. This means:
- Two files with identical content always share the same CID — deduplication is automatic and zero-cost.
- You can verify integrity at any time by re-hashing the retrieved bytes and comparing to the CID.
- CIDs are immutable: the content behind a CID never changes.
- No database lookup is required to locate a file — the CID is the key.
Tenant Isolation at the Key Level
When multi-tenancy is enabled (PrependTenantID=true), every S3 object key is prefixed with
tenant_{tenantID}/. This gives strong logical isolation within a shared bucket:
one tenant's files are physically unreachable by another tenant's prefix, IAM bucket policies can be
scoped to the prefix, and per-tenant data export or purge is a single S3 prefix operation.
When single-tenant or testing mode is used (PrependTenantID=false), the prefix is omitted
and the CID is used directly.
Single Responsibility Split
The storage pipeline is split across three narrow classes, each owning exactly one concern:
ICidProvider/Sha256CidProvider— compute the SHA-256 CID from bytes.IStorageCapacityGuard/MinioCapacityGuard— check disk capacity via Prometheus before every write.S3ObjectStorageProvider— orchestrate the store / retrieve / exists pipeline, delegating CID and capacity concerns to the two services above.
This decomposition means you can swap any piece in isolation — e.g. replace MinioCapacityGuard
with a no-op guard in tests, or replace Sha256CidProvider with a Blake3 provider — without
touching the orchestration logic.
Config-Only Backend Switch
Consumers depend only on IObjectStorageProvider from the domain project (WI-03), which has
zero external NuGet dependencies. Wiring to MinIO, AWS S3, or any S3-compatible backend
is done entirely in configuration — no code changes are required to point a deployment at a different
endpoint. This makes infrastructure migrations purely operational.
Write-Protect Before Overflow
Before writing any file, the capacity guard queries the MinIO Prometheus metrics endpoint and computes
current disk utilization. If utilization equals or exceeds WriteStopThresholdPercent, a
StorageCapacityException is thrown immediately, before any bytes are sent to S3. This
prevents a storage node from filling to 100% and entering a read-only emergency state.
The Four Work Items
The InfraHub Storage feature is delivered across four .NET projects, each with a clearly scoped responsibility. The table below summarises what lives in each project.
| Work Item | Project | Key Types & Contracts |
|---|---|---|
| WI-01 | BizFirst.Integration.S3.Domain |
S3Credential (ServiceURL, AccessKey, SecretKey, Region, ForcePathStyle) —
immutable credential record used by the S3 client factory. Result records: UploadObjectResult, DownloadObjectResult, ExistsObjectResult,
StreamObjectResult, CopyObjectResult, DeleteObjectResult,
GetManyObjectsResult, and the bucket/folder variants
(BucketExists, CreateBucketResult, ListFoldersResult,
ListFolderObjectsResult, etc.).
All results carry a Success flag and an ErrorMessage so callers
never encounter raw S3 exceptions.
|
| WI-02 | BizFirst.Integration.S3.Services |
S3ClientFactory — builds and caches an IAmazonS3 client from an
S3Credential. Supports both path-style (MinIO) and virtual-hosted-style (AWS S3) URLs.IS3ObjectService — object-level operations; includes DownloadStreamAsync
(returns a live Stream without buffering the entire body in memory) and
ExistsAsync (HEAD request, no data transfer).IS3BucketService — bucket lifecycle: create, delete, list, check existence. IS3FolderService — folder (prefix) operations: list, delete by prefix, copy prefix. Depends on WI-01 only. |
| WI-03 | BizFirst.Ai.InfraHub.Storage.Domain |
IObjectStorageProvider — the only interface consumers depend on; three methods:
StoreAsync, GetStreamAsync, ExistsAsync.StoreOptions — optional per-call overrides (e.g. ForceOverwrite,
CustomMetadata).StorageCapacityException — thrown when disk utilization exceeds the configured threshold. This project has zero external dependencies. It can be referenced by any project without pulling in S3 SDKs or infrastructure concerns. |
| WI-04 | BizFirst.Ai.InfraHub.Storage.S3.Services |
ICidProvider / Sha256CidProvider — computes SHA-256 CID from raw bytes.IStorageCapacityGuard / MinioCapacityGuard — reads the Prometheus
cluster metrics endpoint, computes utilization, and enforces the write-stop threshold.S3ObjectStorageProvider — the concrete implementation of IObjectStorageProvider; orchestrates the full pipeline using WI-01, WI-02, WI-03,
and the AI session context for TenantID.FileStorageDependencyInjection — the AddObjectStorage(IConfiguration)
extension method that wires everything into the DI container. This is the only type consumers
call at startup.
|
Project Dependency Graph
The dependency arrows flow in one direction only — from higher-level orchestration down to lower-level
contracts. Consumer projects never reference WI-04 directly; they only call
AddObjectStorage() once at startup and then depend on IObjectStorageProvider
from WI-03.
IObjectStorageProvider without
pulling in AWS SDK assemblies. The S3 implementation is an infrastructure detail that only enters the
process through DI at startup.
The IObjectStorageProvider Contract
The entire InfraHub Storage surface area exposed to application code is just three methods. Every consumer depends on this interface and nothing else from the storage layer.
public interface IObjectStorageProvider
{
/// <summary>
/// Stores raw bytes and returns the stable CID for this content.
/// If identical bytes were stored before, the existing CID is returned immediately
/// (no second upload). Throws StorageCapacityException when disk is full.
/// </summary>
Task<string> StoreAsync(byte[] content, string contentType,
StoreOptions? options = null, CancellationToken ct = default);
/// <summary>
/// Returns a live stream for the object identified by the given CID.
/// The caller is responsible for disposing the stream.
/// </summary>
Task<Stream> GetStreamAsync(string cid,
CancellationToken ct = default);
/// <summary>
/// Returns true if the object identified by the given CID currently exists
/// in the bucket (HEAD request — no data transfer).
/// </summary>
Task<bool> ExistsAsync(string cid,
CancellationToken ct = default);
}
The interface intentionally exposes streams rather than byte arrays on retrieval. This allows large files (video, CAD drawings, archived datasets) to be forwarded directly to an HTTP response stream or written to disk without ever materialising the entire file in memory.
CID — Content Identity
A CID is the SHA-256 hash of a file's raw bytes, encoded as a 64-character lowercase hexadecimal string.
It is computed by Sha256CidProvider using SHA256.HashData(bytes) and
Convert.ToHexString(hash).ToLowerInvariant().
Properties
- Deterministic — the same bytes always produce the same CID, on any machine, at any time.
- Unique (collision-resistant) — SHA-256 has a collision resistance of 2128 operations; practically infinite for file storage purposes.
- Self-verifying — to verify a downloaded file, re-hash the bytes and compare to the CID you requested.
- Immutable — if the content changes, the CID changes. A CID-addressed object is write-once.
Example
// Input: any byte array
byte[] bytes = File.ReadAllBytes("invoice-2026-001.pdf");
// CID output (64-char lowercase hex)
"3a7f2e9b1c4d8f06a2e5b7d9c1f3e8a04b6c2d7e9f1a3b5c8d0e2f4a6b8c0d2e4"
Storage Key Format
The S3 object key is derived from the CID according to the PrependTenantID configuration setting:
| PrependTenantID | S3 Object Key | Use Case |
|---|---|---|
true |
tenant_42/3a7f2e...d2e4 |
Multi-tenant SaaS deployments; per-tenant isolation, export, and IAM policies |
false |
3a7f2e...d2e4 |
Single-tenant deployments, development environments, or when a separate bucket per tenant is used |
The TenantID is resolved at runtime from IAiSessionContextAccessor.CurrentRequestSession.TenantID
— the active session context set by the BizFirst platform request pipeline. No tenant ID is passed
explicitly by the caller; it flows in automatically through the ambient session context.
Capacity Guard
The MinioCapacityGuard is a proactive safeguard against storage exhaustion. It is invoked
at the beginning of every StoreAsync call, before any bytes are transferred.
How It Works
-
Sends an HTTP GET to the MinIO Prometheus endpoint:
{Endpoint}/minio/v2/metrics/cluster -
Parses the Prometheus text format, summing all values for:
minio_node_disk_used_bytes— total bytes currently in use across all nodes.minio_node_disk_total_bytes— total available bytes across all nodes.
-
Computes utilization:
usedBytes / totalBytes * 100 -
If utilization >=
WriteStopThresholdPercent, throwsStorageCapacityExceptionwith a message that includes the current utilization percentage and the threshold. - If utilization is below the threshold, returns immediately and the write proceeds.
MinioCapacityGuard logs a warning at the
Warning log level and allows the write to proceed. This is a fail-open
posture: storage availability is preferred over false negatives from a monitoring endpoint.
Set WriteStopThresholdPercent=0 to disable the guard entirely (the check is skipped
without any HTTP call).
Configuration
| Setting | Default | Meaning |
|---|---|---|
WriteStopThresholdPercent |
90.0 |
Disk utilization percentage at which writes are blocked. Set to 0 to disable. |
SRP Class Decomposition
The three concrete classes that make up the WI-04 service layer each own a single, narrow responsibility. This table maps class to responsibility and DI lifetime.
| Class | Single Responsibility | DI Lifetime | Notes |
|---|---|---|---|
Sha256CidProvider |
Compute a SHA-256 CID from a raw byte array. No I/O, no configuration. | Singleton | Stateless; safe to share across all requests and threads. SHA256.HashData is thread-safe. |
MinioCapacityGuard |
Read the MinIO Prometheus metrics endpoint and enforce the write-stop threshold. | Scoped | Scoped so it participates in the same request lifetime as the session context accessor. Uses IHttpClientFactory internally. |
S3ObjectStorageProvider |
Orchestrate the store / retrieve / exists pipeline, delegating CID computation and capacity checking to the two services above. | Scoped | Depends on IAiSessionContextAccessor (scoped), IS3ObjectService (scoped), ICidProvider (singleton), and IStorageCapacityGuard (scoped). |
Storage Key Strategy
The S3 object key strategy determines how files are organised inside the bucket. The choice is
controlled entirely by the PrependTenantID configuration flag.
PrependTenantID = true (Multi-Tenant Mode)
Each file is stored under the prefix tenant_{tenantID}/ where tenantID is the
integer tenant identifier from the active session context. Example key:
tenant_42/3a7f2e9b1c4d8f06a2e5b7d9c1f3e8a04b6c2d7e9f1a3b5c8d0e2f4a6b8c0d2e4
PrependTenantID = false (Single-Tenant / Testing Mode)
The CID is used directly as the S3 key, with no prefix. Example key:
3a7f2e9b1c4d8f06a2e5b7d9c1f3e8a04b6c2d7e9f1a3b5c8d0e2f4a6b8c0d2e4
Why Tenant Prefixes?
-
S3-level isolation for compliance — data sovereignty requirements can be
satisfied by restricting IAM/MinIO policies to
tenant_{tenantID}/*. No application code changes are needed to enforce the isolation boundary. -
Easy per-tenant data export — a full tenant export is a single S3
ListObjectsV2call with the prefix, followed by a bulk download. No database join or file manifest is required. -
Zero-cost tenant purge — deleting all files for a tenant is one
DeleteObjectscall with prefix-filtered keys. - Observability — storage usage per tenant can be derived directly from S3 prefix-level metrics, without a separate accounting database.
Deduplication Flow
When StoreAsync is called, the following pipeline executes in sequence.
The deduplication short-circuit (the ExistsAsync check) means that storing the same
file a second time costs only a SHA-256 hash computation and a single S3 HEAD request — no data
is transferred.
StoreAsync(bytes)
Check disk %
Compute CID
HEAD request
No upload
PutObjectAsync
New object
Step-by-Step
- Capacity check —
MinioCapacityGuard.GuardAsync(ct)queries Prometheus. If utilization >= threshold, throwsStorageCapacityExceptionimmediately. No further processing. - CID computation —
Sha256CidProvider.Compute(bytes)returns the 64-char hex CID. This is pure CPU work with no I/O. - Key construction — the S3 key is assembled from the CID (and optional tenant prefix).
- Existence check —
IS3ObjectService.ExistsAsync(bucket, key)issues an S3 HEAD request. This is a single round-trip with no body transfer. - Short-circuit on duplicate — if the object already exists, the CID is returned to the caller immediately. The total cost is: one Prometheus HTTP call + one SHA-256 hash + one S3 HEAD. No upload bandwidth is used.
- Upload on new content — if the object does not exist,
IS3ObjectService.UploadAsync(bucket, key, bytes, contentType)streams the bytes to S3. On success, the CID is returned.
ExistsAsync check and both attempt an upload. S3 PUT is idempotent
for the same key and content, so the second upload simply overwrites with identical bytes. No data
corruption occurs; one upload is wasted. This race is extremely rare in practice and requires no
locking mechanism.