File upload sounds boring until you've spent an afternoon debugging MultipartException: Failed to parse multipart servlet request in production.

In Spring Boot, file upload involves MultipartFile, MultipartAutoConfiguration, spring.servlet.multipart.* properties, and a silent assumption that you're running on a servlet container. It works, but it carries a lot of hidden machinery.

Solon takes a different path. The core class is UploadedFile, it lives in the web layer with no extra configuration needed, and you have explicit control over when multipart parsing happens and when temporary files get cleaned up.

The Basics: UploadedFile

When a controller method includes an UploadedFile parameter, Solon automatically triggers multipart parsing — no annotation required:

@Controller
public class FileController {

    @Post
    @Mapping("/upload")
    public String upload(UploadedFile file) {
        try {
            file.transferTo(new File("/storage/uploads/" + file.name));
            return "uploaded: " + file.name + " (" + file.contentSize + " bytes)";
        } finally {
            file.delete(); // always clean up
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Key properties you'll use most:

Property Description
file.name Full filename including extension
file.extension Extension only (e.g. jpg)
file.contentType MIME type
file.contentSize File size in bytes
file.content Raw InputStream
file.contentAsBytes byte[]
file.isEmpty() Whether the upload is empty

One thing worth noting: the framework does not auto-clean temporary files. If you skip delete(), those temp files stay on disk. The try/finally pattern above isn't optional.

Multiple Files, Same Field Name

Use UploadedFile[] when the client sends multiple files under the same field name (supported since v2.3.8):

@Post
@Mapping("/upload/batch")
public void uploadBatch(UploadedFile[] files) {
    for (UploadedFile file : files) {
        try {
            file.transferTo(new File("/storage/" + file.name));
        } finally {
            file.delete();
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

When the Field Name Doesn't Match

If the form field name differs from your parameter name, use @Param:

@Post
@Mapping("/avatar")
public void setAvatar(@Param("user_avatar") UploadedFile file) {
    // handles form field "user_avatar"
}

Enter fullscreen mode Exit fullscreen mode

Mixed Forms: Files + Regular Fields

You can receive both file and text fields in the same request. Parameter injection handles both automatically:

@Post
@Mapping("/upload/with-meta")
public void uploadWithMeta(UploadedFile file, String description, int category) {
    try {
        // file: the uploaded document
        // description, category: plain text form fields
        saveFile(file, description, category);
    } finally {
        file.delete();
    }
}

Enter fullscreen mode Exit fullscreen mode

When There's No UploadedFile Parameter

Sometimes a multipart form has only text fields — no file attachment. In that case Solon won't trigger multipart parsing automatically. Use multipart = true on the mapping:

@Post
@Mapping(path = "/submit", multipart = true)
public void submit(String username, int age) {
    // multipart form with text fields only
}

Enter fullscreen mode Exit fullscreen mode

You can also access files manually via Context when you need more control:

@Post
@Mapping(path = "/upload/manual", multipart = true)
public void uploadManual(String username, Context ctx) {
    UploadedFile file = ctx.file("attachment");
    UploadedFile[] extras = ctx.files("extras");
    // process...
}

Enter fullscreen mode Exit fullscreen mode

Security: Controlling autoMultipart

By default, autoMultipart is true — any incoming multipart request will be parsed automatically. On a public-facing service, this means a client can send a large file to any endpoint and trigger parsing overhead.

Tighten this up with a router filter:

Solon.start(App.class, args, app -> {
    app.router().filter(-1, (ctx, chain) -> {
        // only parse multipart on upload paths
        ctx.autoMultipart(ctx.path().startsWith("/upload"));
        chain.doFilter(ctx);
    });
});

Enter fullscreen mode Exit fullscreen mode

For centralized temp-file cleanup (v2.7.3+), a filter also works well:

@Component
public class MultipartCleanupFilter implements Filter {
    @Override
    public void doFilter(Context ctx, FilterChain chain) throws Throwable {
        try {
            chain.doFilter(ctx);
        } finally {
            if (ctx.isMultipartFormData()) {
                ctx.filesDelete(); // cleans all uploaded temp files
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Note: If you use this pattern, don't make your upload handlers async. The filter runs on request completion — async handlers may still be working when cleanup fires.

File Size Configuration

# app.yml
server:
  request:
    maxBodySize: 2mb          # max request body (default: 2mb)
    maxFileSize: 20mb         # max single file size
    maxHeaderSize: 8kb        # max header size
    fileSizeThreshold: 512kb  # below this: memory; above: temp file (v3.6.0+)

Enter fullscreen mode Exit fullscreen mode

The fileSizeThreshold setting (introduced in v3.6.0) automatically routes small files to memory and large files to disk. Before v3.6.0, you had useTempfile: true for forced temp-file mode — that flag is now deprecated.

Downloading Files

Returning files is equally clean. Return DownloadedFile for byte arrays or streams, or just return a java.io.File directly:

@Get
@Mapping("/download/report")
public DownloadedFile downloadReport() {
    byte[] pdf = reportService.generatePdf();
    return new DownloadedFile("application/pdf", pdf, "report.pdf");
}

@Get
@Mapping("/download/avatar/{userId}")
public File downloadAvatar(@Path String userId) {
    return new File("/storage/avatars/" + userId + ".jpg");
}

Enter fullscreen mode Exit fullscreen mode

For more control — caching, inline display, ETags:

@Get
@Mapping("/preview/logo")
public DownloadedFile previewLogo() {
    DownloadedFile file = new DownloadedFile(new File("/assets/logo.png"));
    file.asAttachment(false);       // display inline, don't trigger download
    file.cacheControl(3600);        // 304 cache for 1 hour
    file.eTag("logo-v3");
    return file;
}

Enter fullscreen mode Exit fullscreen mode

DownloadedFile also supports HTTP Range, so it works for video streaming and large file resumable downloads without any extra configuration.

No Extra Dependencies

Everything above is in solon-web — no separate multipart starter, no auto-configuration class to hunt down:

<dependency>
    <groupId>org.noear</groupId>
    <artifactId>solon-web</artifactId>
</dependency>

Enter fullscreen mode Exit fullscreen mode

Pick any server adapter (JDK HTTP, Jetty, Undertow, Grizzly, smarthttp) — they all support temp-file mode via fileSizeThreshold.

The Mental Model

The contrast with Spring is mostly about explicitness:

  • Spring Boot: MultipartFile injected by the servlet layer; MultipartAutoConfiguration registers everything; spring.servlet.multipart.* configures limits; CommonsMultipartResolver optional.
  • Solon: UploadedFile parameter → parsing triggered; temp files stay until you call delete(); autoMultipart gives you path-level control; configuration lives in server.request.*.

For most projects the difference is minor. Where it matters is when you want to reason clearly about when parsing happens, what lives in memory vs disk, and who is responsible for cleanup. Solon makes those questions explicit instead of hiding them in auto-configuration.


Reference: