Spring Boot – JdbcTemplate

July 13, 20265 min readUpdated 8/18/2026

JPA maps rows onto an object graph and tracks changes to it. That is genuinely valuable — and for a query that aggregates, or whose result is not an entity at all, it is machinery with nothing to do.

Spring Boot gives you JdbcTemplate alongside JPA. Using both, deliberately, is not inconsistency.

Where the choice gets made

Every DAO in the pizza API wires in a Spring Data repository and a JdbcTemplate, so the decision is made once per method rather than argued about at every call site:

/**
 * Users, backed by a repository AND a JdbcTemplate.
 *
 * <ul>
 *   <li><b>UserRepository</b> for saves and single-row lookups. Spring Data derives those
 *       from the method name, returns managed entities that dirty-checking can track, and honours
 *       the {@code @SQLRestriction} that hides soft-deleted rows. Hand-writing them would be more
 *       code that does less.
 *   <li><b>NamedParameterJdbcTemplate</b> for queries that aggregate. JPA has nothing to
 *       offer a query whose result is not an entity.
 * </ul>
 */
@Slf4j
@Repository
public class UserDAOImp implements UserDAO {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private NamedParameterJdbcTemplate jdbcTemplate;

    @Autowired
    private AdminUserRowMapper adminUserRowMapper;
}

The rule of thumb: if the result is an entity, use the repository; if it is a number, a projection or a report row, use SQL.

Named parameters, not positional ones

NamedParameterJdbcTemplate wraps the plain JdbcTemplate and binds :name instead of ?, so a query mentioning the same value twice does not depend on argument order. Prefer it.

Map.of("from", from, "limit", limit)

⚠️ Bind, never concatenate. The parameters are sent separately from the statement, so nothing a caller passes can change the shape of the SQL. String-concatenating a value into a query is SQL injection, and it is injection even when the value "obviously" comes from your own code — today.

Declare the SQL inside the method that runs it

@Override
public ReportDTOs.Summary findSummary(LocalDateTime from) {
    String query =
            """
            SELECT COUNT(*)                  AS total_orders,
                   COALESCE(SUM(o.total), 0) AS total_revenue,
                   COALESCE(AVG(o.total), 0) AS average_order_value
            FROM customer_order o
            WHERE o.status IN ('PAID','PREPARING','COMPLETED')
              AND o.deleted = 0
              AND o.created_at >= :from
            """;

    // An aggregate without GROUP BY always returns exactly one row — COUNT/SUM over an empty set
    // is a row of zeroes, not zero rows — so this cannot throw EmptyResultDataAccessException.
    return jdbcTemplate.queryForObject(query, Map.of("from", from), reportSummaryRowMapper);
}

Java text blocks make SQL readable, and keeping the query in the method that binds its parameters means you read both together. A constants file full of query strings separates the two things that must change together.

Note the comment on queryForObject. It throws EmptyResultDataAccessException when a query returns no rows, which is a real trap — but not here, because an aggregate without GROUP BY always returns exactly one row. Knowing which case you are in is the difference between a comment and a production incident.

RowMappers belong in their own classes

/**
 * Maps a best-seller row onto {@link ReportDTOs.TopProduct}.
 *
 * <p>{@code product_name} is the name SNAPSHOTTED onto the order line, not a join back to the
 * product table — a product deleted from the menu still has to appear in historical sales.
 */
@Component
public class TopProductRowMapper implements RowMapper<ReportDTOs.TopProduct> {

    @Override
    public ReportDTOs.TopProduct mapRow(ResultSet rs, int rowNum) throws SQLException {
        return new ReportDTOs.TopProduct(
                rs.getString("product_name"),
                rs.getLong("units_sold"),
                RowValues.money(rs.getBigDecimal("revenue")));
    }
}

A lambda inside the DAO would work. A class is better: it is unit-testable on its own, and the query and its mapping can then change independently. The column aliases in the SQL are the contract between the two — rename units_sold in the query and this class breaks, which is exactly the coupling you want to be explicit.

The API you will actually use

// A list
List<AdminUserDTO> users = jdbcTemplate.query(query, Map.of(), adminUserRowMapper);

// Exactly one row — throws if there are none or many
Summary summary = jdbcTemplate.queryForObject(query, params, summaryRowMapper);

// A single scalar
Long count = jdbcTemplate.queryForObject(
        "SELECT COUNT(*) FROM customer_order WHERE deleted = 0", Map.of(), Long.class);

// Insert / update / delete — returns rows affected
int updated = jdbcTemplate.update(
        "UPDATE customer_order SET status = :status WHERE public_id = :id",
        Map.of("status", "PAID", "id", orderId.toString()));

For "zero or one row", wrap it:

List<User> rows = jdbcTemplate.query(query, params, userRowMapper);
return rows.stream().findFirst();

That is cleaner than catching EmptyResultDataAccessException as control flow.

⚠️ Hand-written SQL must filter deleted = 0 itself

This is the most expensive gotcha in the pizza codebase, and it is worth reading the whole comment:

/**
 * <b>Every query filters {@code deleted = 0}, and that is not optional.</b> The entities carry
 * {@code @SQLRestriction("deleted = false")}, so soft-deleted rows vanish from ordinary JPA reads
 * and it is tempting to assume they are gone everywhere. They are not: that annotation is applied by
 * Hibernate when it builds a query from the entity model, and SQL written here never goes near the
 * entity model. Omitting the predicate silently counts cancelled-and-deleted orders as revenue — the
 * reports stay entirely plausible, just wrong, which is why it went unnoticed for a while. Guarded
 * by {@code ReportServiceImplTest#softDeletedOrdersAreExcluded}.
 */

Every element of that failure is what makes it dangerous: nothing threw, nothing logged, and the numbers looked reasonable. The only defences are the habit and a test that asserts it — "guarded by" naming the test is a good pattern to copy, because it tells the next person that removing the predicate has a specific consequence somebody already found.

Aggregate in the database

/**
 * <p>The queries aggregate in the DATABASE and return a handful of rows. The alternative — loading
 * every order into memory and summing in Java — works fine on 18 demo rows and falls over on a real
 * order table. Aggregation belongs where the data is.
 */

The same reasoning applies to counting. The pizza API's admin user list used to load every user and then, per user, count their orders and measure their address and card lists — a textbook N+1 that findAllForAdmin() replaced with one query that counts in SQL.

What about @Query(nativeQuery = true)?

You can put native SQL on a Spring Data repository method. The pizza API moved away from it, and the reasoning is instructive:

/**
 * These aggregates used to be declared as {@code @Query(nativeQuery = true)} on a repository
 * interface, which meant real SQL travelling through a persistence layer that added nothing to it,
 * returning interface projections that Spring had to build proxies for. Here the SQL is plain, the
 * mapping is an explicit {@code RowMapper}, and what you read is what the database runs.
 */

If you are writing SQL anyway, a repository adds indirection rather than removing it. Keep the repository for derived queries, where it genuinely writes code for you.

What to take from this

  • Repository for entities, JdbcTemplate for aggregates. Make the choice inside the DAO.
  • Named parameters, bound never concatenated.
  • SQL in the method, RowMappers in their own classes. Column aliases are the contract.
  • Hand-written SQL must filter soft deletes itself — nothing will tell you it did not.
  • Aggregate in the database.

Next: schema migrations with Liquibase — putting the schema itself under version control.