CodeGym /Courses /Spring REST & MVC /Multipart/form-data and file upload

Multipart/form-data and file upload

Spring REST & MVC
Level 26, Lesson 0
Available

1. JSON vs upload: when you need multipart

Comments, tags, and other supporting subresources could still live comfortably in pure JSON. With attachments, that model breaks immediately: the resource gets real file content, and a single @RequestBody is no longer enough.

When we spend a long time writing JSON-first APIs, the brain gets lazy and starts thinking like this: “A request is JSON, a response is JSON, and everything else is optional decoration.” That is normal; it happens to everyone, even to the people who later confidently say in interviews, “I like clean architecture” (and then put business logic in the controller, but that is another story). Files bring us back to reality: the request body is really just a stream of bytes, and not every useful payload is conveniently expressed as a single JSON document.

In an application/json scenario, everything is beautiful: there is one body, it contains one structured document, and Spring MVC can say, “Aha, this is a TaskCreateRequest,” deserialize it, validate it, and pass it along. But in an upload scenario, we need to send two different things at the same time: the binary file content (which may not be text at all) and the metadata (for example, the attachment description), which is conveniently kept as JSON. And these two entities live by different rules: a file has a name, a type, and a size; metadata has structure and validation.

Theoretically, you can “stuff a file into JSON” as a Base64 string. Practically, this is almost always a bad idea: the payload grows, the client suffers, the server suffers, and the logs suffer especially badly (because somebody will definitely log the request body “for debugging,” and then everyone will remember that day as their first encounter with a large binary object). That is why HTTP has a standard mechanism for file scenarios — multipart/form-data.

To feel the contrast, let us look at a familiar JSON endpoint for creating a task — here everything fits into one DTO from one body:

import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;

