В рамках наших Java курсов "Из Middle в Senior" (предыдущие посты Миграция Java Spring Boot на Kotlin и «Работа с документами в Java») недавно вышел новый курс Startup: Spring Boot веб-приложение с хостингом и инфраструктурой на основе эволюции нашей платформы онлайн-обучения с 2016г. 
В рамках курса есть много подходов, сокращающих количество кода/усилий разработчиков. Один из них: сквозная параметризация от сервисов до репозиториев, позволяющая сокращать количество кода ~3х. Код приведен на Java, но общий подход может быть использован в любом языке с параметризацией. Кому интересно - добро пожаловать.
Репозитории
Все, кто работает со Spring Data знает, насколько упростилось кодирование за счет готовых параметризованных интерфейсов работы с БД. Мы также можем создавать собственные наследники этих интерфейсов, расширяя базовый функционал. Например, для JPA:
@NoRepositoryBean
public interface BaseRepository<T> extends JpaRepository<T, Integer> {
    @Transactional
    @Modifying
    @Query("DELETE FROM #{#entityName} e WHERE e.id=:id")
    int delete(int id);
    @SuppressWarnings("all") // transaction invoked
    default void deleteExisted(int id) {
        if (delete(id) == 0) {
            throw new NotFoundException("Entity with id=" + id + " not found");
        }
    }
    default T getExisted(int id) {
        return findById(id).orElseThrow(() -> new NotFoundException("Entity with id=" + id + " not found"));
    }
}Мапперы
Обычно в большом приложении много преобразований Entity <-> Transfer Object (TO). Для автоматизации этого кода есть много библиотек-мапперов. Мне больше всего нравится инструмент автогенерации кода MapStruct. Кроме прямого маппинга, MapStruct также умеет преобразовывать списки и обновлять поля классов. Создаем базовый параметризированный интерфейс мапперов:
public interface BaseMapper<E, T> {
    E toEntity(T to);
    List<E> toEntityList(Collection<T> tos);
    E updateFromTo(T to, @MappingTarget E entity);
    T toTo(E entity);
    List<T> toToList(Collection<E> entities);
}Создаем общую конфигурацию для всех мапперов. Здесь мапперы создаются как бины Spring и на незамапленные поля предупреждения не выдаются:
@MapperConfig(
        componentModel = MappingConstants.ComponentModel.SPRING,
        unmappedTargetPolicy = ReportingPolicy.IGNORE
)
public interface MapStructConfig {
}Поля, которые маппятся 1:1 указывать не надо, для остальных есть разные опции. Пример маппера User <-> UserTo:  
@Mapper(config = MapStructConfig.class)
public interface UserMapper extends BaseMapper<User, UserTo> {
    @Mapping(target = "email", expression = "java(to.getEmail().toLowerCase())")
    @Override
    User toEntity(UserTo to);
    @Mapping(target = "id", ignore = true)
    @Mapping(target = "email", expression = "java(to.getEmail().toLowerCase())")
    @Override
    User updateFromTo(UserTo to, @MappingTarget User entity);
}Мапперы генерируются на фазе compile, при сборке maven в каталоге \target\generated-sources можно посмотреть код реализации. Если маппинг происходить 1:1 без дополнительных подстроек, переопределять методы BaseMapper не требуется.
Общие классы и интерфейсы данных
Сделаем общие классы и интерфейсы для данных, чтобы не дублировать их в каждом объекте. equals/hashCode для сущности сделаем на основе последних рекомендаций от jpa buddy
public interface HasId {
    Integer getId();
    void setId(Integer id);
    @JsonIgnore
    default boolean isNew() {
        return getId() == null;
    }
    // doesn't work for hibernate lazy proxy
    default int id() {
        Assert.notNull(getId(), "Entity must has id");
        return getId();
    }
}
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Data
public abstract class BaseTo implements HasId {
    @Schema(accessMode = Schema.AccessMode.READ_ONLY) // https://stackoverflow.com/a/28025008/548473
    protected Integer id;
    @Override
    public String toString() {
        return getClass().getSimpleName() + ":" + id;
    }
}
@MappedSuperclass
@Access(AccessType.FIELD)
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class BaseEntity implements HasId {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Schema(accessMode = Schema.AccessMode.READ_ONLY) // https://stackoverflow.com/a/28025008/548473
    protected Integer id;
    //  https://jpa-buddy.com/blog/hopefully-the-final-article-about-equals-and-hashcode-for-jpa-entities-with-db-generated-ids/
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getEffectiveClass(this) != getEffectiveClass(o)) return false;
        return getId() != null && getId().equals(((BaseEntity) o).getId());
    }
    @Override
    public final int hashCode() {
        return getEffectiveClass(this).hashCode();
    }
    @Override
    public String toString() {
        return getClass().getSimpleName() + ":" + id;
    }
}Добавим утильные классы для работы с данными
@UtilityClass
public class Util {
    public static Class getEffectiveClass(Object o) {
        return o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass();
    }
}
@UtilityClass
public class ValidationUtil {
    public static void checkNew(HasId bean) {
        if (!bean.isNew()) {
            throw new IllegalRequestDataException(bean.getClass().getSimpleName() + " must be new (id=null)");
        }
    }
    //  Conservative when you reply, but accept liberally (http://stackoverflow.com/a/32728226/548473)
    public static void assureIdConsistent(HasId bean, int id) {
        if (bean.isNew()) {
            bean.setId(id);
        } else if (bean.id() != id) {
            throw new IllegalRequestDataException(bean.getClass().getSimpleName() + " must has id=" + id);
        }
    }
}Сервисы
Наконец, мы можем связать вместе мапперы и репозитории, получив параметризованные сервисы с наиболее частыми запросами контроллеров. Иногда при создании или при обновлении из Entity требуются дополнительные преобразования, добавим опциональные методы преобразования BaseService.prepareForSave  и BaseService.prepareForUpdate (при обновлении из TO преобразования делаются в маппере). Параметризация маппера <M> и репозитория <R> дает возможность брать их из сервиса без необходимости кастинга:
public class BaseService<E extends HasId, T extends BaseTo, R extends BaseRepository<E>, M extends BaseMapper<E, T>> {
    protected final Logger log = LoggerFactory.getLogger(getClass());
    public BaseService(R repository, M mapper) {
        this(repository, mapper, null, null);
    }
    public BaseService(R repository, M mapper,
                       Function<E, E> prepareForSave, BiFunction<E, E, E> prepareForUpdate) {
        this.repository = repository;
        this.mapper = mapper;
        this.prepareForSave = prepareForSave;
        this.prepareForUpdate = prepareForUpdate;
    }
    @Getter
    protected final R repository;
    @Getter
    protected final M mapper;
    private final Function<E, E> prepareForSave;
    private final BiFunction<E, E, E> prepareForUpdate;
    public T getTo(int id) {
        log.info("getTo by id={}", id);
        return toTo(repository.getExisted(id));
    }
    public E get(int id) {
        log.info("get by id={}", id);
        return repository.getExisted(id);
    }
    public List<E> getAll() {
        return getAll(Sort.unsorted());
    }
    public List<E> getAll(Sort sort) {
        log.info("getAll");
        return repository.findAll(sort);
    }
    public List<T> getAllTos() {
        return getAllTos(Sort.unsorted());
    }
    public List<T> getAllTos(Sort sort) {
        log.info("getAllTos");
        return toToList(repository.findAll(sort));
    }
    public E createFromTo(T to) {
        log.info("createFromTo {}", to);
        ValidationUtil.checkNew(to);
        E entity = toEntity(to);
        if (prepareForSave != null) entity = prepareForSave.apply(entity);
        return repository.save(entity);
    }
    public E create(E entity) {
        log.info("create {}", entity);
        ValidationUtil.checkNew(entity);
        if (prepareForSave != null) entity = prepareForSave.apply(entity);
        return repository.save(entity);
    }
    public void delete(int id) {
        log.info("delete by id={}", id);
        repository.deleteExisted(id);
    }
    @Transactional
    public E update(E entity, int id) {
        log.info("update {} with id={}", entity, id);
        ValidationUtil.assureIdConsistent(entity, id);
        if (prepareForUpdate != null) {
            E dbEntity = repository.getExisted(entity.id());
            entity = prepareForUpdate.apply(entity, dbEntity);
        }
        return repository.save(entity);
    }
    @Transactional
    public E updateFromTo(T to, int id) {
        log.info("updateFromTo {} with id={}", to, id);
        ValidationUtil.assureIdConsistent(to, id);
        E dbEntity = repository.getExisted(to.id());
        return repository.save(updateFromTo(to, dbEntity));
    }
    // delegate to mapper
    public E toEntity(T to) {
        return mapper.toEntity(to);
    }
    public List<E> toEntityList(Collection<T> tos) {
        return mapper.toEntityList(tos);
    }
    public E updateFromTo(T to, E entity) {
        return mapper.updateFromTo(to, entity);
    }
    public T toTo(E entity) {
        return mapper.toTo(entity);
    }
    public List<T> toToList(List<E> entities) {
        return mapper.toToList(entities);
    }
}Контроллеры
Общий код создание ответов POST вынесем в WebUtil:
@UtilityClass
public class WebUtil {
    // create ResponseEntity
    public static <T extends HasId> ResponseEntity<T> createdResponse(String url, T created) {
        return createdResponse(url + "/{id}", created, created.getId());
    }
    public static <T extends HasId> ResponseEntity<T> createdResponse(String url, T created, Object... params) {
        URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath()
                .path(url).buildAndExpand(params).toUri();
        return ResponseEntity.created(uriOfNewResource).body(created);
    }
}Наконец, посмотрим, сколько кода нам теперь потребуется на примере написания обычного REST контроллера:
@RestController
@RequestMapping(value = AdminUserController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE)
@Slf4j
public class AdminUserController {
    @Autowired
    protected UserService service;
    static final String REST_URL = SecurityConfig.API_PATH + "/admin/users";
    @GetMapping("/{id}")
    public User get(@PathVariable int id) {
        return service.get(id);
    }
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable int id) {
        service.delete(id);
    }
    @GetMapping
    public List<User> getAll() {
        log.info("getAll");
        return service.getAll(Sort.by(Sort.Direction.ASC, "email"));
    }
    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<User> createWithLocation(@Valid @RequestBody User user) {
        User created = service.create(user);
        return createdResponse(REST_URL, created);
    }
    @PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE)
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void update(@Valid @RequestBody User user, @PathVariable int id) {
        service.update(user, id);
    }
    @GetMapping("/by-email")
    public User getByEmail(@RequestParam String email) {
        log.info("getByEmail {}", email);
        return service.getRepository().getExistedByEmail(email);
    }
}Если проект большой и контролеров много, энономия получается существенная. Меньше кода, меньше ошибок, проще и понятнее код.
Приятного кодирования и приглашаем на наши курсы!  
Комментарии (17)
 - gkislin Автор11.04.2024 13:33- Простите, рука дрогнула, был вопрос про сложность использования. Ответ такой: - Сложно ли пользоваться Spring Data Jpa? Упрощает ли он работу? 
