JDBC First: Connections, Pools & Transactions

2:47 AM, the Saturday after the Diwali sale went live. The checkout service's error rate jumped from 0.01% to 38% in four minutes. The logs were a wall of the same line, repeated thousands of times: Connection is not available, request timed out after 30000ms. HikariCP was telling us the pool was empty — every checkout thread was queueing for a database connection that would never come back.

Nothing had been deployed. Traffic was high but not record-breaking. The database was healthy, CPU at 20%. The connections hadn't gone anywhere. They were being held: a refund-reconciliation job, added the week before, opened a connection per refund batch and — on one code path — never closed it. Each run leaked a handful of connections. At sale traffic, "a handful per run" emptied a 10-connection pool in under an hour. The fix was twelve lines. Finding it took four engineers and most of the night, because nobody on the call had ever thought about what a connection pool is. They'd only ever used JPA, which hides this entire layer.

That's the argument for this post. JPA and Hibernate sit on top of JDBC the way a car sits on top of an engine: you can drive for years without opening the hood, but the day it breaks down at 2:47 AM, you debug at the JDBC layer — the SQL log, the pool metrics, the transaction boundaries. This post opens the hood: DataSource basics, HikariCP pool tuning, a worked JdbcTemplate CRUD example against a real database, connection leaks, and transactions done right.

Why JDBC before JPA

Every JPA operation eventually becomes JDBC calls: open connection (or borrow one from the pool), prepare statement, bind parameters, execute, map the result set, close. When Hibernate is fast, that layer is invisible. When it's slow, every clue lives there:

  • Slow page? You read the SQL Hibernate generated — that's JDBC-level thinking.
  • Pool exhausted? You read pool metrics — connections are a JDBC concept.
  • Half-written data? You check transaction boundaries — @Transactional is a JDBC transaction manager wearing a nice coat.

Learn the layer once, and every ORM mystery becomes a JDBC question you already know how to answer. Skip it, and every ORM mystery stays magic. The checkout service team learned this at 2:47 AM. You'll learn it over coffee.

DataSource: the one interface to learn

The naive way to talk to a database opens a brand-new physical connection on every call — TCP handshake, authentication, session setup — and, in the version below, never closes it. This method compiles, runs, returns the right answer, and slowly kills your database:

public BigDecimal leakyTotalForOrder(long orderId) throws SQLException {
    Connection con = DriverManager.getConnection("jdbc:h2:mem:checkout", "sa", "");
    PreparedStatement ps = con.prepareStatement("SELECT total FROM orders WHERE id = ?");
    ps.setLong(1, orderId);
    ResultSet rs = ps.executeQuery();
    rs.next();
    return rs.getBigDecimal(1);
    // Connection, PreparedStatement, ResultSet: nothing closed. Three leaks per call.
}

The fix has two parts. First, stop asking DriverManager for connections and ask a DataSource instead — javax.sql.DataSource is the single interface all of Spring's data access is built on. A DataSource is a factory for connections; what kind of factory is an implementation detail. Spring's DriverManagerDataSource opens a new connection per call (fine for a test, never for production). A pooling DataSource hands out already-open connections and takes them back when you close them. Your code talks to the interface; the pool is a configuration choice, not a code change.

Second, close what you borrow. The try-with-resources version closes in reverse order — ResultSet, then PreparedStatement, then Connection — even when the body throws. And when the DataSource is a pool, closing the Connection doesn't tear down the TCP connection; it returns it to the pool:

public BigDecimal safeTotalForOrder(DataSource ds, long orderId) throws SQLException {
    try (Connection con = ds.getConnection();
         PreparedStatement ps = con.prepareStatement("SELECT total FROM orders WHERE id = ?")) {
        ps.setLong(1, orderId);
        try (ResultSet rs = ps.executeQuery()) {
            rs.next();
            return rs.getBigDecimal(1);
        }
    }
}

Decision rule: every Connection must be closed in the same method that borrowed it — try-with-resources, no exceptions, no "the caller will close it." With a pool, close() means "return to the pool," so this rule costs nothing and prevents the 2:47 AM outage.

The pool: HikariCP and its five knobs

Spring Boot's default pool is HikariCP — the fastest widely-used JDBC pool, and the one you'll meet in most production Spring apps. Here's a complete pool configuration in plain Spring (no Boot magic yet, so you can see every moving part). Every class below is real and this file compiles:

@Configuration
@EnableTransactionManagement
public class JdbcConfig {

    @Bean
    public DataSource dataSource() {
        HikariConfig cfg = new HikariConfig();
        cfg.setJdbcUrl("jdbc:h2:mem:checkout;DB_CLOSE_DELAY=-1");
        cfg.setUsername("sa");
        cfg.setPassword("");
        cfg.setMaximumPoolSize(10);
        cfg.setMinimumIdle(2);
        cfg.setConnectionTimeout(30_000);
        cfg.setIdleTimeout(600_000);
        cfg.setMaxLifetime(1_800_000);
        return new HikariDataSource(cfg);
    }

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

    @Bean
    public PlatformTransactionManager transactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

What each knob means — and these are worth learning precisely, because mis-set pool knobs are a top-three cause of "the app is slow but the DB is fine":

  • maximumPoolSize (default 10) — the hard cap on connections. This is the number in the 2:47 AM story. Size it from the workload: how many threads genuinely need the database at the same instant, bounded by what the database can survive (a Postgres with max_connections = 100 shared across five app instances cannot give each instance a pool of 50).
  • minimumIdle (default = maximumPoolSize) — how many idle connections the pool tries to keep warm. Set it below the max to let the pool shrink during quiet hours; keep it equal to the max when you want zero connection-setup latency on the first request after idle.
  • connectionTimeout (default 30s) — how long a thread waits for a free connection before HikariCP throws. Thirty seconds of threads piling up is how a pool problem becomes a thread-pool problem becomes a full outage. Keep it shorter than your upstream request timeout so a pool problem surfaces as a fast, clear error instead of a cascade.
  • idleTimeout (default 10 min) — how long an idle connection may sit before being retired. Only applies when the pool holds more than minimumIdle.
  • maxLifetime (default 30 min) — the maximum age of any connection, idle or not. Retire connections before the database or a firewall kills them — set it a few minutes below the database's idle-connection timeout (MySQL's wait_timeout, for example). A connection killed server-side while sitting in your pool becomes a nasty intermittent failure.
checkout threads borrow a connection per request pool: idle connections already-open, waiting minimumIdle … maximumPoolSize active: in use running your SQL close() returns it (green) retired by timeouts idleTimeout: idle too long maxLifetime: too old, even if busy leaked: never returned borrowed, never closed — the pool shrinks until threads time out blue: borrow path · green: return path · set leakDetectionThreshold in dev to log the stack trace of slow returns

Decision rules: size maximumPoolSize from measured concurrency, not from a tutorial's 10 — and never above what the database can handle across all your app instances. Keep connectionTimeout below your request timeout so pool exhaustion fails fast and loud. Set maxLifetime below the database's own idle killer. And in dev and staging, set leakDetectionThreshold (e.g. 60 seconds): it logs the stack trace of any borrow held too long, which is exactly how you'd have caught the 2:47 AM leak before the sale.

JdbcTemplate: SQL without the ceremony

With the pool in place, JdbcTemplate is Spring's answer to raw JDBC: you write the SQL, it handles borrowing the connection, preparing the statement, binding parameters, iterating the result set, translating vendor error codes into Spring's exception hierarchy, and — crucially — returning the connection to the pool even when something throws. Here's a complete worked CRUD repository for the checkout service's orders table, backed by an in-memory H2 database:

public record Order(Long id, Long customerId, BigDecimal total, String status) {
}

@Repository
public class OrderRepository {

    private final JdbcTemplate jdbc;

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

    private static final RowMapper<Order> ROW = (rs, n) -> new Order(
            rs.getLong("id"),
            rs.getLong("customer_id"),
            rs.getBigDecimal("total"),
            rs.getString("status"));

    public void createTable() {
        jdbc.execute("""
                CREATE TABLE IF NOT EXISTS orders (
                    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
                    customer_id BIGINT NOT NULL,
                    total DECIMAL(12, 2) NOT NULL,
                    status VARCHAR(20) NOT NULL
                )""");
    }

    public Order save(Order order) {
        KeyHolder keys = new GeneratedKeyHolder();
        jdbc.update(con -> {
            PreparedStatement ps = con.prepareStatement(
                    "INSERT INTO orders (customer_id, total, status) VALUES (?, ?, ?)",
                    Statement.RETURN_GENERATED_KEYS);
            ps.setLong(1, order.customerId());
            ps.setBigDecimal(2, order.total());
            ps.setString(3, order.status());
            return ps;
        }, keys);
        long id = keys.getKey().longValue();
        return new Order(id, order.customerId(), order.total(), order.status());
    }

    public Optional<Order> findById(long id) {
        List<Order> rows = jdbc.query(
                "SELECT id, customer_id, total, status FROM orders WHERE id = ?", ROW, id);
        return rows.stream().findFirst();
    }

    public List<Order> findByStatus(String status) {
        return jdbc.query(
                "SELECT id, customer_id, total, status FROM orders WHERE status = ? ORDER BY id",
                ROW, status);
    }

    public int markPaid(long id) {
        return jdbc.update(
                "UPDATE orders SET status = 'PAID' WHERE id = ? AND status = 'NEW'", id);
    }

    public int deleteById(long id) {
        return jdbc.update("DELETE FROM orders WHERE id = ?", id);
    }
}

