00:00

Spring Boot File Upload and Download

Last updated:

File upload and download in Spring Boot lets a REST API accept files from clients, such as invoices, product images or reports, and send them back later. Uploads arrive as multipart/form-data requests, and Spring hands each file to your controller as a MultipartFile. Downloads go the other way: you return the file as a Resource with the right headers so the browser knows what it is and what to call it. Getting the details right matters, because file endpoints are a common source of out-of-memory errors and security holes.

This tutorial explains how a multipart request is processed, then covers MultipartFile, @RequestParam vs @RequestPart, uploading several files, sending JSON alongside a file, and downloading with Content-Disposition. It builds a complete upload and download API on Spring Boot 4.1, configures and handles upload size limits, shows the real errors behind the most common pitfalls, and tests the code with MockMultipartFile.

1. What Is File Upload and Download in Spring Boot?

An upload is an HTTP request that carries a file from the client to the server. HTML forms and API clients send files as multipart/form-data: a request body split into parts, each with its own name and content type, so one request can carry several files and ordinary fields together.

A download is a response whose body is the file's bytes. The Content-Type header says what kind of file it is, and the Content-Disposition header says whether the browser should display it (inline) or save it (attachment), and under which file name.

Spring Boot handles the multipart parsing for you. With the web starter on the classpath, multipart support is enabled automatically, and you only write the controller and decide where files are kept.

2. How a Multipart Upload Works

Here is the body of a request that uploads an invoice together with a small JSON description. Each part starts with a boundary line that the client chooses, and the part's name is how Spring matches it to a controller parameter:

Anatomy of a multipart/form-data upload requestA POST to /api/files with Content-Type multipart/form-data and a boundary string chosen by the client. The body has two parts separated by the boundary. The first part, named file, has filename invoice-1001.pdf, content type application/pdf and the file bytes; Spring binds it to a MultipartFile parameter annotated @RequestPart("file"). The second part, named details, has content type application/json and the JSON {"description":"Invoice for order 1001"}; Spring converts it to the UploadDetails record for the @RequestPart("details") parameter. The final boundary ends with two dashes. POST /api/files HTTP/1.1 Content-Type: multipart/form-data; boundary=X7a9 --X7a9 Content-Disposition: form-data; name="file"; filename="invoice-1001.pdf" Content-Type: application/pdf %PDF-1.7 ... (the file bytes) --X7a9 Content-Disposition: form-data; name="details" Content-Type: application/json {"description":"Invoice for order 1001"} --X7a9-- Boundary chosen by the client, separates the parts @RequestPart("file") MultipartFile file @RequestPart("details") UploadDetails details (JSON converted to a record)
Figure 1: each part has its own name and content type; the name is what @RequestPart matches.

Before your controller runs, the server reads the request and enforces the size limits. With the default settings, each file part is written to a temporary file. Spring then wraps every file part in a MultipartFile, calls your controller, and deletes the temporary files when the request completes:

How an upload request is processedThe client sends a multipart/form-data request. Tomcat parses the parts and checks the file size, request size and part count limits; if a limit is exceeded, Spring raises MaxUploadSizeExceededException and the client gets 413 Content Too Large before the controller runs. Otherwise each file part is written to a temporary file and passed to FileController as a MultipartFile. FileStorageService validates it, returning 400 Bad Request for an empty file or a type that is not allowed, and copies it to the uploads folder under a UUID. The temporary files are deleted when the request completes. Client multipart/ form-data Tomcat parses the parts and checks file, request and part limits Temp files one per file part FileController MultipartFile parameters FileStorageService validates, copies to uploads/<uuid> 413 Content Too Large a limit was exceeded: MaxUploadSizeExceededException 400 Bad Request empty file or type not allowed Temp files are deleted when the request completes
Figure 2: size limits are enforced before your controller runs; your own validation happens in the service.

Two consequences follow. Size limits are checked before your code runs, so they need their own error handling (section 7). And a MultipartFile is only valid during the request, so you must save it before returning (section 8.4).

3. Setup

The web starter is all you need. The test starter is used in section 9:

pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
Version note: in Spring Boot 4, the Spring MVC starter is spring-boot-starter-webmvc. In Spring Boot 3.x it was spring-boot-starter-web. The spring.servlet.multipart.* property keys are the same in both versions.

The application settings raise the upload limits and define where files go and which types are accepted:

application.properties
spring.application.name=shop

# Upload limits: per file, and for the whole multipart request
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=20MB

# Where files are saved, and which types are accepted
app.storage.location=uploads
app.storage.allowed-content-types=image/png,image/jpeg,application/pdf

4. Uploading Files with MultipartFile

4.1 The MultipartFile Interface

MultipartFile (from org.springframework.web.multipart) represents one uploaded file part. The methods you will use most:

MethodReturnsNotes
getOriginalFilename()The client's file nameUntrusted, can contain paths such as ../, and may be null
getContentType()The part's declared typeSet by the client, so treat it as a hint
getSize() / isEmpty()Size in bytes / whether it is 0 bytesAn empty file still arrives as a part
getInputStream()A stream of the contentUse it to copy large files without loading them into memory
transferTo(Path)NothingSaves the content to a file in one call
getBytes()The whole content as a byte[]Loads the entire file into memory; fine only for small files

4.2 @RequestParam vs @RequestPart

Both annotations bind a multipart part to a controller parameter. The difference shows up with parts that are not files:

@RequestParam@RequestPart
File parts (MultipartFile)YesYes
Plain text fieldsYes, converted like query parametersYes
JSON part to an object, e.g. a recordNoYes, using the part's Content-Type and a message converter
Also reads query parametersYesNo

So use @RequestPart when a file comes with structured JSON, as the example's upload endpoint does. A missing required part is rejected with 400 Bad Request.

4.3 Sending JSON Together with a File

The client sends the JSON as its own part with Content-Type: application/json, and Spring converts it into the UploadDetails record. Marking it required = false makes the description optional:

FileController.java (excerpt)
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<StoredFile> upload(@RequestPart("file") MultipartFile file,
                                         @RequestPart(name = "details", required = false) UploadDetails details) {
    StoredFile stored = storageService.store(file, details == null ? null : details.description());
    return ResponseEntity.created(URI.create("/api/files/" + stored.id())).body(stored);
}

4.4 Uploading Multiple Files

When a client sends several parts with the same name, bind them to a List<MultipartFile> (or MultipartFile[]):

