Practical conventions for JDBC in NUS Java Spring applications using Spring JDBC (JdbcTemplate / NamedParameterJdbcTemplate) and plain SQL. Covers safe parameter binding, null-return patterns, inline SQL building (StringBuilder/concat), row mapping, generated keys, batch operations, and pragmatic transaction boundaries. Use this skill when writing or reviewing SQL-based repository/DAO code (not JPA/Hibernate).
Scope: Spring JDBC (
JdbcTemplate,NamedParameterJdbcTemplate), inline SQL, safe parameter binding, null-return read patterns, mapping, batch, and transaction boundaries.See also:
- Coding skill (boundary-safe errors + sensitive data):
.github/skills/coding/SKILL.md- Java umbrella skill:
.github/skills/java/SKILL.md- Java REST API skill (API boundary, DTO rules, error mapping):
.github/skills/java/rest-api/SKILL.md- Java Spring Service skill (service-layer transaction + exception handling for DAO callers):
.github/skills/java/spring-service/SKILL.md
For an existing application, you MUST follow what the repo already does for:
*Dao vs *Repository), package locationJdbcTemplate vs NamedParameterJdbcTemplate usagenull)You MUST NOT refactor the whole data-access style unless explicitly requested.
SELECT * unless the codebase already accepts it.NamedParameterJdbcTemplate for readability and complex queries.JdbcTemplate is acceptable in existing codebases; don't introduce NamedParameterJdbcTemplate everywhere unless asked.Constructor injection is required.
@Repository
public class UserDao {
private final NamedParameterJdbcTemplate jdbc;
public UserDao(NamedParameterJdbcTemplate jdbc) {
this.jdbc = jdbc;
}
}
null is acceptable and common.null, call sites MUST perform null checks (service/controller layer).Use query(...) + stream().findFirst().orElse(null) to avoid exceptions for 0 rows.
public UserDto findById(Long id) {
String sql =
"SELECT id, name, email " +
"FROM users " +
"WHERE id = :id";
List<UserDto> results = jdbc.query(
sql,
new MapSqlParameterSource("id", id),
userRowMapper()
);
return results.stream().findFirst().orElse(null);
}
Avoid queryForObject unless the codebase already uses it heavily, because it throws on 0 rows.
RowMapper<T> (lambda or dedicated class) for stability.BeanPropertyRowMapper is allowed only if the codebase already uses it and column-to-field mapping is consistent.private RowMapper<UserDto> userRowMapper() {
return (rs, rowNum) -> {
UserDto dto = new UserDto();
dto.setId(rs.getLong("id"));
dto.setName(rs.getString("name"));
dto.setEmail(rs.getString("email"));
dto.setCreatedAt(rs.getTimestamp("created_at")); // map to java.util.Date via Timestamp if needed
return dto;
};
}
public List<UserDto> findAll() {
String sql =
"SELECT id, name, email " +
"FROM users " +
"ORDER BY name";
return jdbc.query(sql, new MapSqlParameterSource(), userRowMapper());
}
"..." + "..." only for fixed SQL fragments (no user-supplied values).public List<UserDto> search(UserSearchCriteria c) {
StringBuilder sql = new StringBuilder();
MapSqlParameterSource params = new MapSqlParameterSource();
sql.append("SELECT id, name, email ");
sql.append("FROM users ");
sql.append("WHERE 1=1 ");
if (c.getNameLike() != null && !c.getNameLike().isBlank()) {
sql.append("AND name LIKE :nameLike ");
params.addValue("nameLike", "%" + c.getNameLike() + "%");
}
if (c.getEmailLike() != null && !c.getEmailLike().isBlank()) {
sql.append("AND email LIKE :emailLike ");
params.addValue("emailLike", "%" + c.getEmailLike() + "%");
}
sql.append("ORDER BY name ");
return jdbc.query(sql.toString(), params, userRowMapper());
}
You cannot parameter-bind column names. If sorting is needed, use allowlists:
private static final Map<String, String> SORT_COLUMNS = Map.of(
"name", "name",
"email", "email",
"createdAt", "created_at"
);
public List<UserDto> searchSorted(String sortBy, String sortDir) {
String baseSql = "SELECT id, name, email, created_at FROM users WHERE 1=1 ";
String col = SORT_COLUMNS.getOrDefault(sortBy, "created_at");
String dir = "DESC".equalsIgnoreCase(sortDir) ? "DESC" : "ASC";
String sql = baseSql + " ORDER BY " + col + " " + dir;
return jdbc.query(sql, new MapSqlParameterSource(), userRowMapper());
}
Spring can expand named list params:
public List<UserDto> findByIds(List<Long> ids) {
String sql = "SELECT id, name, email FROM users WHERE id IN (:ids)";
return jdbc.query(sql, new MapSqlParameterSource("ids", ids), userRowMapper());
}
public int insert(UserDto user) {
String sql =
"INSERT INTO users (name, email) " +
"VALUES (:name, :email)";
MapSqlParameterSource params = new MapSqlParameterSource()
.addValue("name", user.getName())
.addValue("email", user.getEmail());
return jdbc.update(sql, params);
}
public Long insertAndReturnId(UserDto user) {
String sql =
"INSERT INTO users (name, email) " +
"VALUES (:name, :email)";
MapSqlParameterSource params = new MapSqlParameterSource()
.addValue("name", user.getName())
.addValue("email", user.getEmail());
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbc.update(sql, params, keyHolder, new String[] {"id"}); // follow DB/PK conventions
Number key = keyHolder.getKey();
return (key == null) ? null : key.longValue();
}
public int update(UserDto user) {
String sql =
"UPDATE users " +
"SET name = :name, email = :email " +
"WHERE id = :id";
MapSqlParameterSource params = new MapSqlParameterSource()
.addValue("id", user.getId())
.addValue("name", user.getName())
.addValue("email", user.getEmail());
return jdbc.update(sql, params);
}
public int deleteById(Long id) {
String sql = "DELETE FROM users WHERE id = :id";
return jdbc.update(sql, new MapSqlParameterSource("id", id));
}
public int[] batchInsert(List<UserDto> users) {
String sql = "INSERT INTO users (name, email) VALUES (:name, :email)";
SqlParameterSource[] batch = users.stream()
.map(u -> new MapSqlParameterSource()
.addValue("name", u.getName())
.addValue("email", u.getEmail()))
.toArray(SqlParameterSource[]::new);
return jdbc.batchUpdate(sql, batch);
}
Notes:
Statement.SUCCESS_NO_INFO for batch counts; do not assume exact counts unless required and supported.java.util.Date. Prefer consistent mapping with the existing schema:
rs.getTimestamp("col") and assign to Date.Date, pass as a parameter value; optionally specify SQL type if needed.Example:
params.addValue("createdAt", user.getCreatedAt());
If null handling is sensitive, specify types:
params.addValue("createdAt", user.getCreatedAt(), Types.TIMESTAMP);
DataAccessException (runtime).@Transactional at service layer for multi-step operations.@Transactional unless the codebase already uses it.@Service
public class UserService {
private final UserDao userDao;
private final AuditDao auditDao;
public UserService(UserDao userDao, AuditDao auditDao) {
this.userDao = userDao;
this.auditDao = auditDao;
}
@Transactional
public void createUser(UserDto user) {
userDao.insert(user);
auditDao.insertAudit("USER_CREATE", new Date());
}
}
Map<Id, ParentDto> while iterating rows.Do not introduce complex ORM-like mapping unless explicitly requested.
When reviewing or generating JDBC-layer code, check:
NamedParameterJdbcTemplate) used when more than one parameter improves readabilitybatchUpdate where appropriate; per-row inserts in a loop avoided for bulk writesDataAccessException is caught at the appropriate layer and mapped to domain or boundary-safe errorsJdbcTemplate; connections are not obtained or closed manuallyjava.util.Date / java.sql.Timestamp)