1. Границы upload-endpoint’а
После upload-проверок картина уже собрана: shape-ошибки ловит Spring MVC, @Valid отвечает за metadata, дешёвые file-checks режут пустой файл и неподдерживаемый тип, а 404/409 живут в бизнес-правилах. Теперь главная опасность другая — свалить всё это в один endpoint и получить контроллер на 300 строк.
File upload почти всегда начинается одинаково: «давайте просто примем файл и сохраним». И почти всегда заканчивается одинаково: «почему у нас контроллер на 300 строк, сервис принимает MultipartFile, а любое изменение ломает всё вокруг?». Это не магия и не злой рок — это обычная инженерная реальность: файловый сценарий быстро обрастает проверками, бизнес-правилами, метаданными и ошибками.
В обычном JSON-endpoint’е вы чаще всего не чувствуете боли сразу, потому что DTO красиво валидируются, а данные маленькие. С файлами ощущение другое: вы работаете с контентом, размерами, типами, исключениями IOException, плюс у вас появляется соблазн сделать endpoint «универсальным швейцарским ножом». Именно поэтому на upload’е особенно важно держать в голове простую мысль: контроллер — это переводчик с HTTP на язык приложения, а сервис — это место, где живут правила операции.
Если провести границу правильно, то контроллер получится коротким (его можно понять глазами за 30 секунд), сервис будет тестироваться без поднятия MVC, а ваш код будет развиваться спокойно. Если границу не провести, то вы получите «полуживое» существо: контроллер, который одновременно принимает multipart, валидирует, решает бизнес-конфликты, генерирует id, пишет в хранилище, формирует ProblemDetail и ещё немного играет в диджея — «сейчас я вам поставлю трек Location header, а потом миксану с try/catch». Звучит смешно… пока это не ваша кодовая база.
Именно поэтому upload лучше держать отдельной операцией со своим чистым внутренним входом; выдача файла и устройство storage — уже другая задача, и ей не место в том же методе.
2. Тонкий контроллер в upload-сценарии
Слово «тонкий контроллер» иногда звучит как советы из мира фитнеса: «будь стройным, здоровым и не ешь сахар». Но в backend это очень конкретная техника: контроллер делает минимум работы, связанный строго с web-layer, и дальше делегирует. Не потому что «так модно», а потому что иначе вам придётся тестировать и поддерживать MVC-специфику во всех слоях.
У upload-endpoint’а есть идеальный ритм, который стоит запомнить как небольшую мантру (да, будет мантра, но инженерная): bind → convert → delegate → return. То есть мы привязали входные данные (@PathVariable, @RequestPart), превратили их в внутренний формат (команда/объект операции), передали в сервис, и построили HTTP-ответ.
Снаружи upload остаётся одним и тем же: мы создаём attachment, отдаём 201 Created, Location и metadata созданного ресурса. Внутри нас сейчас интересует другое — как не размазать по контроллеру binding, cheap file checks, business rules и response mapping.
Чтобы это ощущалось не как лозунг, а как конкретика, полезно сравнить зоны ответственности в одной таблице:
| Зона | Про что она знает | Про что она не должна знать |
|---|---|---|
| Controller (web-layer) | HTTP path/parts, @RequestPart, ResponseEntity, Location | task not found, архивность, детали storage |
| Upload validator / assembler | cheap file checks (isEmpty, whitelist Content-Type, прикладной max size), сборка AttachmentUploadCommand | 404/409, ResponseEntity, доменные переходы |
| Service (application/domain) | правила операции «прикрепить файл к задаче», существование задачи, архивность, сохранение результата | MultipartFile, аннотации MVC, multipart-binding |
| Response mapper | mapping внутренней metadata-модели во внешний AttachmentResponse | file-validation и business rules |
| Error layer (@ControllerAdvice) | как перевести исключение в ProblemDetail | логика upload-а как таковая |
Теперь давай посмотрим на контроллер так, как он должен выглядеть в приличном обществе (то есть в проекте, который хочется продолжать).
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/v1/tasks/{taskId}/attachments")
public class AttachmentController {
@PostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<AttachmentResponse> upload(
@PathVariable String taskId, // bind: берём taskId из URL
@RequestPart("file") MultipartFile file, // bind: берём файл из multipart
@Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata // bind + validation: метаданные отдельной частью
) {
// bind -> convert -> delegate -> return
}
}
Сигнатура уже показывает внешний контракт; 201 Created, Location и metadata-ответ добавятся после делегирования сервису. Никаких репозиториев, никаких try/catch по бизнес-ошибкам, никаких System.out.println(file.getBytes()) (это вообще отдельное преступление против логов и здравого смысла).
3. MultipartFile только в web-layer
С MultipartFile есть типичная ловушка: он такой удобный, что хочется передать его дальше «ну просто чтобы сервис сам разберётся». Но это удобство, как дешёвый сахар: быстро даёт энергию, но потом как-то грустно.
MultipartFile — это MVC/web-тип. Он приходит из механики multipart-binding, завязан на servlet-мир и на то, как Spring разбирает request. Если вы протащите его в сервис, вы сделаете сервис зависимым от web-stack. А потом вы захотите протестировать сервис отдельно — и внезапно окажется, что вы должны где-то добыть MockMultipartFile, поднять кусочек MVC-контекста или вручную строить файл так, как его строит Spring. Это не невозможно, но это лишняя цена.
Есть ещё одна, более философская причина. Сервисный слой в нашем проекте — это «место правил операции». Он должен работать с данными операции: taskId, contentType, size, content и т.д. Ему не нужно знать, что эти данные пришли из multipart. Сегодня они пришли из multipart, завтра они могли бы прийти из другого транспорта. Мы не строим второй транспорт в этом курсе, но дисциплина всё равно полезна: она делает код устойчивым и объяснимым.
Хорошая аналогия: контроллер — это официант, который принимает заказ на человеческом языке, а сервис — кухня, где готовят блюда. Официант приносит на кухню «заказ: пицца 30 см, без грибов», а не приводит на кухню самого клиента со словами «пусть повар сам у него уточнит, что он хотел». Клиенту на кухне делать нечего — там горячо, остро и страшно.
Поэтому cheap file checks вроде пустого файла, unsupported media type и прикладного max size логично держать рядом с границей входа, пока у нас ещё есть MultipartFile. А сервис уже должен получать обычные данные операции.
Вот так выглядит плохая граница — сервис принимает MultipartFile:
import org.springframework.web.multipart.MultipartFile;
public interface AttachmentService {
// Плохо: сервисный слой становится зависим от Spring MVC (web-тип протащили внутрь приложения)
void upload(String taskId, MultipartFile file, String description);
}
На первый взгляд — удобно. На второй — вы подписались жить с web-типом в глубине приложения.
А вот так выглядит здоровая граница — сервис принимает команду:
public interface AttachmentService {
// Хорошо: сервис получает чистые данные операции, а не детали транспорта (multipart/MVC)
AttachmentMetadata upload(AttachmentUploadCommand command);
}
Теперь сервису всё равно, откуда именно пришёл контент. У него есть просто «данные операции». И это очень хороший фундамент для спокойной жизни.
4. Команда и маппинг upload
AttachmentUploadCommand как вход операции
Когда мы говорим «команда» (command), это не значит «командный паттерн из книжки GoF, срочно достаём UML и начинаем страдать». Здесь это простая и полезная идея: мы создаём обычный Java-объект, который описывает внутренний вход операции. Он не является публичным request DTO, потому что request DTO — это контракт API, а command — это внутренний «пакет данных», который удобно передать дальше.
В upload-сценарии command полезен по двум причинам. Во‑первых, он не тащит MVC-типы в сервис. Во‑вторых, он помогает не путать «что клиент может прислать» и «что сервер вычисляет сам». Например, size и contentType клиент не задаёт — сервер их читает из файла и фиксирует как факт. И это удобно выразить в command-объекте: часть данных пришла из metadata, часть вычислена из файла.
И ещё одна полезная граница: пустой файл, whitelist Content-Type и прикладной max size лучше отсеять до file.getBytes(). После этого assembler уже спокойно собирает command из технически пригодного входа.
Минимальная версия команды может выглядеть так:
public record AttachmentUploadCommand(
String taskId, // id задачи: пришёл из URL, но для доменной операции это просто входной параметр
String originalFileName, // имя оригинального файла: читаем из multipart
String contentType, // contentType: читаем из multipart, клиенту напрямую не доверяем через metadata
long size, // размер: сервер фиксирует по факту полученного файла
byte[] content, // содержимое: в учебном проекте читаем целиком
String description // описание: пришло из metadata
) {
}
Здесь есть важный момент: мы не принимаем от клиента uploadedAt, id, storageKey, «реальный путь на диске» и прочие вещи, которыми клиент управлять не должен. Command отражает «внутренний truth операции», а не «всё, что пришло от клиента».
Если вам хочется добавить в command больше полей (например, uploadedAt), остановитесь и задайте себе вопрос: это поле должен задавать клиент или сервер? Если сервер, то оно появляется в доменной модели/metadata уже после выполнения операции. В command оно не обязано быть.
Mapping в отдельный mapper
Чтобы контроллер оставался тонким, ему полезно не заниматься «сборкой» внутренней команды вручную прямо внутри метода. Это не потому что «лишний класс — это красиво», а потому что сборка команды обычно постепенно разрастается: обработка null у имени файла, нормализация contentType, чтение bytes, перевод IOException в понятное исключение. Если всё это держать в контроллере, контроллер станет «толстым» очень быстро.
Поэтому хороший компромисс: вынести сборку команды в mapper/assembler в пакете api.mapper. Контроллер тогда читается как сценарий: «принял → собрал → делегировал → вернул». А mapper содержит «техническую» часть превращения multipart-данных в internal object.
Пример простого mapper-класса:
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.UncheckedIOException;
@Component
public class AttachmentUploadMapper {
public AttachmentUploadCommand toCommand(
String taskId,
MultipartFile file,
AttachmentUploadMetadataRequest metadata
) {
try {
// convert: предполагаем, что cheap file checks уже прошли;
// здесь превращаем web-данные во внутренний объект операции
return new AttachmentUploadCommand(
taskId,
file.getOriginalFilename(), // техническая деталь: имя из multipart-заголовков
file.getContentType(), // техническая деталь: contentType из multipart
file.getSize(), // факт: размер того, что реально пришло
file.getBytes(), // в учебном проекте читаем в память целиком
metadata.description() // бизнес-часть, которую действительно задаёт клиент
);
} catch (IOException e) {
// важно: IOException — это «транспортная»/техническая ошибка, её удобно поднять выше как runtime
throw new UncheckedIOException("Не удалось прочитать содержимое файла", e);
}
}
}
Да, здесь мы выбрали byte[] и читаем файл целиком. В реальном production вы могли бы работать потоково, но в рамках нашей учебной модели это нормальный компромисс: мы учимся контракту и границам слоёв, а не строим файловый сервер на терабайты.
Обратите внимание, что mapper не возвращает ResponseEntity, не думает про 201, не решает, архивная ли задача. Он делает строго одно: превращает web-данные в внутренние данные операции.
Где именно живут cheap file checks — отдельный validator или assembler рядом с ним — вторично. Важна граница: они должны случиться до тяжёлой обработки файла, а сервис получает уже чистые данные операции.
Теперь контроллер можно сделать компактным и понятным:
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.net.URI;
@RestController
@RequestMapping("/api/v1/tasks/{taskId}/attachments")
public class AttachmentController {
private final AttachmentUploadMapper uploadMapper;
private final AttachmentMapper attachmentMapper;
private final AttachmentService attachmentService;
public AttachmentController(
AttachmentUploadMapper uploadMapper,
AttachmentMapper attachmentMapper,
AttachmentService attachmentService
) {
this.uploadMapper = uploadMapper;
this.attachmentMapper = attachmentMapper;
this.attachmentService = attachmentService;
}
@PostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<AttachmentResponse> upload(
@PathVariable String taskId, // bind
@RequestPart("file") MultipartFile file, // bind
@Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata // bind + validate
) {
// cheap file checks должны отработать до чтения bytes; после этого собираем внутреннюю команду
AttachmentUploadCommand command = uploadMapper.toCommand(taskId, file, metadata);
// delegate: доменные правила и операция — в сервисе
AttachmentMetadata created = attachmentService.upload(command);
// return: внешний контракт остаётся 201 Created + Location + metadata
AttachmentResponse response = attachmentMapper.toResponse(created);
URI location = URI.create("/api/v1/tasks/" + taskId + "/attachments/" + created.id());
return ResponseEntity.created(location).body(response);
}
}
И вот здесь уже отлично видно наш ритм bind → convert → delegate → return. Контроллер занимается тем, что умеет: web-binding и HTTP-ответ. Всё остальное уходит в другие части системы.
5. Сервисный слой: операция и правила
К этому моменту сервис получает уже технически пригодный вход: multipart shape разобрал MVC, metadata провалидировалась, дешёвые file-checks отсеяли явный мусор. Теперь начинаются предметные правила.
Под AttachmentMetadata здесь достаточно понимать внутреннюю metadata-модель attachment’а, без привязки к HTTP.
Сервис в этом сценарии — главный взрослый: он знает предметные правила. Он не должен «ковыряться» в multipart-частях, потому что это транспорт. Но он обязан знать, можно ли прикреплять файл к задаче, существует ли задача, не архивна ли она, и какие ошибки возвращать при нарушении правил.
Важно, что сервис не строит HTTP-ответ. Он не знает, что такое 201 Created (ему это знать не обязательно). Он знает, что операция «загрузить вложение» либо успешна и возвращает metadata созданного вложения, либо падает с осмысленным исключением.
Интерфейс сервиса можно оставить максимально простым:
public interface AttachmentService {
// Сервисный контракт: «выполни операцию загрузки и верни metadata созданного вложения»
AttachmentMetadata upload(AttachmentUploadCommand command);
}
А реализация уже держит правила. Пример очень упрощённого куска логики (без деталей хранения, потому что нам сейчас важны границы):
import java.time.Instant;
import java.util.UUID;
public class DefaultAttachmentService implements AttachmentService {
private final TaskRepository taskRepository;
public DefaultAttachmentService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@Override
public AttachmentMetadata upload(AttachmentUploadCommand command) {
// Доменная проверка №1: задача должна существовать
Task task = taskRepository.findById(command.taskId())
.orElseThrow(() -> new TaskNotFoundException(command.taskId()));
// Доменная проверка №2: нельзя прикреплять файлы к архивной задаче
if (task.status() == TaskStatus.ARCHIVED) {
throw new FileUploadNotAllowedException("Нельзя прикреплять файлы к архивной задаче");
}
// Сохранение content/storage здесь опущено: нам важна граница слоя
return new AttachmentMetadata(
UUID.randomUUID().toString(),
command.taskId(),
command.originalFileName(),
command.contentType(),
command.size(),
command.description(),
Instant.now()
);
}
}
Здесь важно не то, что мы «не сохранили файл» (это временно допустимо в учебной точке), а то, что сервис:
1) принимает чистые данные операции,
2) проверяет доменные правила,
3) возвращает результат операции (metadata),
4) кидает доменные исключения, которые позже переводятся в ProblemDetail нашим глобальным error layer.
Если вы уже в предыдущих модулях сделали @ControllerAdvice и mapping ошибок, то этот сервис естественно вписывается в систему. Например, TaskNotFoundException уйдёт в 404, а FileUploadNotAllowedException в 409. Сервис при этом не превращается в «мини-контроллер» и не начинает играть в HTTP.
HTTP-часть (Location, AttachmentResponse, 201) при этом остаётся снаружи, в контроллере. Сервис возвращает результат операции или кидает исключение.
6. Антипаттерны upload-endpoint’а
В файлах почти всегда есть соблазн: «раз уж клиент всё равно отправляет multipart, давайте заодно в этом запросе поменяем статус, добавим комментарий, поставим тег и переименуем задачу». Звучит как «оптимизация» и «меньше запросов», но на практике это превращает API в плохо управляемую кашу. У вас смешиваются несколько операций с разными правилами, статусами, ошибками и семантикой. Вы уже не можете нормально объяснить клиенту, что означает успех, что означают ошибки, и какие side effects произошли.
Очень показательный анти-пример — «слишком умный endpoint»:
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
public class BadAttachmentController {
@PostMapping(path = "/api/v1/tasks/{taskId}/attachments")
public ResponseEntity<?> upload(
@PathVariable String taskId,
@RequestPart(value = "file", required = false) MultipartFile file, // тревожный сигнал: файл «может отсутствовать»
@RequestPart(value = "comment", required = false) String comment // тревожный сигнал: в upload внезапно впихнули другую операцию
) {
// Такой endpoint сложно документировать: что считается успехом, если file нет? а если comment есть?
return ResponseEntity.ok().build();
}
}
Здесь уже видно проблему даже без деталей. required = false для файла — это почти всегда признак «endpoint пытается делать слишком многое». Плюс у нас появляется comment в upload. Это другой подресурс (comments), другой контракт, другой набор validation, и другой смысл ответа. Такой endpoint невозможно нормально документировать и тестировать, потому что «что он делает» зависит от того, какие части прислали.
Другой анти-паттерн — протаскивать web-типы в сервисный слой:
import org.springframework.web.multipart.MultipartFile;
public class BadAttachmentService {
public void upload(String taskId, MultipartFile file) {
// Плохо: сервис внезапно знает про multipart и Spring MVC
// Итог: тестировать и переиспользовать сервис без web-контекста становится больнее
}
}
Формально работает. Архитектурно — вы «приклеили» доменную операцию к одному транспортному механизму, и теперь любое изменение web-layer будет тянуть за собой сервис. А тестирование сервиса превратится в упражнение «построй MultipartFile из воздуха».
Есть ещё тонкая, но очень частая ошибка: metadata DTO начинает разрастаться server-managed полями. Когда разработчик видит, что ему нужен contentType и size, он иногда думает: «О! Добавлю их в AttachmentUploadMetadataRequest». И вот здесь нужно сказать себе «стоп». Клиент не должен присылать size — это просто ложь в красивой упаковке. Сервер должен считать size сам и доверять только тому, что он реально получил.
В результате хорошая архитектурная формула upload-endpoint’а звучит скучно, но работает идеально: один endpoint → одна операция → минимальный контракт → понятные границы слоёв. Скучно — это хорошо. Как говорят инженеры, «если код скучный, значит он предсказуемый». А предсказуемость — это любовь.
7. Типичные ошибки при upload-endpoint’е
Ошибка №1: контроллер делает всё подряд, а потом “как-нибудь вынесем в сервис”.
Это классический путь в технический долг. Upload-endpoint почти гарантированно обрастёт проверками и нюансами, и если вы сразу не удержите ритм bind → convert → delegate → return, то через неделю вы не сможете нормально понять, что именно делает метод. Дешевле сразу вынести сборку команды в mapper и держать сервис независимым от MVC.
Ошибка №2: MultipartFile уезжает в сервис “для удобства”.
Удобство здесь краткосрочное. Дальше сервис становится привязан к Spring MVC, тесты усложняются, а граница web-layer размывается. В учебном проекте это особенно опасно: вы потом начнёте тащить web-типы глубже и глубже, пока не получите “web everywhere”.
Ошибка №3: metadata DTO превращается в помойку полей, включая server-managed значения.
Если клиент начинает присылать size, contentType, uploadedAt и тем более id, вы сами себе строите проблему. Эти поля либо будут игнорироваться (тогда зачем они вообще?), либо начнут влиять на поведение (тогда клиент управляет тем, чем не должен). Правило простое: request DTO содержит только то, что реально задаёт клиент по контракту.
Ошибка №4: endpoint становится “слишком умным” и начинает выполнять несколько разных операций за раз.
Добавили в upload ещё комментарий, статус, теги, “архивацию” — и всё, у вас не REST API, а RPC-комбайн. Появляются скрытые side effects, непонятные статусы и ошибки, и самое неприятное — это очень тяжело документировать и тестировать. Держите одну операцию на один endpoint, особенно в учебном проекте.
Ошибка №5: сервис строит HTTP-ответы и знает про ResponseEntity.
Это выглядит как “ну мы же возвращаем статус”, но на самом деле это смешение слоёв. Сервис должен возвращать результат операции или кидать исключение. Формирование HTTP-ответа — ответственность контроллера и global error handler. Если сервис знает про ResponseEntity, значит вы уже не сможете переиспользовать и тестировать его как чистую бизнес-операцию без MVC-контекста.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