The Files module is the kit’s shared object-storage layer. Any other module that needs file uploads (Catalog product images, Chat attachments, user avatars) goes through it. Files are owned by an OwnerType (e.g. Product, ChatChannel, User) and an OwnerId; per-OwnerType IFileAccessPolicy implementations decide who can upload, read, and delete. Storage is MinIO / S3 via presigned URLs — the API never proxies bytes.
What ships in v10
- Presigned upload flow —
POST /files/upload-urlmints a presigned PUT URL with a 15-minute TTL (configurable). Client uploads directly to MinIO / S3.POST /files/{id}/finalizeflips the file fromPendingUploadtoAvailable. - Pluggable access policies —
IFileAccessPolicyinterface registered perOwnerType. Catalog shipsProductFileAccessPolicy, Chat shipsChatChannelFileAccessPolicy, the Files module itself ships defaults forMyFilesandUser. - Per-category validation — extensions whitelist + size cap per category (
Image10 MB,Document25 MB,Archive50 MB), configured inappsettings. - File scanner hook —
IFileScannerinterface withNoOpFileScanneras the default. Implement it to plug in ClamAV / S3 antivirus / VirusTotal; quarantined files transition toQuarantinedinstead ofAvailable. - Soft delete + retention — deleted files go to trash;
PurgeDeletedFilesJob(Hangfire daily 03:30 UTC) hard-deletes after 30 days. - Orphan cleanup —
PurgeOrphanedFilesJob(hourly) deletesPendingUploadrows whose upload deadline passed without a finalize call. FileFinalizedIntegrationEvent— published when a file becomesAvailable; the Catalog product-image flow and Chat attachment flow consume this.
Architecture at a glance
src/Modules/Files/├── Modules.Files/ ~500 LoC│ ├── FilesModule.cs IModule entry — order 350│ ├── Domain/FileAsset.cs AggregateRoot, state machine│ ├── Data/FilesDbContext.cs Schema: files│ ├── Services/│ │ ├── FileAccessPolicyRegistry.cs OwnerType → IFileAccessPolicy lookup│ │ ├── DefaultUploaderOnlyPolicy.cs For MyFiles + User│ │ └── NoOpFileScanner.cs Default IFileScanner│ ├── Background/│ │ ├── PurgeOrphanedFilesJob.cs Hangfire hourly│ │ └── PurgeDeletedFilesJob.cs Hangfire 03:30 daily│ └── Features/v1/ 8 features└── Modules.Files.Contracts/ ~50 LoC, plus IFileAccessPolicyThe lifecycle
FileAsset is the aggregate. Three states, transitions one-way:
finalizePendingUpload ────────────────────► Available │ │ scan = Quarantined └──────────────────────────► QuarantinedUpload-deadline expiry transitions PendingUpload to hard-deleted via the orphan-purge job. Available and Quarantined files are soft-deletable; the daily purge hard-deletes after the retention window.
public sealed class FileAsset : AggregateRoot, ISoftDeletable{ public FileAssetStatus Status { get; private set; } // PendingUpload | Available | Quarantined public DateTime UploadDeadline { get; private set; }
public static FileAsset CreatePending( Guid tenantId, string ownerType, Guid? ownerId, string fileName, string contentType, long sizeBytes, FileVisibility visibility, TimeSpan uploadTtl) { /* ... */ }
public void MarkAvailable(long actualSize) { EnsureStatus(FileAssetStatus.PendingUpload); Status = FileAssetStatus.Available; ActualSize = actualSize; RaiseDomainEvent(new FileFinalizedDomainEvent(Id, OwnerType, OwnerId)); }
public void MarkQuarantined(string reason) { /* ... */ }}Access policies
IFileAccessPolicy is the seam every consuming module implements:
public interface IFileAccessPolicy{ string OwnerType { get; } ValueTask<bool> CanUploadAsync(Guid? ownerId, ICurrentUser user, CancellationToken ct); ValueTask<bool> CanReadAsync(FileAsset file, ICurrentUser user, CancellationToken ct); ValueTask<bool> CanDeleteAsync(FileAsset file, ICurrentUser user, CancellationToken ct);}Catalog’s ProductFileAccessPolicy checks that the caller has Catalog.Products.Update and that the product exists. Chat’s ChatChannelFileAccessPolicy checks that the caller is a member of the channel.
FileAccessPolicyRegistry looks up the right policy by OwnerType. Missing policy → request rejected. This is a deliberate fail-closed default.
Public API
| Type | Purpose |
|---|---|
RequestUploadUrlCommand(ownerType, ownerId?, fileName, contentType, sizeBytes, visibility, category) | Mints a presigned PUT URL and a pending FileAsset |
FinalizeUploadCommand(fileId, actualSize) | Flips to Available; raises domain event |
DeleteFileCommand(fileId) | Soft delete |
RestoreFileCommand(fileId) | Undelete |
GetFileMetadataQuery(fileId) | Single file’s metadata |
GetFileDownloadUrlQuery(fileId) | Mints a presigned GET URL |
ListMyFilesQuery(paging) | Paginated caller-scoped list |
ListTrashedFilesQuery(paging) | Paginated trash |
The PresignedUploadResponse returned by RequestUploadUrl includes the URL, the headers the client must send with the PUT, and the file id. The download response is just URL + content-disposition.
Endpoints
| Verb | Route | What it does |
|---|---|---|
| POST | /api/v1/files/upload-url | Mint presigned PUT URL (idempotent) |
| POST | /api/v1/files/{id}/finalize | Flip to Available, trigger scan |
| GET | /api/v1/files/{id} | File metadata (not content) |
| GET | /api/v1/files/{id}/url | Mint presigned GET URL |
| DELETE | /api/v1/files/{id} | Soft delete |
| POST | /api/v1/files/{id}/restore | Restore from trash |
| GET | /api/v1/files/mine | Paginated caller-scoped list |
| GET | /api/v1/files/trash | Paginated trash |
Configuration
{ "Files": { "UploadUrlTtlMinutes": 15, "DownloadUrlTtlMinutes": 60, "OrphanRetentionMinutes": 60, "SoftDeleteRetentionDays": 30, "Categories": { "Image": { "Extensions": ["png","jpg","jpeg","webp","gif","svg"], "MaxBytes": 10485760 }, "Document": { "Extensions": ["pdf","docx","xlsx","txt","md"], "MaxBytes": 26214400 }, "Archive": { "Extensions": ["zip","tar.gz","tgz"], "MaxBytes": 52428800 } } }}The S3 / MinIO target is configured in the Storage building block — the Files module is the policy + lifecycle layer on top.
How to extend
Add a policy for a new owner type
public sealed class ContractDocumentAccessPolicy(IContractDbContext db) : IFileAccessPolicy{ public string OwnerType => "Contract";
public async ValueTask<bool> CanUploadAsync(Guid? ownerId, ICurrentUser user, CancellationToken ct) { if (ownerId is null) return false; var contract = await db.Contracts.FindAsync([ownerId], ct).ConfigureAwait(false); return contract is not null && contract.OwnerUserId == user.GetUserId(); }
public ValueTask<bool> CanReadAsync(FileAsset file, ICurrentUser user, CancellationToken ct) { /* ... */ } public ValueTask<bool> CanDeleteAsync(FileAsset file, ICurrentUser user, CancellationToken ct) { /* ... */ }}
// register in your module's ConfigureServicesservices.AddScoped<IFileAccessPolicy, ContractDocumentAccessPolicy>();Plug a virus scanner
Implement IFileScanner.ScanAsync(stream, ct) and register your implementation in DI to replace NoOpFileScanner. FinalizeUploadCommandHandler calls the scanner during finalize; quarantined files transition to Quarantined instead of Available.
Listen for file finalization in another module
public sealed class FileFinalizedNotifyHandler(/* ... */) : IIntegrationEventHandler<FileFinalizedIntegrationEvent>{ public async ValueTask HandleAsync(FileFinalizedIntegrationEvent evt, CancellationToken ct) { if (evt.OwnerType != "Product") return; // attach the file to the product domain }}The Catalog module uses this exact pattern to associate product images.
Tests
- Domain + service tests at
src/Tests/Files.Tests/:FileAssetTests— state transitionsDefaultUploaderOnlyPolicyTests— policy enforcementFileAccessPolicyRegistryTests— registry lookupStorageKeyBuilderTests— tenant + category path generation
- Integration tests at
src/Tests/Integration.Tests/Tests/Files/cover the full presigned URL round-trip against Testcontainers MinIO.
Related
- Storage building block — the S3 / MinIO abstraction this module sits on top of.
- Catalog module — product images use the policy hook.
- Chat module — channel attachments use the policy hook.