Raw JDBC — feel the pain.
Today's purpose is not to make you a JDBC expert. It is to make every Spring Data convenience feel earned. By the end you will know exactly what JpaRepository.findById() actually does — because you'll have written it yourself.
JdbcTemplate and JpaRepository "just work" later, you'll see right through them. That's worth one day.What JDBC is
JDBC stands for Java Database Connectivity. It is the official, low-level API in the standard library for talking to a relational database from Java.
JDBC has two layers:
- The JDBC API — interfaces in
java.sql.*likeConnection,Statement,ResultSet - The JDBC Driver — a vendor-specific JAR that implements those interfaces for a particular database (Postgres driver, MySQL driver, Oracle driver…)
Before JDBC, every database had its own C library and you wrote vendor-specific code. JDBC added one standard API. Switching databases became (mostly) just a driver swap. This pattern — interface in the standard library, vendor-specific implementation — became how Java does almost everything: JNDI, JMS, JCA, JPA, the works.
Day-11 hands-on · setup
If you want to follow along (recommended), you need:
- A local Postgres (or any DB) running.
brew install postgresql && brew services start postgresqlon macOS, or rundocker run --name pg -e POSTGRES_PASSWORD=pass -p 5432:5432 -d postgres - A
userstable —CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT UNIQUE, name TEXT, created_at TIMESTAMPTZ DEFAULT now()); - The Postgres JDBC driver on your classpath (Maven:
org.postgresql:postgresql:42.7.2)
If you'd rather just read, the code below tells the story without running.
The five interfaces you must know
| Interface | Role |
|---|---|
DriverManager | Static helper to get a Connection |
Connection | Represents an open session with the database |
Statement | Executes a static SQL string (don't use directly — see below) |
PreparedStatement | Statement with parameterized SQL — safe |
ResultSet | Iterable cursor over rows from a SELECT |
Hello JDBC — the simplest possible read
import java.sql.*;
public class HelloJdbc {
public static void main(String[] args) throws SQLException {
String url = "jdbc:postgresql://localhost:5432/postgres";
String user = "postgres";
String pass = "pass";
try (Connection conn = DriverManager.getConnection(url, user, pass);
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT id, email FROM users")) {
while (rs.next()) {
long id = rs.getLong("id");
String email = rs.getString("email");
System.out.println(id + " " + email);
}
}
}
}That's it. Five things happen:
DriverManager.getConnectionopens a TCP connection to Postgresconn.createStatement()gives you a thing that runs SQLexecuteQuery(...)returns aResultSet(a cursor)rs.next()advances the cursor; returns false when donetry-with-resourcesauto-closes the three resources
Each of Connection, Statement, and ResultSet implements AutoCloseable. If you don't close them — connections leak. Eventually your pool runs out and your app dies. Try-with-resources is the only safe pattern.
PreparedStatement — and why Statement is dangerous
Look at this innocent-looking code:
String email = req.getParameter("email"); // from a form
String sql = "SELECT * FROM users WHERE email = '" + email + "'";
stmt.executeQuery(sql);If a malicious user submits the email ' OR '1'='1, the SQL becomes:
SELECT * FROM users WHERE email = '' OR '1'='1'Now you've returned every user. Worse versions can drop tables. This is SQL injection. The fix is PreparedStatement with placeholders:
String sql = "SELECT * FROM users WHERE email = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, email); // driver escapes safely
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) { ... }
}
}Never concatenate user input into SQL strings. Use PreparedStatement with ? placeholders. This is non-negotiable. Spring's JdbcTemplate, JPA, and every ORM enforce this for you — but raw JDBC will let you shoot yourself.
Full CRUD example — feel the boilerplate
// CREATE
public long createUser(Connection conn, String email, String name) throws SQLException {
String sql = "INSERT INTO users(email, name) VALUES (?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, email);
ps.setString(2, name);
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (keys.next()) return keys.getLong(1);
throw new SQLException("no key returned");
}
}
}
// READ — by id
public Optional<User> findById(Connection conn, long id) throws SQLException {
String sql = "SELECT id, email, name FROM users WHERE id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return Optional.empty();
return Optional.of(new User(rs.getLong("id"), rs.getString("email"), rs.getString("name")));
}
}
}
// UPDATE
public int updateName(Connection conn, long id, String newName) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("UPDATE users SET name = ? WHERE id = ?")) {
ps.setString(1, newName);
ps.setLong(2, id);
return ps.executeUpdate(); // rows affected
}
}
// DELETE
public int delete(Connection conn, long id) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("DELETE FROM users WHERE id = ?")) {
ps.setLong(1, id);
return ps.executeUpdate();
}
}Look at the noise. Try-blocks nesting. Manual parameter setting. Manual ResultSet iteration. Manual mapping from columns to fields. Every single Spring/JPA convenience replaces a chunk of this.
Transactions in raw JDBC
By default, JDBC is auto-commit: every executeUpdate() commits immediately. To group multiple statements:
try (Connection conn = DriverManager.getConnection(...)) {
conn.setAutoCommit(false); // start a transaction
try {
// many INSERT/UPDATE/DELETE here...
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw e;
}
}Spring's @Transactional wraps your method with these exact lines. Now you know.
Connection pooling — why DriverManager isn't enough
Opening a TCP connection to a database takes ~50–200ms. If every HTTP request opens its own, your app is dead at 100 RPS.
The fix is a connection pool: a pre-warmed cache of open connections. You "borrow" one for a request and "return" it when done. The most popular pool today is HikariCP — Spring Boot's default.
From Day 22 onwards, Spring Boot configures HikariCP for you. You'll inject a DataSource bean. dataSource.getConnection() grabs from the pool, conn.close() returns it. The exact same JDBC API — but pooled.
The pain points — what you just felt
- Boilerplate everywhere — every query has try-blocks, parameter setting, iteration
- Manual mapping — column → field is hand-coded for every table
- Checked SQLException — pollutes every signature with
throws SQLException - No type safety — column names are strings; typos surface at runtime
- Easy to leak resources — forget to close → app dies eventually
- Easy to introduce SQL injection — one momentary lapse and you're vulnerable
What every layer above JDBC fixes
| Layer | What it removes |
|---|---|
JdbcTemplate (Spring) | Try-blocks, mapping boilerplate. You still write SQL. |
| JPA / Hibernate | SQL itself, manual mapping, transaction management. |
| Spring Data JPA | Entire repository implementations. |
When in Week 4 you write userRepo.findById(42) and get back an Optional<User> as if by magic — you'll smile. You know exactly what's happening underneath. You wrote it yourself today.
Lock in today's learning
Pure understanding questions. No code required.
- What two layers does JDBC consist of? What does the driver actually do?
- Why must you always use PreparedStatement instead of concatenating SQL strings?
- Name three resources that must be closed and the safest way to ensure that.
- What is JDBC's default auto-commit behavior, and how do you opt out?
- Why does opening a connection per request not scale — and what fixes it?
- List two pieces of boilerplate that
JdbcTemplateremoves for you.
End of Day 11. Tomorrow: Servlets — concept walkthrough. No real coding, just diagrams and the lifecycle.