В данной статье будет рассмотрен способ применения Java records в качестве DTO (data transfer objects).
Используем Spring Boot / Hibernate.
Представленный далее код не предназначен для продакшена. Это, скорее, размышления на тему. Возможно кому-то будет интересно и полезно.
Цель — за пределами сервисного слоя использовать только DTO и не таскать сущности с persistence context'ом по бизнес-логике.
Обычно использование паттерна DTO подразумевает применение отдельных функций для преобразования модели в DTO и обратно.
По сути имеем три задачи — сохранение/выборку данных и удобный способ работы с ними.
В демо проекте реализованы две сущности — Note и Tag, со связями ManyToMany.
Далее описаны способы выборки и обновления данных на примере Tag.
Для выборки данных в repository используем JPQL Constructor Expressions:
    @Transactional(readOnly = true)
    @Query(value = "SELECT new dev.isdn.demo.records_dto.app.domain.tag.TagDto(t.id, t.name, t.color) FROM tags t WHERE t.id = :id")
    Optional<TagDto> findById(@Param("id") long id);Получаем результат сразу в record, завернутый в Optional. Если выборка пустая — то получаем пустой Optional.
Соответственно, в сервисном слое просто передаем результат:
    public Optional<TagDto> getTagById(long tagId) {
        return repository.findById(tagId);
    }Для сохранения в базу нужно будет сделать несколько дополнительных действий:
Optional.ofNullable(tagDto)
.flatMap(t -> repository.getTagById(t.id())) // проверяем наличие записи с таким ID в базе и преобразуем DTO в entity
.flatMap(t -> setTagNameAndColor(t, content.name(), content.color())) // обновляем entityВ данном случае очень удобно использовать фичи Optional для проверки результата на каждом шаге.
Метод setTagNameAndColor() выполняет проверку входных данных и обновляет сущность.
В итоге сделал вот такой сервисный метод в декларативном стиле:
    @Transactional
    public Optional<TagDto> updateTagContent(TagDto tag, TagContent content) {
        return Functions.checkTagDto.apply(tag)
                .flatMap(t -> repository.getTagById(t.id()))
                .flatMap(t -> setTagNameAndColor(t, content.name(), content.color()))
                .map(repository::saveAndFlush)
                .flatMap(t -> repository.findById(t.getId()));
    }Такой подход выглядит немного избыточным, сделано исключительно в демонстрационных целях.
Функция checkTagDto делает простую проверку Optional.ofNullable(tag).filter(t -> t.id() > 0). 
Более сложные выборки из базы в repository можно делать аналогично.
Например, получение связанных данных:
    @QueryHints(value = {
            @QueryHint(name = HINT_FETCH_SIZE, value = "100"),
            @QueryHint(name = READ_ONLY, value = "true")
    })
    @Transactional(readOnly = true)
    @Query(value = "SELECT new dev.isdn.demo.records_dto.app.domain.tag.TagDto(t.id, t.name, t.color) FROM tags t INNER JOIN t.notes n WHERE n.id = :noteId")
    Stream<TagDto> findAllByNoteId(@Param("noteId") long noteId);    @QueryHints(value = {
            @QueryHint(name = HINT_FETCH_SIZE, value = "100"),
            @QueryHint(name = READ_ONLY, value = "true")
    })
    @Transactional(readOnly = true)
    @Query(value = "SELECT new dev.isdn.demo.records_dto.app.domain.note.NoteDto(n.id, n.created, n.modified, n.content) FROM notes n INNER JOIN n.tags t WHERE t.id = :tagId")
    Stream<NoteDto> findAllByTagId(@Param("tagId") long tagId);Теперь о том, как с этим работать.
Простой пример REST контроллера:
    @PutMapping(PREFIX + VERSION + "/tags/{id}")
    TagDto updateTagContent(@PathVariable long id, @RequestBody TagContent content) {
        TagDto tag = tagService.getTagById(id).orElseThrow(() -> new NoSuchItemException("tag " + id));
        return tagService.updateTagContent(tag, content).orElseThrow(() -> new NotUpdatedException("tag " + id));
    }
    @GetMapping(PREFIX + VERSION + "/tags/{id}/notes")
    List<NoteDto> getTagNotes(@PathVariable long id) {
        TagDto tag = tagService.getTagById(id).orElseThrow(() -> new NoSuchItemException("tag " + id));
        return noteService.getTagNotes(tag);
    }Полностью код можно посмотреть вот здесь.
