Skip to content
View Article Network

A Practical Discussion on File Upload Handling in ASP.NET Core Web API

This article documents my thoughts and experiences regarding file upload handling in Web API development. It does not cover specific implementation code. Due to limited scope, it does not address advanced techniques such as chunked uploads.

Sample Project

An executable sample for this article: CloudyWing/SecureFileUploadSample.

This article only records design concepts and does not contain implementation code; the sample provides a complete implementation using .NET 10 + EF Core (SQLite in-memory mode), corresponding to the design direction discussed here. The sample also includes features not mentioned in this article: file hash calculation (using the Content-Digest response header) and executable file detection.

Development Patterns and Data Reception Methods

  • Full-stack development: Without specific AJAX handling, most cases involve submitting form data directly, with the backend using [FromForm] to receive the data.
  • Frontend-backend separation: Most cases involve passing JSON via AJAX, with the backend using [FromBody] to receive the data.

Two Main Processing Methods

Method 1: Using Form Submit or FormData

Pros:

  • Does not increase data transmission volume.

Cons:

  • In ASP.NET Core Web API, [FromBody] and [FromForm] use different ModelBinders when binding complex types. If you need to handle specific custom types, you must write a new ModelBinder for [FromForm] and a new JsonConverter for [FromBody].
  • Having two processing methods in the system confuses developers regarding which one to use, leading to inconsistent development standards.
  • Significant impact on the frontend; the transmission method needs to be completely changed, especially if file upload functionality is added to an existing API.

Method 2: Passing Base64 Strings via JSON

Pros:

  • Convenient for both frontend and backend; no extra processing required.
  • High API format consistency.

Cons:

  • Data size increases by approximately 1.33 times, which may cause performance issues.

Conflict Case Between Frontend and Backend Engineers

Of course, passing Base64 via JSON is more convenient for both frontend and backend without extra processing. Previously, a backend engineer at my company refused this approach and switched to [FromForm], expecting the frontend to use FormData, which led to a dispute.

I cannot say that the engineer's approach was wrong; after all, they might have wanted to implement it in a more performant way. However, when a team already has established practices, one should communicate before implementing changes. I did not delve into their specific implementation at the time, so I am not sure if they intended to split file uploads into a separate API or change all file-related APIs to [FromForm]. If it were the latter, the dispute would be more understandable.

For the backend, this change is minor—just adding the [FromForm] attribute before the complex type. But for the frontend, it means the entire transmission method must be adjusted.

This is an approach shared by a colleague a few months ago, which I find relatively superior:

  1. Create a separate file upload API (using [FromForm]).
  2. Record file information in a file data table and return a file ID.
  3. Pass the file ID instead of the file content in the main business data creation/update API.

Pros:

  • Does not increase data transmission volume.
  • Simpler for the frontend to handle.
  • High API format consistency.

Additional Note: This approach also solves a problem I encountered in practice. Originally, when uploading files, the business data creation API did not have an ID for the file information, but updates might require changing a specific file, requiring the frontend to pass an ID additionally, leading to inconsistent API formats between creation and update. With this method, both only need to pass the file ID, maintaining consistency.

Specific Implementation

Based on my colleague's sharing and my own practical experience, I have summarized the following implementation details:

  1. File Data Structure Design.

    • Create a dedicated file attachment table to record information such as filename, storage path, file size, download count, creation time, and whether it is enabled.
    • The main business data table records the file ID, or a join table is created to handle one-to-many relationships.
  2. File Upload Workflow.

    • The frontend first calls the file upload API to send the file directly to the backend.
    • Upon receiving the file, the backend stores it on the file server and writes the file information into the file data table.
    • The backend returns the file ID to the frontend.
    • When the frontend creates/updates business data, it only needs to pass the file ID.
  3. Data Management Mechanism.

    • When business data is created, mark the file status as "enabled".
    • Set up a scheduled task to periodically clean up files and their database records that have not been enabled for more than a day to avoid occupying excessive space.
    • When deleting business data, you can choose to delete the associated files simultaneously or keep the files but mark them as "disabled".
  4. File Data Validation.

    • Create a custom ValidationAttribute to verify the validity of the file data.
    • Since file information is already stored in the database, you can obtain file information in the validation attribute via Dependency Injection (DI):
    csharp
    protected override ValidationResult IsValid(
        object value, ValidationContext validationContext
    ) {
        using IServiceScope scope = validationContext.CreateScope();
        // Get the corresponding Service, Repository, or EfContext
        IFileService service = scope.ServiceProvider.GetRequiredService<IFileService>();
    }
    • Note the limitations of file type validation: it cannot detect file extension spoofing. Even with file signature identification, for most file types other than .exe, identification is either difficult or prone to false positives.
    • IValidatableObject's Validate can also use this method to inject objects, but I personally dislike retrieving database data within Request Input. For logical validation involving database data, I prefer handling it in the Action or Service.

Extended Discussion: Considerations for File Download Implementation

After separating file uploads into an independent API, the implementation of file downloads also faces two design choices:

1. Generic File Download API (FileController):

  • Pros: Simple implementation, one API fits all scenarios, high code reusability.
  • Cons: Difficult permission control; cannot set fine-grained permission checks for different business scenarios.

2. Implementing Download API in Business Logic Controller (OrderController.DownloadInvoice()):

  • Pros: Can implement fine-grained permission control, high integration with business logic.
  • Cons: Each business module needs to write similar download logic, higher implementation complexity.

Personal Preference

I prefer integrating the file download functionality into the business logic Controller. If the final requirement is just a pure download without any additional permission control logic, it might be considered over-engineering; conversely, if a generic download method is adopted, the cost of modification will be higher when encountering files that require permission control later, or it will require establishing additional processing mechanisms for specific files.

To reduce duplicate code, a base file download service class can be created to encapsulate common logic, which is then used by various business modules via dependency injection.

Change Log

  • 2025-03-10 Initial document creation.
  • 2026-05-21 Added link to the GitHub sample project.