Spring BootJava 21Spring Framework深入淺出

Spring Boot 05: database writes, commits, and rollback

··4 min read
Back to the Spring Boot learning path

An executed INSERT does not mean data will remain committed. This lesson inserts a first ticket, rejects a second, and queries again outside the transaction. The observation is commit or rollback, not merely whether an insert method ran.

Objects between the application and the database

text
TicketService → TicketStore → JdbcClient
                             → DataSource / connection pool → H2

Transaction proxy: begin → service work → commit / rollback

DataSource provides access to connections, while a pool manages reusable connections. JdbcClient supports parameterized SQL and result mapping. TicketStore holds this lab’s queries, and H2 is the database. Boot is not a database and does not make different SQL dialects automatically compatible.

java
// TicketStore.java
package com.keeponfirst.bootlab;

import java.util.Optional;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;

@Repository
public class TicketStore {
    private final JdbcClient jdbc;

    public TicketStore(JdbcClient jdbc) { this.jdbc = jdbc; }

    public void insert(Ticket ticket) {
        jdbc.sql("INSERT INTO tickets (id, title) VALUES (:id, :title)")
            .param("id", ticket.id()).param("title", ticket.title()).update();
    }

    public Optional<Ticket> find(String id) {
        return jdbc.sql("SELECT id, title FROM tickets WHERE id = :id")
            .param("id", id)
            .query((rs, row) -> new Ticket(rs.getString("id"), rs.getString("title")))
            .optional();
    }
}

insert binds named parameters instead of concatenating input into SQL. find lists its columns and returns Optional, leaving missing-data policy to the service. There are no JPA entities, sessions, or lazy-loading rules here; passing this JDBC lab does not validate a Hibernate 4-to-6 migration.

The transaction surrounds the whole batch

java
// TicketService.java
package com.keeponfirst.bootlab;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class TicketService {
    private final TicketStore store;
    private final LabProperties properties;

    public TicketService(TicketStore store, LabProperties properties) {
        this.store = store;
        this.properties = properties;
    }

    public Ticket find(String id) {
        return store.find(id).orElseThrow(MissingTicket::new);
    }

    @Transactional
    public List<Ticket> createBatch(List<String> titles) {
        var created = new ArrayList<Ticket>();
        for (String raw : titles) {
            String title = raw == null ? "" : raw.trim();
            if (title.isBlank() || title.length() > properties.maxTitleLength()) {
                throw new BadTitle();
            }
            var ticket = new Ticket(UUID.randomUUID().toString(), title);
            store.insert(ticket);
            created.add(ticket);
        }
        return List.copyOf(created);
    }
}

createBatch is a public method on a managed service. An external invocation passes through its transaction proxy, covering the entire loop. A valid first title is inserted; a blank second one throws BadTitle. That RuntimeException triggers rollback under the default rules used here for database operations participating in the transaction.

Sequential validation is essential to the experiment: one write must occur before a later failure. Production batches may prevalidate the entire input to avoid partial work. That is a different design; a test that never writes anything cannot establish rollback.

Common trap: does an internal call activate transaction advice?

With default proxy-based transactions, a self-invocation such as this.createBatch(...) does not cross the proxy again. Without an existing transaction, the annotation on the internally called method will not start one through that path. Manual construction also bypasses the proxy. Clarify ownership and call flow before separating beans or using programmatic transactions.

Swallowing an exception can make a method appear successful. Default rules normally roll back for RuntimeException and Error; checked exceptions require checking rollback configuration, and projects may change global defaults. Do not assume every exception guarantees rollback.

Exercise: inspect the result outside the transaction

java
@Test void aFailureAfterTheFirstInsertRollsBackEverything() {
    assertThatThrownBy(() -> service.createBatch(List.of("first", " ")))
        .isInstanceOf(BadTitle.class);
    assertThat(count()).isZero();
}

@Test void successfulBatchReallyCommits() {
    service.createBatch(List.of("first", "second"));
    assertThat(count()).isEqualTo(2);
}

These are excerpts from the test class, with its injection, cleanup, and count helper required. The tests deliberately omit @Transactional so the service owns the transaction. Each case clears the isolated H2 test table first. Run mvn test: the failing batch should leave zero rows and the successful batch two.

Answer: zero and two, then identify participating resources

In a disposable copy, remove @Transactional and rerun the test. The rollback case should fail because the first write may already be committed. Restore it afterwards. This checks whether the test detects a semantic change; removing the annotation is not a fix.

The evidence covers one H2 data source. MQ sends, remote APIs, and files do not automatically roll back simply because they occur in the annotated Java method. EAP/MDB work requires separately checking JTA, XA, the resource adapter, data source, and actual resource participation.

Download the complete ticket API lab (ZIP)Next: 06: Tests, debugging, deployment boundaries

Sources