- Смотрели ли вы внутрь него, как он реализован? 
 - Я к тому, что для использования предложенного подхода вам нужно только делать код контроллера в конце статьи, что проще для понимания и использования, чем традиционный подход 
 - bugy11.04.2024 13:33- @Override public final int hashCode() {- return getEffectiveClass(this).hashCode();- }- Выглядит сурово. У всех ентити одного типа будет один и тот же хешкод?  - gkislin Автор11.04.2024 13:33+2- Да. По ссылке jpa buddy посмотрите - У себя на сайте они пишут, что с hashCode от ID будут проблемы, если сначала был NULL, а затем после сохранения появился ID, сгенерированный БД: - "Once the id is generated (on its first save) the hashCode gets changed. So the HashSet looks for the entity in a different bucket and cannot find it. It wouldn’t be an issue if the id was set during the entity object creation (e.g. was a UUID set by the app), but DB-generated ids are more common."  - bugy11.04.2024 13:33- Понял, критика не к вам, извините :) - Особенно порадовало, что решение от Thorben Janssen им не подошло, т.к. - The implementation proposed by Thorben Janssen returns the same hash code for all objects! This approach makes - hashCode()completely meaningless.- А вот возвращать один и тот же hashCode для обьектов одного и того же класса вполне норм. Это другое  - gkislin Автор11.04.2024 13:33- Это не критика, за удобство JPA приходиться платить. Обычно не критично, но, как всегда, думаю есть особые случаи. 
 
 
 
 - Antharas11.04.2024 13:33- Чем текущее решение лучше/практичнее Spring Data REST? 
 Зачем выдумывать очередной велосипед, кроме как получение физического контроля над выполняемой логикой? - gkislin Автор11.04.2024 13:33- Spring Data REST - это вообще не про это 
 Это Hateoas, который генерит код на основе репозиториев. Там как раз невозможно ничего кастомизировать - Antharas11.04.2024 13:33- А про что тогда 0_о? В данный момент вы делаете тоже самое что и указанный модуль. Поясните в чем же фундаментальные различия.  - gkislin Автор11.04.2024 13:33- Вы не поняли статьи или сути Spring Data REST, я делаю все вручную, никаких автогенераций кода. НО без дублирования кода - композиция, наследование, стратегия и дженерики. 
 
 
 
 - iozhukau11.04.2024 13:33- Я может не уловил сути. Если у нас простой CRUD с возможностью кастомизации, тогда чем к задаче не подходит Spring Data REST?  - gkislin Автор11.04.2024 13:33- См ответ выше + у нас не простой CRUD- любые методы в репозиториях и контроллерах поддерживаются. Суть - не дублировать код на основе общих методов и маппинга.  - iozhukau11.04.2024 13:33- Ну это не правда, там много чего кастомизируется. Логика спокойно наращивается, притом довольно чистым кодом т.к. завязана на события CRUD. + Если нужно как-то изменять представление сущностей, там вроде работает JPA Projections. 
 Исходя из примеров кода, я вижу что для любого не CRUD действия, нужно переопределять метод или создавать промежуточный сервис в цепочку запрос-ответ. Я имею в виду, что всё, что не заложено в "дженерик" реализацию, нужно всё равно писать, но если это не нужно, то как бы есть уже готовое решение (Data REST).
 Да, это Hateoas и RESTFull, где не так удобны некоторые операции. Но если задача стоит уменьшить кодовую базу (дублирование) + время разработки, я думаю это валидный способ, воспользоваться существующим инструментом.
 А если задача построить большой монолит, то как бы я предполагаю, что хороший архитектор подумает о разработчиках\коллегах и базовые одинаковые действия выведет в слой абстракции.
 Всегда же приятнее, удобно добавлять фичи, а не страдать и рефакторить бесконечно или колупаться в копипастах с мыслями "Где же не переименовал\добавил\удалил что-то?". - gkislin Автор11.04.2024 13:33- Ну да, все верно- код выше как раз ближе всего к слою абстрации для большого монилита, можно вынести в модуль common. В Data REST кроме ресурсов на выходе делать маппинг на урове проекций очень муторно. И совсем некастомизируемо, например кэширование в сервисах или енкодинг пароля приходится чз приседания с Jackson делать. Используя абстракцию вы ничем не связаны- собственный биндинг, валидация, кэширование, настраиваемый маппинг и пр. фишки Spring, Mapstruct и просто Java кода. 
 
 
 
 
           
 
onets
Чуть менее, чем каждый раз , когда я начинал с дженерик контроллеров / сервисов / репозиториев / и тому подобное - спустя некоторое время то тут то там появлялись специфичные вещи, которые не укладывались в общий подход и приходилось их переделывать на нормальные…
gkislin Автор
Если посмотрите - сам контроллер у меня не параметризирован + есть возможность напрямую вызывать специфичные методы репозитория и настраивать маппинг. Попробуйте попользовать и жду фидбэка- что не получилось сделать
aleksandy
За использование в контроллере репозиториев и базюшных сущностей нужно бить по рукам железной линейкой. Работа со слоем данных должна быть только в сераисах.
gkislin Автор
Все это очень проекто-специфично
Никто не мешает отнаследоваться от BaseService и вызывать репозиторий оттуда