Комментарии (16)
 - aleksandy17.10.2022 08:23+4- Ох уж эти Spring Java Developer-ы... - Такая организация кода - говнокод, хоть с записями, хоть с обыкновенными классами. Транзакции должны управляться на уровне инфраструктурных фасадов, а не репозиториев. - В наипростейшей updateTagContent() открывается 2 транзакции, между которыми в конкурентном окружении может вклиниться удаление и таки никакого обновления не случится.  - isden Автор17.10.2022 09:55- Транзакции должны управляться на уровне инфраструктурных фасадов, а не репозиториев. - А в документации пишут "CRUD methods on repository instances are transactional by default." - открывается 2 транзакции, между которыми - А разве сам метод не будет выполняться одной транзакцией (с вложенными из репозитория)? 
 
 - Throwable17.10.2022 09:28-1- Меня всегда удивляет боязнь девелоперов вместо DTO отдавать сразу Entities. Начинают что-то говорить, что это неправильно, и что-то про разные модели и секьюрити. Но на практике это те же самые POJO, и в 99% случаев из fieldset-ы совпадают. А при грамотно выставленных EntityGraphs можно четко контролировать отдаваемый контент. Поэтому чаще всего следуя каким-то искусственным паттернам, сами себе усложняем жизнь.  - Arty_Fact17.10.2022 13:04- Ну не знаю. Все-таки у многих объектов есть дополнительные филды, типа created, updated, author и т. д., которые нужны в работе, но не нужны фронтенду, и DTO сильно помогает. Ну и плюс сразу защищаешься от сложностей при переименовывании полей. Удобно, как мне кажется.  - Throwable17.10.2022 19:37- Для сильно служебных полей есть @JsonIgnore и ещё куча способов исключить их из сериализации. Хорошая модель данных организована так, чтобы отделить процессинг от стейта: не загромождать сами Entities промежуточными состояниями и служебными данными, а выделить их в отдельный объект. - Согласно моему горькому опыту в любом проекте очень быстро замусоривается DTO, всевозможными мепперами и промежуточными сервисами. Доходит до того, что на одну entity получается с десяток схожих DTO. В то же время успешный рефакторинг на раздачу Entities уменьшил кодовую базу сразу на 90%.  - isden Автор17.10.2022 20:32- А я видел проекты где DTO долго и успешно используются, без этих всяких ужасов. 
 Наверное все сильно зависит от команды и от структуры/архитектуры проекта.
  - Arty_Fact18.10.2022 12:48- Даже не могу представить зачем при одном API может понадобиться десяток схожих DTO. Я могу понять два схожих DTO: для запроса и для ответа. Но зачем еще куча? 
 Ну и конечно не представляю, что там за код такой был, на 90 % состоящий из мапперов и DTO. Взглянуть бы на проект до рефакторинга. - Throwable19.10.2022 09:27+1- Там до Api как такового никому особо дела не было. Проект прошел по куче рук, и каждый реализовал тикет как мог. Для каждой новой фичи на бэке тупо писался свой контроллер, сервис, DTO и маппер. В итоге все т.н. "api" -- это куча findByXxx, которые отдают схожие DTO с немного разным набором филдов под каждый конкретный кейс.  - isden Автор19.10.2022 10:19+1- Ну так тут очевидно проблема была совсем не в паттерне DTO как таковом. 
 
 
 
 
 
 
           
 
Antharas
Это все конечно замечательно, синтаксический сахар и все вот это вот удобство.. но вы же, наверно, вкурсе о больших накладных расходах при создании record-классов? Нет? Проведите стресс тест системы, будете удивлены. Крайне не советую в принципе использовать в высоконагруженной системе все эти удобства.
isden Автор
А где про это почитать?
Везде где я читал пишут, что +- как у обычных классов.
Сейчас погуглил, сходу то же самое нашлось.
Antharas
Увы, материалы сам когда-то искал, так и не нашел. Провел пару тестов в нагрузке (tcp - 10к клиентов с рейтом в 30-40rps) - профайлер показал деградацию в Record<init> (jdk 19).
isden Автор
Ок, спасибо. Если будет свободное время, попробую потестировать разницу в производительности.
Но кмк, если бы с этим были серьезные проблемы, об этом бы точно писали и обсуждали.
Antharas
Да, имхо единичный "мой" случай, дособираю статистику и вышлю багрепортом.
Hixon10
> вы же, наверно, вкурсе о больших накладных расходах при создании record-классов
Больших расходов по сравнению с чем? С созданием обычных объектов, или не пулом объектов?