Three things to notice, because each one prevents a real bug:

  • ? placeholders, never string concatenation. The values are bound as parameters, not interpolated into the SQL text. This is what stops SQL injection — the database never parses your data as code.
  • The RowMapper is yours. You map columns to fields explicitly, by name. No reflection magic, no surprises: if someone renames a column, this fails loudly at the mapping site instead of silently returning nulls.
  • Updates return row counts. markPaid returns how many rows changed. A return of 0 means "no NEW order with that id" — a concurrent state change or a wrong id — and your service layer can decide what that means instead of assuming success.

Transactions: the refund that must be atomic

Refunding an order is two writes: flip the order's status to REFUNDED, and insert a row into the refunds ledger. If the first write commits and the second fails, the order says refunded but no money movement was recorded — the books don't balance and finance comes looking for you. The two writes must succeed or fail together. That's a transaction, and in Spring you declare it, you don't code it:

@Service
public class OrderService {

    private final OrderRepository orders;
    private final RefundRepository refunds;

    public OrderService(OrderRepository orders, RefundRepository refunds) {
        this.orders = orders;
        this.refunds = refunds;
    }

    @Transactional
    public void refund(long orderId, BigDecimal amount) {
        Order order = orders.findById(orderId)
                .orElseThrow(() -> new IllegalArgumentException("unknown order " + orderId));
        if (!"PAID".equals(order.status())) {
            throw new IllegalStateException("only PAID orders can be refunded");
        }
        orders.markRefunded(orderId);
        refunds.record(orderId, amount);
        if (amount.compareTo(order.total()) > 0) {
            // Thrown AFTER two writes already ran. The transaction manager
            // rolls both statements back — the refund row and the status flip
            // never become visible.
            throw new IllegalArgumentException("refund exceeds order total");
        }
    }
}

The @Transactional annotation puts a proxy around this service: before the method runs, the proxy borrows a connection and starts a transaction; if the method returns, it commits; if it throws an unchecked exception, it rolls back. I ran exactly this code against H2 — here's the real output, including the deliberate failure:

saved: Order[id=1, customerId=42, total=199.99, status=NEW]
expected failure: refund exceeds order total
status after failed refund: PAID
PAID revenue: 199.99

Read that third line carefully: the refund threw after markRefunded and record had executed, yet the order is still PAID and the refund row is gone. Both writes were undone as a unit. That is the whole contract of @Transactional, verified rather than asserted.

@Transactional refund() starts — one connection, one transaction UPDATE orders SET status='REFUNDED' … · INSERT INTO refunds … method returns proxy commits: both writes become visible together, atomically method throws (unchecked) proxy rolls back: both writes undone order stays PAID, no refund row rollback is the default for RuntimeException and Error — checked exceptions do NOT roll back unless you say rollbackFor

Decision rules: put @Transactional on the service method that expresses the business unit of work — never on repositories (too fine-grained) and never on private methods (the proxy can't see them, so the annotation is silently ignored; self-invocation within the same class bypasses the proxy too). Rollback is the default for unchecked exceptions only — if your method throws a checked exception, declare rollbackFor or translate it. And mark read-only flows @Transactional(readOnly = true): it's a hint that lets the provider skip dirty-checking and, on some databases, route to a replica.

The Spring Boot way: properties over beans

Everything above was plain Spring so the machinery stays visible. In a Spring Boot app you rarely write that JdbcConfig by hand — Boot's auto-configuration builds the same DataSource, JdbcTemplate, and transaction manager from properties (if you define your own DataSource bean, Boot politely backs off). The project setup:

<project>
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
  </parent>
  <groupId>com.javamakeuse</groupId>
  <artifactId>checkout-service</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <properties>
    <java.version>25</java.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <version>2.5.252</version>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>

(Check for newer versions than the ones pinned above — the coordinates are the stable part. Spring Boot 4 requires Java 17 or newer; this track targets JDK 25. spring-boot-starter-jdbc pulls in HikariCP, spring-jdbc, and spring-tx.)

spring.datasource.url=jdbc:h2:mem:checkout;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=2
spring.datasource.hikari.connection-timeout=5000
spring.datasource.hikari.max-lifetime=1500000

H2 here is a deliberate choice for learning: in-memory, zero-install, and fast enough that your tests stay fast. It is not a production database. When you're ready to test against the real thing, this track's Testcontainers post shows how to spin up real Postgres in your tests — same JdbcTemplate code, honest database underneath. And when you want to prove the pool survives real traffic rather than a unit test, that's what the Gatling post is for: pool exhaustion is exactly the kind of failure that only appears under concurrent load.

Field check before you move on: take the JdbcConfig from this post, set maximumPoolSize to 2 and leakDetectionThreshold to 5 seconds, then run the leaky method in a loop from two threads. Watch the pool drain and the leak detector print the stack trace pointing at the exact line that borrowed and never returned. Then switch to the try-with-resources version and watch the pool stay healthy. That ten-minute experiment teaches more about pools than any documentation page.

Continue: Java Learning Roadmap 2026

Comments

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number