FileController.java (excerpt)
@PostMapping(path = "/batch", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public List<StoredFile> uploadMany(@RequestParam("files") List<MultipartFile> files) {
    return files.stream()
            .map(file -> storageService.store(file, null))
            .toList();
}

5. Downloading Files with Resource and Content-Disposition

Return a ResponseEntity<Resource>. A Resource is Spring's handle on a file, classpath entry or other source of bytes. Spring MVC streams it to the client in chunks, so even a large file never has to fit in memory. Set three headers:

  • Content-Type tells the client what the file is, for example application/pdf.
  • Content-Disposition chooses between attachment (save the file) and inline (show it in the browser if possible), and supplies the file name.
  • Content-Length lets the client show download progress.

Build the disposition header with Spring's ContentDisposition builder rather than by joining strings. When you pass a charset, it adds an encoded filename* parameter, so names such as résumé Priya.pdf survive the trip:

FileController.java (excerpt)
ContentDisposition disposition = ContentDisposition.attachment()
        .filename(file.originalName(), StandardCharsets.UTF_8)
        .build();

// For "résumé Priya.pdf" the header becomes:
// Content-Disposition: attachment; filename="resume Priya.pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9%20Priya.pdf

For Resource bodies, Spring MVC also supports HTTP range requests: it adds Accept-Ranges: bytes and answers a Range header with 206 Partial Content. That lets browsers resume interrupted downloads and seek in videos at no extra cost (section 6.4 shows it).

6. A Complete Example: File Upload and Download REST API

The example stores invoices and product images for a shop. It saves each upload under a generated id, keeps the file's metadata, and serves it back as a download. It builds on the controller style from the REST API tutorial and runs on Spring Boot 4.1 with Java 21.

EndpointPurposeSuccess
POST /api/filesUpload one file with optional JSON details201 Created with Location
POST /api/files/batchUpload several files200 OK
GET /api/filesList uploaded files200 OK
GET /api/files/{id}Download a file (?inline=true to display it)200 OK, or 206 for a range

6.1 Storage Settings and DTOs

StorageProperties binds the app.storage.* settings to a record. The configuration properties tutorial explains how that binding works:

package com.javabytechie.shop.files;

import java.nio.file.Path;
import java.util.Set;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("app.storage")
public record StorageProperties(Path location, Set<String> allowedContentTypes) {
}

6.2 The Storage Service

The service rejects empty files and types that are not allowed, then streams the content to disk under a random UUID. The client's file name is kept only as metadata:

FileStorageService.java
package com.javabytechie.shop.files;

import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

@Service
public class FileStorageService {

    private static final Logger log = LoggerFactory.getLogger(FileStorageService.class);

    private final Path root;
    private final StorageProperties properties;
    // Metadata is kept in memory to keep the example short; a real app would use a database table
    private final Map<String, StoredFile> files = new ConcurrentHashMap<>();

    public FileStorageService(StorageProperties properties) {
        this.properties = properties;
        this.root = properties.location().toAbsolutePath().normalize();
        try {
            Files.createDirectories(root);
        } catch (IOException e) {
            throw new UncheckedIOException("Could not create upload directory " + root, e);
        }
    }

    public StoredFile store(MultipartFile file, String description) {
        if (file.isEmpty()) {
            throw new InvalidFileException("File is empty");
        }
        String contentType = file.getContentType();
        if (contentType == null || !properties.allowedContentTypes().contains(contentType)) {
            throw new InvalidFileException("File type not allowed: " + contentType);
        }

        // Keep only the last part of the client's file name, e.g. "../../invoice.pdf" -> "invoice.pdf"
        String originalName = StringUtils.getFilename(
                StringUtils.cleanPath(Objects.requireNonNullElse(file.getOriginalFilename(), "file")));

        // Save under a generated id, never under the name the client sent
        String id = UUID.randomUUID().toString();
        Path target = root.resolve(id);
        try (InputStream in = file.getInputStream()) {
            Files.copy(in, target);
        } catch (IOException e) {
            throw new UncheckedIOException("Could not save " + originalName, e);
        }

        StoredFile stored = new StoredFile(id, originalName, contentType, file.getSize(), description, Instant.now());
        files.put(id, stored);
        log.info("Stored {} ({} bytes) as {}", originalName, file.getSize(), id);
        return stored;
    }

    public List<StoredFile> list() {
        return files.values().stream()
                .sorted(Comparator.comparing(StoredFile::uploadedAt))
                .toList();
    }

    public FileDownload load(String id) {
        StoredFile stored = files.get(id);
        if (stored == null) {
            throw new StoredFileNotFoundException(id);
        }
        Resource resource = new FileSystemResource(root.resolve(id));
        if (!resource.exists()) {
            throw new StoredFileNotFoundException(id);
        }
        return new FileDownload(stored, resource);
    }
}

6.3 Controller and Error Handling

The exception handler extends ResponseEntityExceptionHandler, so Spring MVC's own errors, such as a missing part or an upload that is too large, are returned in the same ProblemDetail format as the application's errors. The exception handling tutorial covers this class in depth:

package com.javabytechie.shop.files;

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.List;

import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/api/files")
public class FileController {

    private final FileStorageService storageService;

    public FileController(FileStorageService storageService) {
        this.storageService = storageService;
    }

    // One file, plus optional JSON details sent as a second part
    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<StoredFile> upload(@RequestPart("file") MultipartFile file,
                                             @RequestPart(name = "details", required = false) UploadDetails details) {
        StoredFile stored = storageService.store(file, details == null ? null : details.description());
        return ResponseEntity.created(URI.create("/api/files/" + stored.id())).body(stored);
    }

    // Several files sent under the same part name
    @PostMapping(path = "/batch", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public List<StoredFile> uploadMany(@RequestParam("files") List<MultipartFile> files) {
        return files.stream()
                .map(file -> storageService.store(file, null))
                .toList();
    }

    @GetMapping
    public List<StoredFile> list() {
        return storageService.list();
    }

    // ?inline=true lets the browser display the file instead of saving it
    @GetMapping("/{id}")
    public ResponseEntity<Resource> download(@PathVariable String id,
                                             @RequestParam(defaultValue = "false") boolean inline) {
        FileDownload download = storageService.load(id);
        StoredFile file = download.file();
        ContentDisposition disposition = (inline ? ContentDisposition.inline() : ContentDisposition.attachment())
                .filename(file.originalName(), StandardCharsets.UTF_8)
                .build();
        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(file.contentType()))
                .contentLength(file.size())
                .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
                .body(download.resource());
    }
}

6.4 Run It

Start the app with ./mvnw spring-boot:run and upload with curl -F, which sends multipart/form-data. The ;type= suffix sets each part's content type:

Terminal
$ curl -s -i -F 'file=@invoice-1001.pdf;type=application/pdf' \
    -F 'details={"description":"Invoice for order 1001"};type=application/json' \
    localhost:8080/api/files
HTTP/1.1 201
Location: /api/files/374e1d9b-068b-4492-adb3-9821f634fa8a

{"id":"374e1d9b-068b-4492-adb3-9821f634fa8a","originalName":"invoice-1001.pdf","contentType":"application/pdf","size":42,"description":"Invoice for order 1001","uploadedAt":"2026-10-25T05:17:30.272415Z"}

$ curl -s -F 'files=@blue-kettle.png;type=image/png' \
    -F 'files=@order-1002.pdf;type=application/pdf' localhost:8080/api/files/batch
[{"id":"7cc25ff7-abe7-4a37-b0fc-c7177f24062c","originalName":"blue-kettle.png","contentType":"image/png","size":17,"description":null,"uploadedAt":"2026-10-25T05:17:30.301767Z"},{"id":"28706dff-bdf5-4940-bc62-b329cc36a089","originalName":"order-1002.pdf","contentType":"application/pdf","size":20,"description":null,"uploadedAt":"2026-10-25T05:17:30.302015Z"}]

Download the invoice, then ask for only its first 9 bytes:

Terminal
$ curl -s -D - -o invoice.pdf localhost:8080/api/files/374e1d9b-068b-4492-adb3-9821f634fa8a
HTTP/1.1 200
Content-Disposition: attachment; filename="invoice-1001.pdf"; filename*=UTF-8''invoice-1001.pdf
Accept-Ranges: bytes
Content-Type: application/pdf
Content-Length: 42

$ curl -s -i -r 0-8 localhost:8080/api/files/374e1d9b-068b-4492-adb3-9821f634fa8a
HTTP/1.1 206
Content-Disposition: attachment; filename="invoice-1001.pdf"; filename*=UTF-8''invoice-1001.pdf
Content-Range: bytes 0-8/42
Content-Type: application/pdf
Content-Length: 9

%PDF-1.7

Unsupported, empty and unknown files get clear errors:

Terminal
$ curl -s -i -F 'file=@notes.sh;type=text/x-sh' localhost:8080/api/files
HTTP/1.1 400
{"detail":"File type not allowed: text/x-sh","instance":"/api/files","status":400,"title":"Bad Request"}

$ curl -s -i -F 'file=@empty.pdf;type=application/pdf' localhost:8080/api/files
HTTP/1.1 400
{"detail":"File is empty","instance":"/api/files","status":400,"title":"Bad Request"}

$ curl -s -i -F 'other=@order-1002.pdf;type=application/pdf' localhost:8080/api/files
HTTP/1.1 400
{"detail":"Required part 'file' is not present.","instance":"/api/files","status":400,"title":"Bad Request"}

$ curl -s -i localhost:8080/api/files/does-not-exist
HTTP/1.1 404
{"detail":"File not found: does-not-exist","instance":"/api/files/does-not-exist","status":404,"title":"Not Found"}

The log shows each file saved under its generated id:

Console
c.j.shop.files.FileStorageService : Stored invoice-1001.pdf (42 bytes) as 374e1d9b-068b-4492-adb3-9821f634fa8a
c.j.shop.files.FileStorageService : Stored blue-kettle.png (17 bytes) as 7cc25ff7-abe7-4a37-b0fc-c7177f24062c
c.j.shop.files.FileStorageService : Stored order-1002.pdf (20 bytes) as 28706dff-bdf5-4940-bc62-b329cc36a089

7. Configuring File Upload Size Limits

Spring Boot's default limit is only 1MB per file, which is the first thing most projects need to change. These are the settings that control uploads:

PropertyDefaultWhat it controls
spring.servlet.multipart.max-file-size1MBLargest single file
spring.servlet.multipart.max-request-size10MBLargest whole multipart request, all parts together
server.tomcat.max-part-count50Most parts per request, counting files and form fields
spring.servlet.multipart.file-size-threshold0BSize above which a part is written to a temporary file instead of kept in memory
spring.servlet.multipart.locationServer's temp directoryWhere those temporary files go
spring.servlet.multipart.enabledtrueTurns multipart support on or off

When a limit is exceeded, the server rejects the request before your controller runs, and Spring throws MaxUploadSizeExceededException. The example's handler turns all three cases into 413 Content Too Large:

Terminal
# One 6MB file (limit: 5MB per file)
$ curl -s -i -F 'file=@big-catalogue.pdf;type=application/pdf' localhost:8080/api/files
HTTP/1.1 413
Connection: close
{"detail":"Upload rejected: files can be up to 5MB each, 20MB per request, and 50 parts at most","instance":"/api/files","status":413,"title":"Content Too Large"}

# Seven 3MB files, 21MB in total (limit: 20MB per request)
HTTP/1.1 413
{"detail":"Upload rejected: files can be up to 5MB each, 20MB per request, and 50 parts at most","instance":"/api/files/batch","status":413,"title":"Content Too Large"}

# Sixty 20-byte files (limit: 50 parts)
HTTP/1.1 413
{"detail":"Upload rejected: files can be up to 5MB each, 20MB per request, and 50 parts at most","instance":"/api/files/batch","status":413,"title":"Content Too Large"}

The log records which limit was hit, because the cause differs in each case:

Console
WARN c.j.shop.files.FileExceptionHandler : Upload rejected: org.apache.tomcat.util.http.fileupload.impl.FileSizeLimitExceededException: The field file exceeds its maximum permitted size of 5242880 bytes.
WARN c.j.shop.files.FileExceptionHandler : Upload rejected: org.apache.tomcat.util.http.fileupload.impl.SizeLimitExceededException: the request was rejected because its size (22021212) exceeds the configured maximum (20971520)
WARN c.j.shop.files.FileExceptionHandler : Upload rejected: org.apache.tomcat.util.http.fileupload.impl.FileCountLimitExceededException: attachment
Version note: Spring Framework 6.1 renamed the 413 constant to HttpStatus.CONTENT_TOO_LARGE. The old HttpStatus.PAYLOAD_TOO_LARGE is deprecated.

8. Common Pitfalls and How to Fix Them

8.1 "Maximum upload size exceeded" on Small Files

A 2MB image fails even though nothing in your code limits it, because the default max-file-size is 1MB. How to fix it: set spring.servlet.multipart.max-file-size and max-request-size to sizes that suit your use case. Keep max-request-size at least as large as max-file-size.

8.2 413 for Many Small Files

Sending 60 small files to the batch endpoint fails with 413, even though they total only a few kilobytes. Tomcat's server.tomcat.max-part-count allows 50 parts per request, and the log shows FileCountLimitExceededException. How to fix it: raise the limit if you really need more parts, or upload in smaller batches.

8.3 Saving Files Under the Client's File Name

Writing to root.resolve(file.getOriginalFilename()) trusts a value the client controls. A name like ../../app/config.properties can write outside the upload folder (a path traversal attack), and two users uploading invoice.pdf overwrite each other. How to fix it: save under a generated id, as FileStorageService does, and keep the original name only as metadata. When you display it, strip any directories first, as StringUtils.getFilename(StringUtils.cleanPath(name)) does.

8.4 Using a MultipartFile After the Request Ends

Spring deletes the temporary files when the request completes. If you pass a MultipartFile to an @Async method or another thread, the content may already be gone when that code runs. How to fix it: save the file to permanent storage inside the request, then hand the stored id or path to the background job.

8.5 Loading Whole Files into Memory

Calling file.getBytes() on upload, or returning a byte[] on download, holds the entire file in memory for each request at the same time. A few concurrent large files can exhaust the heap. How to fix it: copy with getInputStream() or transferTo(), and return a Resource for downloads.

8.6 Trusting the Content-Type

The client sets each part's content type, so image/png does not prove the file is an image. How to fix it: use an allow-list as a first filter, as the example does, and if it matters, check the file's actual content with a detection library. Never serve user uploads from a folder where the server executes or interprets files.

8.7 415 Unsupported Media Type

Posting JSON to an endpoint declared with consumes = MediaType.MULTIPART_FORM_DATA_VALUE returns 415 with "Content-Type 'application/json' is not supported.". How to fix it: send the request as multipart/form-data, and don't set the Content-Type header by hand in the client, because it must include the boundary. Let curl -F, the browser's FormData or your HTTP client generate it.

9. Testing File Upload and Download

MockMultipartFile (from org.springframework.mock.web) builds an upload in memory. The unit test below runs the real service against JUnit's @TempDir. It checks that the folder is empty before the upload and holds the file after it, and that rejected files leave nothing behind:

FileStorageServiceTest.java
package com.javabytechie.shop.files;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;

class FileStorageServiceTest {

    @TempDir
    Path uploadDir;

    private FileStorageService service;

    @BeforeEach
    void setUp() {
        service = new FileStorageService(new StorageProperties(uploadDir, Set.of("application/pdf")));
    }

    @Test
    void storesFileUnderGeneratedIdAndKeepsOnlyTheFileName() throws IOException {
        MockMultipartFile pdf = new MockMultipartFile("file", "../../invoice-1001.pdf",
                "application/pdf", "%PDF-1.7 invoice".getBytes());
        assertThat(service.list()).isEmpty();
        try (var stream = Files.list(uploadDir)) {
            assertThat(stream).isEmpty();
        }

        StoredFile stored = service.store(pdf, "Invoice for order 1001");

        assertThat(stored.originalName()).isEqualTo("invoice-1001.pdf");
        assertThat(service.list()).containsExactly(stored);
        assertThat(Files.readString(uploadDir.resolve(stored.id()))).isEqualTo("%PDF-1.7 invoice");
        assertThat(service.load(stored.id()).resource().contentLength()).isEqualTo(16);
    }

    @Test
    void rejectsEmptyAndDisallowedFilesWithoutSavingThem() throws IOException {
        MockMultipartFile empty = new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]);
        MockMultipartFile script = new MockMultipartFile("file", "run.sh", "text/x-sh", "rm -rf /".getBytes());

        assertThatThrownBy(() -> service.store(empty, null))
                .isInstanceOf(InvalidFileException.class).hasMessage("File is empty");
        assertThatThrownBy(() -> service.store(script, null))
                .isInstanceOf(InvalidFileException.class).hasMessage("File type not allowed: text/x-sh");

        assertThat(service.list()).isEmpty();
        try (var stream = Files.list(uploadDir)) {
            assertThat(stream).isEmpty();
        }
    }

    @Test
    void unknownIdIsNotFound() {
        assertThatThrownBy(() -> service.load("missing-id"))
                .isInstanceOf(StoredFileNotFoundException.class).hasMessage("File not found: missing-id");
    }
}

