JdbcTemplate в Spring: как выполнять SQL-запросы и маппить результаты без лишнего boilerplate

На чтение
7 мин
Дата обновления
05.11.2026

В JDBC легко потратить больше кода на соединение, statement, обход ResultSet и обработку исключений, чем на сам запрос. JdbcTemplate берёт на себя повторяющуюся часть работы, оставляя разработчику SQL, параметры и преобразование результата. Это удобно, когда нужен явный контроль запросов без перехода к объектно-реляционному отображению всего приложения.

Разберём авторский учебный репозиторий клиентов: вставку записи, чтение одной и списка, изменение и удаление. Пример рассчитан на Java 17+ и Spring JDBC, для локального опыта используется H2. Это не схема рабочей базы: идентификаторы задаём вручную, миграции, пул соединений и сервисные транзакции здесь не реализуем.

DataSource и JdbcTemplate: что настроить один раз

В проекте нужны совместимые зависимости Spring Context, Spring JDBC и драйвер H2. Их версии выбирают согласованно с используемой версией Spring. В Spring Boot учебное руководство предлагает начать с Initializr и зависимостей JDBC API и H2 Database; Boot умеет настроить JdbcTemplate для такого проекта. Не создавайте параллельно несколько вариантов конфигурации без понимания, какой bean используется.

Если настраиваете обычный Spring-контекст вручную, пример DbConfig.java выглядит так. В остальных классах используйте тот же package, например example:

package example;

import javax.sql.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

@Configuration
public class DbConfig {
    @Bean
    DataSource dataSource() {
        var ds = new DriverManagerDataSource();
        ds.setDriverClassName("org.h2.Driver");
        ds.setUrl("jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1");
        ds.setUsername("sa");
        ds.setPassword("");
        return ds;
    }

    @Bean
    JdbcTemplate jdbcTemplate(DataSource ds) {
        return new JdbcTemplate(ds);
    }

    @Bean
    CustomerRepository customerRepository(JdbcTemplate jdbc) {
        return new CustomerRepository(jdbc);
    }
}

DriverManagerDataSource подходит для простого опыта, но не является пулом соединений: документация прямо отделяет его от полноценного pooled DataSource. В реальном приложении источник и credentials настраивают для конкретной инфраструктуры. Пустой пароль в примере относится только к временной локальной H2, а не к рекомендации для сервера.

Модель и явный RowMapper

Создайте Customer.java. Record здесь позволяет кратко представить неизменяемые данные:

package example;

public record Customer(long id, String firstName, String lastName) {}

В таблице используем id, first_name и last_name. RowMapper преобразует одну строку ResultSet в Customer; JdbcTemplate организует обход результата. Один mapper можно повторно использовать в запросе одной записи и списка, чтобы не дублировать правила преобразования.

Ниже полный класс CustomerRepository.java. Он получает уже настроенный JdbcTemplate через конструктор и не открывает соединение вручную в каждом методе:

package example;

import java.util.List;
import java.util.Optional;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;

public class CustomerRepository {
    private final JdbcTemplate jdbc;

    private static final RowMapper<Customer> MAPPER = (rs, rowNum) ->
        new Customer(rs.getLong("id"),
                     rs.getString("first_name"),
                     rs.getString("last_name"));

    public CustomerRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public int insert(long id, String firstName, String lastName) {
        return jdbc.update(
            "insert into customers (id, first_name, last_name) values (?, ?, ?)",
            id, firstName, lastName);
    }

    public Optional<Customer> findById(long id) {
        try {
            Customer value = jdbc.queryForObject(
                "select id, first_name, last_name from customers where id = ?",
                MAPPER, id);
            return Optional.ofNullable(value);
        } catch (EmptyResultDataAccessException ex) {
            return Optional.empty();
        }
    }

    public List<Customer> findByLastName(String lastName) {
        return jdbc.query(
            "select id, first_name, last_name from customers where last_name = ? order by id",
            MAPPER, lastName);
    }

    public int rename(long id, String lastName) {
        return jdbc.update(
            "update customers set last_name = ? where id = ?",
            lastName, id);
    }

    public int delete(long id) {
        return jdbc.update("delete from customers where id = ?", id);
    }
}

Одна запись и отсутствие записи — разные результаты

