@Entity
@Table(name = "stored_files")
public class StoredFile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String filename;
@Column(nullable = false)
private String contentType;
@Column(nullable = false)
private long size;
@Column(name = "storage_key", nullable = false, unique = true)
private String storageKey;
@Column(length = 512)
private String description;
@Column(name = "uploaded_at", nullable = false)
private Instant uploadedAt;
@PrePersist
void onCreate() {
if (this.storageKey == null) {
this.storageKey = UUID.randomUUID().toString().replace("-", "");
}
this.uploadedAt = Instant.now();
}
public String getStorageKey() { return storageKey; }
public void setStorageKey(String storageKey) { this.storageKey = storageKey; }
public void setFilename(String filename) { this.filename = filename; }
public void setContentType(String contentType) { this.contentType = contentType; }
public void setSize(long size) { this.size = size; }
public void setDescription(String description) { this.description = description; }
public Long getId() { return id; }
}
@Service
public class FileStorageService {
private final Path rootLocation;
public FileStorageService(@Value("${app.storage.root:/var/data/uploads}") String root) {
this.rootLocation = Paths.get(root).toAbsolutePath().normalize();
}
@PostConstruct
void init() {
try {
Files.createDirectories(rootLocation);
} catch (IOException e) {
throw new StorageException("Could not initialize storage directory", e);
}
}
public String store(MultipartFile file, String storageKey) {
Path target = rootLocation.resolve(storageKey).normalize();
if (!target.startsWith(rootLocation)) {
throw new StorageException("Rejected path outside storage root: " + storageKey);
}
try (InputStream in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
return storageKey;
} catch (IOException e) {
throw new StorageException("Failed to store file " + file.getOriginalFilename(), e);
}
}
}
@RestController
@RequestMapping("/api/files")
public class FileUploadController {
private final FileStorageService storage;
private final StoredFileRepository repository;
public FileUploadController(FileStorageService storage, StoredFileRepository repository) {
this.storage = storage;
this.repository = repository;
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional
public ResponseEntity<Map<String, Object>> upload(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "description", required = false) String description) {
if (file.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "file is required"));
}
StoredFile record = new StoredFile();
record.setFilename(StringUtils.cleanPath(file.getOriginalFilename()));
record.setContentType(Optional.ofNullable(file.getContentType())
.orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE));
record.setSize(file.getSize());
record.setDescription(description);
StoredFile saved = repository.save(record);
storage.store(file, saved.getStorageKey());
URI location = URI.create("/api/files/" + saved.getId());
return ResponseEntity.created(location)
.body(Map.of("id", saved.getId(), "storageKey", saved.getStorageKey()));
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<Map<String, Object>> tooLarge(MaxUploadSizeExceededException ex) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(Map.of("error", "upload exceeds configured size limit"));
}
}
This snippet shows how a Spring Boot service accepts an uploaded file over multipart/form-data, streams it to disk, and persists a durable metadata record so the raw bytes and their descriptive information stay decoupled. The pattern is common whenever an application needs to serve, audit, or garbage-collect uploads later: the binary content lives on a filesystem (or object store), while a database row carries the searchable facts about it.
In StoredFile entity, the JPA entity models exactly that metadata: the original client-provided filename, the negotiated contentType, the byte size, and a storageKey that points at the physical location. A UUID-based storageKey is generated in @PrePersist so the on-disk name never collides even when two clients upload files with the same original name, and it avoids leaking user-controlled paths. The entity deliberately stores no bytes — keeping large blobs out of the row keeps queries and backups cheap.
FileStorageService isolates all disk I/O behind one class. store derives a fresh storageKey, resolves it against a configured rootLocation, and calls Files.copy with REPLACE_EXISTING while streaming from MultipartFile.getInputStream(), so the whole file is never buffered in memory at once. The crucial detail is the normalize().startsWith(rootLocation) guard: it defeats path-traversal attempts (a ../ in a crafted name) before anything touches the filesystem. Any IOException is wrapped in a domain StorageException so callers deal with one failure type.
FileUploadController ties it together. The @PostMapping consumes MULTIPART_FORM_DATA_VALUE and binds the part to a @RequestParam("file") MultipartFile, alongside an optional free-text description field from the same form. It rejects empty uploads early, delegates byte-writing to the service, then builds and saves a StoredFile transactionally. The handler returns 201 Created with a Location header pointing at the new resource, following REST conventions. A @ControllerAdvice-style @ExceptionHandler maps MaxUploadSizeExceededException to 413, which is the pitfall most first implementations miss — Spring's multipart size limits throw before the controller body runs, so they must be handled at the framework boundary rather than inside the method.
Related snips
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
package com.example.starter.config;
import com.example.starter.properties.CustomProperties;
import com.example.starter.service.CustomService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
Custom Spring Boot starters
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
# Installation
# rails active_storage:install
# rails db:migrate
# config/storage.yml
local:
ActiveStorage for file uploads and attachments
package com.example.demo.config;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
Messaging with Apache Kafka
Share this code
Here's the card — post it anywhere.