The web layer test sends multipart requests through MockMvc with multipart() and replaces the service with a Mockito mock using @MockitoBean. Size limits are enforced by the real server, not by MockMvc, so check them by running the app as in section 7. The Spring Boot testing tutorial covers @WebMvcTest in more detail:

FileControllerTest.java
package com.javabytechie.shop.files;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.time.Instant;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(FileController.class)
class FileControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockitoBean
    FileStorageService storageService;

    private final StoredFile invoice = new StoredFile("a1b2", "invoice-1001.pdf", "application/pdf",
            16, "Invoice for order 1001", Instant.parse("2026-10-25T09:30:00Z"));

    @Test
    void uploadReturns201WithLocation() throws Exception {
        MockMultipartFile file = new MockMultipartFile("file", "invoice-1001.pdf",
                "application/pdf", "%PDF-1.7 invoice".getBytes());
        MockMultipartFile details = new MockMultipartFile("details", "", "application/json",
                "{\"description\":\"Invoice for order 1001\"}".getBytes());
        given(storageService.store(any(), eq("Invoice for order 1001"))).willReturn(invoice);

        mockMvc.perform(multipart("/api/files").file(file).file(details))
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", "/api/files/a1b2"))
                .andExpect(jsonPath("$.originalName").value("invoice-1001.pdf"));

        verify(storageService).store(file, "Invoice for order 1001");
    }

    @Test
    void downloadSendsFileAsAttachment() throws Exception {
        given(storageService.load("a1b2"))
                .willReturn(new FileDownload(invoice, new ByteArrayResource("%PDF-1.7 invoice".getBytes())));

        mockMvc.perform(get("/api/files/a1b2"))
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_PDF))
                .andExpect(header().string("Content-Disposition", "attachment; filename=\"invoice-1001.pdf\"; filename*=UTF-8''invoice-1001.pdf"))
                .andExpect(content().string("%PDF-1.7 invoice"));
    }

    @Test
    void rejectedFileReturns400() throws Exception {
        MockMultipartFile script = new MockMultipartFile("file", "run.sh", "text/x-sh", "echo".getBytes());
        given(storageService.store(any(), any())).willThrow(new InvalidFileException("File type not allowed: text/x-sh"));

        mockMvc.perform(multipart("/api/files").file(script))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.detail").value("File type not allowed: text/x-sh"));
    }

    @Test
    void unknownFileReturns404() throws Exception {
        given(storageService.load("missing-id")).willThrow(new StoredFileNotFoundException("missing-id"));

        mockMvc.perform(get("/api/files/missing-id"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.detail").value("File not found: missing-id"));
    }
}
Console
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.files.FileControllerTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.files.FileStorageServiceTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
Version note: @MockBean was removed in Spring Boot 4. Use @MockitoBean. In Boot 4, @WebMvcTest lives in org.springframework.boot.webmvc.test.autoconfigure and comes from spring-boot-starter-webmvc-test.

10. Where to Store Uploaded Files

The example writes to a local folder, which is the simplest option but not always the right one:

StorageGood fitPoor fit
Local disk or mounted volumeOne server, internal tools, small volumesSeveral instances behind a load balancer, or containers without a persistent volume
Object storage (e.g. Amazon S3, Azure Blob, Google Cloud Storage)Cloud deployments, large or many files, sharing between instancesFully offline environments
Database column (BLOB)Small files that must stay consistent with a row in the same transactionLarge files, which bloat the database and backups

Whatever you choose, keep the metadata (id, original name, type, size, owner) in a database table. The example keeps it in a ConcurrentHashMap only to stay short, so it is lost on restart. Hiding storage behind a service like FileStorageService lets you switch from disk to object storage without touching the controller.

11. Best Practices

  • Set explicit size limits for files and requests that match your use case, and return a clear 413 when they are exceeded.
  • Store files under generated ids, never under the client's file name.
  • Allow-list content types and reject empty files before saving anything.
  • Stream instead of buffering: copy uploads with getInputStream() and return downloads as a Resource.
  • Build Content-Disposition with ContentDisposition and a charset, so non-ASCII file names work.
  • Choose attachment or inline deliberately: force attachment for file types a browser could execute or render as a page.
  • Save uploads within the request before handing work to another thread.
  • Return the same error format everywhere by extending ResponseEntityExceptionHandler.
  • Keep file storage behind a service so you can move to object storage later.
  • Test with MockMultipartFile and a temporary folder, checking that rejected files leave nothing on disk.

12. Summary

File upload and download in Spring Boot comes down to a few steps. Accept multipart/form-data requests with MultipartFile parameters, using @RequestPart when a JSON part comes with the file and a List for several files. Validate the file, stream it to storage under a generated id, and keep its metadata. Serve it back as a ResponseEntity<Resource> with Content-Type, Content-Length and a Content-Disposition header built with ContentDisposition. Raise max-file-size and max-request-size from their small defaults, and turn MaxUploadSizeExceededException into a clear 413 response. The main recommendation: never trust the client's file name or content type, and never hold whole files in memory.