Adding and Committing Files
Files enter a repository through a transaction. This guide covers the add APIs, the transaction lifecycle, rollback behavior, and how to coordinate a repository commit with a database commit so a user-uploaded file and the database row that references it stay consistent.
When to use a transaction
Every add goes through a FileRepoTransaction. A transaction can add multiple files and is the unit that commits or rolls back as a whole, which is what lets you align a batch of file operations with a single database transaction. Even a single-file upload should be wrapped in one so the add can be rolled back if the surrounding database work fails.
Beginning a Transaction
Create a transaction with BeginTransactionAsync. The returned FileRepoTransaction is an IAsyncDisposable, so an await using block guarantees it is finalized.
await using var txn = await repo.BeginTransactionAsync();
If the block exits without a successful CommitAsync, disposal rolls the transaction back automatically. This means an exception thrown anywhere inside the block cleans up any tentatively added files without further effort on your part.
Adding Files
AddAsync has two overloads. One takes a FileStream and infers the extension from it; the other takes any Stream together with an explicit extension. Both take a leaveOpen flag and an IFileProcessingPipelineSelector that decides how the source is processed.
// Storing a user-uploaded PDF attachment with no processing.
await using var source = uploadedFile.OpenReadStream();
var added = await txn.AddAsync(
source,
extension: ".pdf",
leaveOpen: true,
FileProcessingPipeline.Empty);
FileId fileId = added.FileId;
The result is a RepoFileGroupInfo. Its FileId is the handle you persist on the owning database row (a Documents.FileId column, say); its MainFile and VariantFiles describe what was produced when the pipeline generates variants such as thumbnails.
Tip
Pass leaveOpen: true when the calling code is responsible for disposing the source stream (the common case with HTTP upload streams owned by the request scope). Pass leaveOpen: false only when you want the transaction to take ownership of the stream.
Passing a pipeline or a selector
When every source should be processed the same way, pass a single FileProcessingPipeline directly, since it implements IFileProcessingPipelineSelector. When different file types need different handling (for example an attachment endpoint that accepts both PDFs and images), pass a FileProcessingPipelineSelector that routes by extension. See Processing Pipelines.
Adding several files in one transaction
A single FileRepoTransaction can carry any number of adds (and deletes). All of them commit or roll back together, which is exactly what you want when a database operation creates multiple rows that each reference a file (a forum post with several attachments, an import that produces a document plus generated previews, etc.):
await using var txn = await repo.BeginTransactionAsync();
var added = new List<RepoFileGroupInfo>();
foreach (var upload in uploadedFiles)
{
await using var source = upload.OpenReadStream();
added.Add(await txn.AddAsync(source, Path.GetExtension(upload.FileName), leaveOpen: true, pipeline));
}
// ... persist each added.FileId on its owning row, commit the database, then:
await txn.CommitAsync();
Committing
Call CommitAsync to make the adds durable.
await txn.CommitAsync();
When the file is owned by a database row, commit the database transaction first and the repository transaction second. A crash between the two leaves at worst a harmless orphan file that the cleaner reclaims later. See Transactional Commit Model for why this ordering is required.
A complete upload handler therefore looks like this:
await using var source = uploadedFile.OpenReadStream();
await using var txn = await repo.BeginTransactionAsync();
await using var dbTxn = await dbContext.Database.BeginTransactionAsync();
var added = await txn.AddAsync(source, ".pdf", leaveOpen: true, pipeline);
dbContext.Documents.Add(new Document { Id = documentId, FileId = added.FileId });
await dbContext.SaveChangesAsync();
await dbTxn.CommitAsync(); // 1. Database first.
await txn.CommitAsync(); // 2. Repository second.
Important
A failed commit does not throw. Affected files are marked indeterminate and remain fully readable and usable, and the failure is reported through the TransactionCompletionFailed event (with Operation set to Commit). Subscribe to it if you need to log, alert, or surface commit failures to operators. See Exception Handling.
Rolling Back
To abandon a transaction explicitly, call RollbackAsync. Otherwise, disposing without committing rolls back automatically. Tentatively added files are discarded; if rollback itself cannot complete cleanly, the affected files become indeterminate and the same TransactionCompletionFailed event fires with Operation set to Rollback.
await using var txn = await repo.BeginTransactionAsync();
var added = await txn.AddAsync(source, ".jpg", leaveOpen: true, pipeline);
if (!await TrySaveDocumentRowAsync(added.FileId))
{
// The database refused the row (for example a unique-constraint violation).
// Roll back so the just-added file does not become an orphan.
await txn.RollbackAsync();
return Results.Conflict();
}
await txn.CommitAsync();
Note
The explicit rollback call is rarely needed in practice; letting the await using block dispose the transaction is enough. Reach for RollbackAsync when you want to release the tentative state before later code runs, or to make the abandonment intent obvious at the call site.
Next Steps
- Fetching Files - Read files and variants back out.
- Processing Pipelines - Validate and transform during add.
- Exception Handling - Commit and rollback failure events.