queryForObject рассчитан на одну строку. Если запрос не вернул строк, возникает EmptyResultDataAccessException; в нашем публичном методе репозитория мы осознанно преобразуем именно этот случай в Optional.empty(). Если строк несколько, это другой случай неправильного размера результата — его не следует молча превращать в «клиент не найден».

Первичный ключ в нашей таблице делает id уникальным. Если заменить условие на фамилию, нельзя предполагать, что фамилия тоже уникальна. Для возможного множества строк используйте query и список. Не заменяйте проверку уникальности произвольным «возьмём первую строку», если бизнес-правило требует однозначного клиента.

У mapper также есть граница: он ожидает именно указанные колонки и типы. Если запрос переименовал колонку или перестал её выбирать, преобразование нужно согласовать с SQL. Параметр rowNum — номер обрабатываемой строки в callback, а не значение первичного ключа.

Когда помогает BeanPropertyRowMapper

Для обычного JavaBean можно использовать BeanPropertyRowMapper: он сопоставляет названия колонок с доступными свойствами, в том числе first_name с firstName. Но класс должен подходить под его контракт — иметь доступный конструктор без аргументов и свойства для заполнения. Он не является автоматической заменой MAPPER для нашего record с компонентами конструктора.

В этом примере оставляем явный mapper: видно, откуда берётся каждое значение. У BeanPropertyRowMapper есть конкретный случай ошибки: SQL NULL по умолчанию нельзя записать в примитивное свойство Java, например int, — документация указывает TypeMismatchException. Если отсутствие значения имеет смысл, продумайте тип Integer и обработку null. Флаг primitivesDefaultedForNullValue позволяет иначе обработать примитивы, но подстановка значения по умолчанию не должна молча заменять смысл «неизвестно» на ноль. Отдельно проверяйте несовпадающие имена колонок и свойств; сокращение кода не отменяет контракт модели.

Параметры относятся к значениям, а не ко всему SQL

В запросах знак ? обозначает связанное значение. Передавайте пользовательскую фамилию отдельным аргументом, а не склеивайте её с текстом SQL через кавычки. Это сохраняет структуру подготовленного запроса и отделяет её от данных.

Имя таблицы, имя колонки или направление ORDER BY нельзя считать обычным значением и бездумно подставить через ?. Если пользователь выбирает сортировку, сопоставьте разрешённый вариант с заранее заданным фрагментом SQL. Параметризация значений не делает безопасной произвольную конкатенацию остальных частей запроса.

У update порядок аргументов соответствует порядку плейсхолдеров. В rename сначала идёт новая фамилия, потом id. Перестановка часто выглядит как «запрос ничего не меняет», хотя проблема в переданных данных.

Проверить CRUD на локальной схеме

В учебном main или тесте создайте AnnotationConfigApplicationContext с DbConfig.class. Через контекст получите JdbcTemplate и CustomerRepository. Инициализация ниже уничтожает таблицу только в нашей временной H2; не переносите её в подключение к рабочим данным:

jdbc.execute("drop table if exists customers");
jdbc.execute("create table customers (id bigint primary key, first_name varchar(100), last_name varchar(100))");

int inserted = repository.insert(1L, "Mira", "Stone");
Optional<Customer> first = repository.findById(1L);
List<Customer> list = repository.findByLastName("Stone");
int changed = repository.rename(1L, "River");
int removed = repository.delete(1L);
Optional<Customer> missing = repository.findById(1L);

Ожидания упражнения: inserted=1, найденная запись содержит Mira Stone, список имеет один элемент, changed=1 и removed=1, после удаления missing пустой. Проверьте и несуществующий id: обновление или удаление без подходящей строки в этом сценарии не должно трактоваться как успешное изменение клиента. Возвращаемое число update позволяет сделать это различие явным.

Затем попробуйте повторную вставку того же первичного ключа до удаления. Это нарушение ограничения базы, а не «пустой результат». JdbcTemplate переводит JDBC-исключения в иерархию DataAccessException; не ловите любое такое исключение с возвратом Optional.empty(), иначе спрячете недоступность базы, ошибку SQL и нарушение данных под одной фразой.

Репозиторий не освобождает от проектирования SQL и транзакций. Несколько успешных вызовов update не означают, что вся бизнес-операция стала атомарной. После проверки отдельных запросов отдельно определяют границу транзакции и правила обработки ошибок. Полезное сокращение boilerplate — это управляемые ресурсы и повторно используемый mapper, а не исчезновение ответственности за данные.