VSATutorialSeptember 2026 · 9 min read

VSA Todo App — Part 7: File Uploads & Storage in Vertical Slices

TL;DR

Part 7 implements file and image attachments in VSA using the MVC Project Manager's production patterns: a FileStorageService for disk I/O, diff-based attachment management (keep existing by Id, delete removed files from disk, add new uploads), rollback logic that cleans up saved files on failure, download endpoints with proper content types, and FluentValidation rules for allowed file types (.png, .pdf, .docx, .zip).

Part 7 of 20 in the VSA Todo App Tutorial Series|← Previous: Part 6|Next: Part 8 →

Help Us Grow

Love this tutorial? Explore our ready-to-use enterprise starter kits built with VSA in .NET 10.

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);
});

Key Takeaways

  • FileStorageService isolates filesystem operations from handler logic — swap storage backends without changing handlers
  • Diff-based attachment management compares the request against the database to determine add/delete/keep operations
  • Rollback logic in catch blocks cleans up orphaned files when database operations fail
  • Download endpoints use Results.File(stream, contentType, fileName) for proper browser handling
  • Separate DTOs for image and file attachments enable different validation rules per type

Frequently Asked Questions

Q: Where should I store uploaded files in VSA?

Use a dedicated FileStorageService that abstracts the storage backend. Store files on local disk for development, then swap to Azure Blob Storage or S3 for production by implementing the same interface. The service is injected into handlers via DI — no handler code changes.

Q: How do I handle file deletes when an attachment is removed in VSA?

Use diff-based management: load existing attachments from the database, compare against the request, and delete files from disk for any attachment not present in the request. Always delete the database record AND the physical file. Orphaned files on disk are hard to clean up later.

Q: What file types should I allow for attachments in VSA?

Restrict by business need. The MVC Project Manager allows .png for images, .pdf/.docx/.zip for files. Validate file extensions in FluentValidation rules and validate MIME types in the handler. Never trust client-provided content types — validate on the server.

Q: How to rollback file operations on failure in VSA?

Wrap the save operation in try-catch. If SaveChangesAsync fails, iterate through newly created attachment entities and call FileStorageService.DeleteFile() for each one. Re-throw the exception so the endpoint returns an error. This ensures no orphaned files remain on disk.

Part 7 of 20 in the VSA Todo App Tutorial Series|← Previous: Part 6|Next: Part 8 →

Ready to study real VSA code?

Every Indotalent product is a complete .NET 10 application built with Vertical Slice Architecture. Complete source code — $21 each.

Explore Products

Looking for a ready-to-use traditional monolithic multilayered clean architecture?

Monolithic Clean Architecture — 1,300+ devs, 500+ forks. Clean Arch + CQRS + Repository Pattern. Free & open source for commercial use.

Star on GitHub