File attachments are a reality in business applications. The MVC Project Manager's Todo feature supports two types: image attachments (PNG only, displayed in a gallery) and file attachments (PDF, DOCX, ZIP, available for download). Implementing this in VSA requires careful design — where do files live on disk, how do you handle partial failures, and how do you keep the handler logic clean when dealing with both database and filesystem operations? This part answers all of these questions with production code from the MVC Project Manager.
The key architectural decision in VSA file handling is the FileStorageService — an abstraction that isolates filesystem operations from handler logic. The handler creates entities in the database; the storage service saves and deletes files on disk. This separation keeps handlers focused on business logic and makes the storage backend swappable (local disk today, Azure Blob tomorrow) without changing handler code.
The FileStorageService: Isolating Disk I/O
The FileStorageService provides three operations: save a base64-encoded file to disk, delete a file from disk, and retrieve a file for download. It handles directory creation, filename generation, and content type mapping. The service is registered as a scoped service in DI. Handlers inject it alongside the DbContext:
public class FileStorageService
{
private readonly string _basePath;
public async Task<string> SaveFileAsync(string base64Data, string fileName, string folder)
{
var directory = Path.Combine(_basePath, folder);
Directory.CreateDirectory(directory);
var filePath = Path.Combine(directory, $"{Guid.NewGuid()}_{fileName}");
var bytes = Convert.FromBase64String(base64Data);
await File.WriteAllBytesAsync(filePath, bytes);
return filePath;
}
public void DeleteFile(string filePath)
{
if (File.Exists(filePath)) File.Delete(filePath);
}
public async Task<(Stream, string, string)> GetFileAsync(string filePath)
{
var stream = File.OpenRead(filePath);
var contentType = GetContentType(Path.GetExtension(filePath));
return (stream, contentType, Path.GetFileName(filePath));
}
}
Attachment Request DTOs: Images and Files
The MVC Project Manager defines separate request DTOs for image and file attachments. Each contains the file data as a base64 string and an optional Id for existing attachments during updates. The Id field is the key to diff-based management — null means new, non-null means existing:
public class CreateTodoImageAttachmentRequest
{
public string? Id { get; set; } // null = new, set = existing
public string? FileName { get; set; }
public string? Data { get; set; } // base64-encoded PNG
}
public class CreateTodoFileAttachmentRequest
{
public string? Id { get; set; }
public string? FileName { get; set; }
public string? Data { get; set; } // base64-encoded PDF/DOCX/ZIP
}
Diff-Based Attachment Management in the Handler
The UpdateTodoHandler implements diff-based attachment management. It loads existing attachments from the database, then compares against the request to determine what to keep, delete, or add. Removing an attachment from the UI deletes it from both the database and disk. Adding a new attachment saves it to disk and creates a database record. Existing attachments with unchanged data are left alone — no unnecessary file I/O:
var existingImages = entity.TodoImageAttachments.ToList();
foreach (var existing in existingImages)
{
if (!request.ImageAttachments.Any(a => a.Id == existing.Id))
{
_fileStorage.DeleteFile(existing.FilePath); // delete from disk
_context.SoftDelete(existing); // soft-delete from DB
}
}
foreach (var imageReq in request.ImageAttachments)
{
if (string.IsNullOrEmpty(imageReq.Id)) // new attachment
{
var filePath = await _fileStorage.SaveFileAsync(
imageReq.Data, imageReq.FileName, "todo-images");
entity.TodoImageAttachments.Add(new TodoImageAttachment
{ FileName = imageReq.FileName, FilePath = filePath });
}
}
Rollback on Failure
When saving files to disk, partial failures are possible — the database transaction succeeds but a file write fails, or vice versa. The handler implements rollback logic using try-catch: if SaveChangesAsync fails after files are written, the catch block cleans up the orphaned files. The exception is re-thrown so the endpoint returns a 500 error — the client knows the operation failed completely:
try
{
await _context.SaveChangesAsync(cancellationToken);
return ApiResponse<CreateTodoResponse>.Success(response, "Todo created");
}
catch (Exception)
{
foreach (var image in entity.TodoImageAttachments)
_fileStorage.DeleteFile(image.FilePath);
foreach (var file in entity.TodoFileAttachments)
_fileStorage.DeleteFile(file.FilePath);
throw;
}
Download Endpoint
The download endpoint streams files directly to the client with the correct content type and filename. The endpoint lives in the Todo feature's TodoEndpoint.cs alongside all other Todo routes. The file content type is determined by extension, so browsers handle the download correctly:
group.MapGet("/{id}/files/{attachmentId}/download",
async (string id, string attachmentId, IMediator mediator,
FileStorageService fileStorage) =>
{
var attachment = await _context.TodoFileAttachments
.FirstOrDefaultAsync(a => a.Id == attachmentId);
if (attachment == null) return Results.NotFound();
var (stream, contentType, fileName) =
await fileStorage.GetFileAsync(attachment.FilePath);
return Results.File(stream, contentType, fileName);
});