@PostMapping(path = "/api/v1/tasks", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<TaskDetailsResponse> create(@Valid @RequestBody TaskCreateRequest request) {
    // @RequestBody: take one JSON from the body and map it into a DTO
    // @Valid: run Bean Validation before entering the business logic
    TaskDetailsResponse created = taskService.create(request); // the controller only delegates
    return ResponseEntity.status(HttpStatus.CREATED).body(created); // 201 + response body
}

Here the contract is simple: the client sent one JSON, we received one Java object. For a file, this “single-object” format quickly turns into misery, so we need another kind of request body.

2. Multipart: one HTTP request

If we put it in plain English, multipart/form-data is a way to make one HTTP request that contains several separate parts inside it, and each part has its own mini headers and its own content. Think of a box with several packages inside: one box (the HTTP request), many packages (parts). This is not two requests, not “first metadata, then file,” but exactly one operation with one contract.

At the HTTP level, this is expressed by the header Content-Type: multipart/form-data; boundary=.... The word boundary is a separator that helps the server understand where one part ends and the next begins. We are not going to turn the lecture into a course called “Writing a multipart parser by hand in plain Java” (otherwise you would reasonably suspect me of a hidden attraction to pain), but you do need to understand the core idea: the request body in multipart is a “stitching together” of parts separated by boundaries.

It is useful to fix the differences between application/json and multipart/form-data in a small table so that it is easier not to get confused:

Aspect application/json multipart/form-data
How many parts in the body One Several (parts)
What is inside One JSON document A set of parts: each part has its own headers + content
Type of content Usually text/JSON Mixed: JSON + binary data
What it is good for CRUD, update, search File uploads and mixed payloads
Main idea One DTO from one body Several parts with different roles

To see the “real” shape, here is a very simplified (but honest in terms of meaning) example of a raw HTTP multipart request. It is longer than JSON, and that is normal: it does more work.

# boundary defines the separator between parts of the multipart body
POST /api/v1/tasks/123/attachments HTTP/1.1
Content-Type: multipart/form-data; boundary=---abc

# Part 1: JSON metadata (the part name is metadata)
-----abc
Content-Disposition: form-data; name="metadata"
Content-Type: application/json

{"description":"Application startup logs"}

# Part 2: file (the part name is file, plus filename)
-----abc
Content-Disposition: form-data; name="file"; filename="logs.txt"
Content-Type: text/plain

...file bytes...

# Closing boundary (pay attention to the -- at the end)
-----abc--

Notice two key ideas: each part has a name (name="metadata" and name="file"), and each part can have its own Content-Type. This model is what later grows into Spring MVC binding through @RequestPart.

3. Part: name and headers

The word part sounds very modest, almost like “a piece of something.” But in an API contract, it is an important entity: a part is a named fragment of a multipart request that the server must be able to find, read, and interpret. If a part is missing or has an unexpected format, the server must respond with an error just as honestly as we did for malformed JSON or an invalid query parameter in previous modules.

A part usually has its own headers. The most visible one is Content-Disposition: form-data; name="...". It tells the server what this part is called. If it is a file, filename="..." is added, meaning the original file name provided by the client. A part also often has its own Content-Type, so the server can understand what is inside: application/json, text/plain, image/png, application/pdf, and so on.

An important point for the backend developer mindset: the part name is part of the public contract, almost on par with the path and the names of JSON fields. If the client knows that it needs to send a part called file, and the server suddenly starts expecting a part called upload, that is exactly the same kind of breaking change as renaming the field title to taskTitle without announcing a migration. The client is not a telepath.

For clarity, you can think of a multipart request as a small diagram. What matters here is the structure, not the parsing details:

flowchart TD
    A["HTTP Request Content-Type: multipart/form-data"] --> B["Part: metadata Content-Type: application/json"]
    A --> C["Part: file Content-Type: image/png / application/pdf / ..."]

And one more thought to keep in mind: multipart is not “chaos made of form fields.” In our course, we make it just as neat a contract as JSON endpoints; only the body format is different.

4. Request parts: file and metadata

Now we will bring multipart down to our domain; otherwise it will remain an abstract “format for browser forms.” In the Task Tracker API, an attachment is a supporting subresource of a task. The client uploads a file and optionally attaches a description. At the same time, there is an important boundary: the client must not manage server-managed fields that the server can determine on its own.

For example, attributes such as size, contentType, and originalFileName effectively arrive together with the file (or are computed on the server side from the file). If we let the client send them as “metadata,” we open the door to a world of weirdness: the client says the file is image/png, but actually sends a PDF; or says the size is 10 bytes and sends 10 megabytes. The server will still have to verify reality. So these fields should not be part of the request DTO.

That is why it is convenient in our contract to divide responsibilities like this: the file part carries the file content, and the metadata part carries JSON with what the client really controls. For example, description.

Here is the canonical request DTO for upload metadata — small and clear:

import jakarta.validation.constraints.Size;

public record AttachmentUploadMetadataRequest(
        // Limit the length of the description: this is a client-controlled field
        @Size(max = 255) String description
) {
}

This DTO fits perfectly into the model we already know: JSON + Bean Validation. The only difference is that this JSON does not live in the main request body, but in a separate part of the multipart request.

And the file part is a different nature: it is about bytes. And it has its own properties, which Spring can give us through a convenient API we will approach carefully, without jumping ahead.

5. Part names as a public API

When we design a REST API, we are already used to the fact that the contract is not only “which URL to call.” The contract also includes query params, the structure of JSON, response statuses, and errors. Multipart adds another layer to the contract: the names of the parts (file, metadata). And this layer is just as important as the names of DTO fields, because the client needs to build the request in a reproducible way.

Imagine this situation. You released an API, the client integrated with it, and sends a part called file and a part called metadata. A week later you decide that the word metadata sounds too grown-up, and you rename the part to info. On the server side, this is “a couple of characters.” On the client side, it is a sudden failure, because the server says: “Where is metadata?” The client replies: “But I sent info.” And both are right in their own way, but the API is still broken.

That is why, when we design a multipart contract, we choose part names so that they are meaningful and obvious, and then we treat them as stable. In this course, a good standard is file for the file and metadata for the JSON metadata. These words are not perfect (perfect words do not exist), but they are as clear as possible: one file, one description.

Another nuance: part names are not an “internal Spring detail.” They live at the HTTP level. Even if tomorrow you rewrite the application without Spring (don’t do that, but let us imagine it for the sake of a clean experiment), the client will still send the same parts in the same format. That means part names are part of your API contract, not a technical trifle.

6. Content-Type for a multipart endpoint

In previous modules, we already used consumes and produces as a way to make the contract more explicit. With multipart, this becomes even more important, because the server must honestly tell the client: “This endpoint expects multipart, not JSON.” Otherwise, strange situations appear: the client sends JSON, the server tries to read multipart, crashes somewhere deep in conversion, and then the client thinks that “Spring is doing magic again.”

On the client side, the format is expressed by the Content-Type header. For multipart this will be multipart/form-data; boundary=... (boundary is generated by the HTTP client). On the server side, in Spring MVC we fix this through consumes = MediaType.MULTIPART_FORM_DATA_VALUE.

For contrast, let us show two different contracts at the controller level once again.

JSON create endpoint (one body, one DTO):

import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;

@PostMapping(path = "/api/v1/tasks", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<TaskDetailsResponse> create(@Valid @RequestBody TaskCreateRequest request) {
    // Important: consumes fixes the request format at the contract level (JSON here)
    TaskDetailsResponse created = taskService.create(request); // business logic stays outside the controller
    return ResponseEntity.status(HttpStatus.CREATED).body(created); // return 201 Created
}

Multipart upload endpoint (one request, but several parts). Here it is enough for us to see the multipart request shape, so we will keep metadata as a string for now:

import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@PostMapping(path = "/api/v1/tasks/{taskId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Void> upload(@PathVariable String taskId,
                                  @RequestPart("file") MultipartFile file,
                                  @RequestPart("metadata") String metadataJson) {
    // @RequestPart("file"): extract the binary part by the name "file"
    // @RequestPart("metadata"): extract the JSON part by the name "metadata"
    // consumes = MULTIPART_FORM_DATA: the server explicitly says it expects a multipart request
    return ResponseEntity.status(HttpStatus.CREATED).build(); // 201 with no body
}

Yes, this method looks unfamiliar because it does not have a @RequestBody. And that is exactly the right signal: “this request has a different body shape.” The controller literally reflects the contract.

7. How Spring MVC binds parts

It is very tempting to think that multipart is “some separate universe.” In reality, it fits into the same Spring MVC model we have already studied: a request arrives, the framework extracts the input data, converts it into the required Java types, and calls the controller method. Only now the input data is not in one body, but in several parts. It is more of an “extension of the format” than a new paradigm.

And there is a pleasant engineering symmetry here. For the file, Spring gives you a convenient wrapper from which you can get the basic properties. Even without reading the content, we can tell what was sent:

import org.springframework.web.multipart.MultipartFile;

// Original file name from the client (may be null — depends on the client and implementation)
String originalName = file.getOriginalFilename(); // for example: "report.pdf"

// MIME type that arrived in the part headers (may be null/inaccurate; the server still verifies it)
String contentType = file.getContentType();       // for example: "application/pdf"

// File body size in bytes (useful for limits and logging)
long size = file.getSize();                       // for example: 12345

And the JSON part (metadata) remains JSON in essence. That is, it has a Content-Type: application/json, structure, and rules that we already know from @RequestBody. The difference is only that this JSON arrives as a separate part. The only conclusion we need here is this: multipart carries several inputs, and Spring MVC knows how to distribute them into method arguments — without manual string parsing and without “magic for magic’s sake.”

For now, it is important to remember the basic mental model: multipart is a request that “carries several inputs,” and Spring MVC can distribute these inputs into the method arguments if we describe them explicitly and honestly.

8. Typical mistakes in multipart upload

Before we start choosing between MultipartFile, Part, and different variants of @RequestPart, it is worth discussing typical mistakes at the thinking level. They are not about syntax — they are about how easy it is to break the contract even when the code compiles, and even when “it works on my machine.”

Mistake #1: trying to represent upload as a regular @RequestBody.
A very common idea: “Let us make an AttachmentUploadRequest with byte[] fileContent and String description.” Formally, this is possible, but practically you get huge JSON documents, size problems, pointless serialization overhead, and poor UX. In an upload contract, it is better to separate the binary part and metadata via multipart.

Mistake #2: treating part names as “internal” names.
When you write @RequestPart("file"), it feels like just a string in the code that can be changed “at any time.” But the client builds the request with exactly that name. Renaming part names is a breaking change, exactly like renaming a JSON field. In a stable API, part names are chosen deliberately and kept constant.

Mistake #3: making part names abstract (data1, data2).
Yes, the code will work. But the contract becomes unreadable: the client opens Swagger/README/.http and sees “data1.” What is that? A file? Metadata? JSON? A bunch of bytes? Clear names like file and metadata dramatically reduce cognitive load for both the client and future you (who, a month later, will already have forgotten what you meant).

Mistake #4: moving metadata into query params “just because it is easier.”
Sometimes it seems convenient to do POST /attachments?description=... and put the file separately. But metadata is part of the same upload operation. Keeping it in a multipart part called metadata is usually easier for the client, easier for validation, and scales better if another field appears in the metadata tomorrow.

Mistake #5: forgetting that multipart is also a contract with its own errors.
If a part is missing, if a part has the wrong type, if the request Content-Type is incorrect, the server should respond predictably: with the correct status and in your unified ProblemDetail format. Multipart is not a “special chaos” where you can respond however you want. On the contrary: this is exactly where contract discipline is especially visible.

1
Task
Spring REST & MVC, level 26, lesson 0
Locked
Diagnostic multipart endpoint
Diagnostic multipart endpoint
1
Task
Spring REST & MVC, level 26, lesson 0
Locked
Two Different Contracts for a Note
Two Different Contracts for a Note
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION