Old-School Backend / 2026 ・ No.08

Old-School Backend

Part 8: Database access — the same list in JdbcClient, JPA and MyBatis

Published

A terminal. At the bottom, python3 ways.py 新井 reports jdbc with 1 SQL statement in 13.0 ms, jpa with 104 in 85.6 ms, jpa-fetch with 1 in 29.6 ms and mybatis with 1 in 27.9 ms. At the top, the database log shows select … from expense_line … where l1_0.expense_id=$1 repeated over many lines, then single selects for join fetch and MyBatis
The same list of Arai's claims with lines, fetched four ways (bottom). With plain JPA, one SQL statement per claim arrived at the database to fetch its lines (top) (Captured by the author on a cloud Linux machine on 31 October 2026. The bar across the top of the window was added for the illustration.)

Since part 1 our storekeeper has been JdbcClient, with SQL written by hand. Java shops have other standard storekeepers too: JPA (Hibernate), which generates SQL, and MyBatis, which keeps SQL in XML.
This time we write the same job, “a list of claims with their lines”, in all three and measure how many SQL statements reach the database and where the traps are: N+1, updates without save, open-in-view, and MyBatis's ${}.

1

Choosing a storekeeper

AraiWith JPA you don't have to write SQL. Why have we been writing it by hand?

Furui“Don't have to write” isn't “nobody writes”. Someone writes it. Today we count who, when, and how many.

AraiAnd MyBatis? I used it at my last job.

FuruiStill common in Japanese business systems. It has one trap of its own, the same as part 4's.

The 15-part route map (you are here: part 8)

1 Big picture 2 Java basics 3 Raw HTTP 4 Raw JDBC 5 Spring Boot 6 Validation 7 Errors 8 DB access 9 Transactions 10 Workflow 11 Auth 12 Files 13 Logging 14 Testing 15 Running it You are here Basics By hand Front door Data Rules & safety Operations
  • Three ways — the same answer (JSON identical to the byte) in 35, 62 and 56 lines
  • N+1 — plain JPA sent 104 SQL statements where one would do (measured)
  • Silent updates — the database changed without save being called (measured)
  • open-in-view — left at its default, SQL runs in the counter; turned off, a 500 (measured)
  • ${} — MyBatis too gets SQL injection from one way of writing (measured)

The application is last time's Spring Boot 4.1.2 with JPA (Hibernate 7.4, Spring Data JPA 4.1) and MyBatis (3.5.19, mybatis-spring-boot 4.1.0) added. The database holds business-trip claims, 100 for Arai and 20 for Furui, each with three lines (Shinkansen, hotel, taxi). With part 4's six, Arai has 103 claims.

2

Three ways to write it

First, hand-written JdbcClient. One SQL statement joins claims and lines, and Java assembles the rows.

# ① JdbcClient: SQL by hand, rows assembled by hand (the gist of JdbcClaims.java)
public List<ClaimView> findByApplicant(String applicant) {
    Map<String, ClaimView> byId = new LinkedHashMap<>();
    jdbc.sql("""
            select e.id, e.applicant, e.applied_on, e.item, e.amount, l.line_no, l.item as line_item, l.amount as line_amount
            from expense e left join expense_line l on l.expense_id = e.id
            where e.applicant = ? order by e.id, l.line_no""")
        .param(applicant)
        .query(rs -> {
            ClaimView c = byId.computeIfAbsent(rs.getString("id"), id -> {
                try {
                    return new ClaimView(id, rs.getString("applicant"), rs.getObject("applied_on", java.time.LocalDate.class),
                            rs.getString("item"), rs.getBigDecimal("amount"), new ArrayList<>());
                } catch (java.sql.SQLException e) { throw new RuntimeException(e); }
            });
            if (rs.getObject("line_no") != null)
                c.lines().add(new ClaimView.Line(rs.getInt("line_no"), rs.getString("line_item"), rs.getBigDecimal("line_amount")));
        });
    return new ArrayList<>(byId.values());
}

Next, JPA. A table row is written as a Java object (an entity), and relations between tables with @OneToMany. The storekeeper is just an interface; Spring Data builds the SQL from method names.

# ② JPA: a table row held as a Java object (the gist of ExpenseEntity.java)
@Entity
@Table(name = "expense")
public class ExpenseEntity {
    @Id
    private String id;
    private String applicant;
    @Column(name = "applied_on")
    private LocalDate date;
    private String item;
    private BigDecimal amount;
    private String status;

    @OneToMany(mappedBy = "expense")  // lines are fetched when touched (LAZY)
    @OrderBy("id.lineNo")
    private List<ExpenseLineEntity> lines;
# The storekeeper is just an interface (ExpenseJpaRepository.java)
public interface ExpenseJpaRepository extends JpaRepository<ExpenseEntity, String> {
    List<ExpenseEntity> findByApplicantOrderById(String applicant);  // SQL made from the method name

    @Query("select distinct e from ExpenseEntity e left join fetch e.lines where e.applicant = :applicant order by e.id")
    List<ExpenseEntity> findWithLines(String applicant);  // lines fetched in the same query
}

Finally, MyBatis. The SQL is written by hand in XML, along with how to assemble the results (a resultMap).

# ③ MyBatis: SQL and how to assemble results, in XML (the gist of ClaimMapper.xml)
<resultMap id="claim" type="example.expenses.v8.ClaimRow">
  <id property="id" column="id"/>
  <result property="applicant" column="applicant"/>
  <result property="appliedOn" column="applied_on"/>
  <result property="item" column="item"/>
  <result property="amount" column="amount"/>
  <collection property="lines" resultMap="line"/>
</resultMap>
<resultMap id="line" type="example.expenses.v8.ClaimView$Line">
  <constructor>
    <idArg column="line_no" javaType="_int"/>
    <arg column="line_item" javaType="string"/>
    <arg column="line_amount" javaType="java.math.BigDecimal"/>
  </constructor>
</resultMap>

<select id="findByApplicant" resultMap="claim">
  select e.id, e.applicant, e.applied_on, e.item, e.amount, l.line_no, l.item as line_item, l.amount as line_amount
  from expense e left join expense_line l on l.expense_id = e.id
  where e.applicant = #{applicant}
  order by e.id, l.line_no
</select>

MyBatis tripped us once. Passing the list of lines into a record's constructor stopped with “argument type mismatch”, so we used a plain class (ClaimRow) that lines can be added to afterwards. Each tool has shapes it likes.

Three storekeepers: who writes the SQL, who assembles the rows

JdbcClient JPA MyBatis who writes SQL you (in Java) Hibernate does you (in XML) rows → objects you assemble automatic (entity) a resultMap lines this part 35 62 (2 entities) 56 (incl. XML) hard to see little when SQL is sent little traps (measured) assembly work N+1, silent update open-in-view ${} glues strings Who writes the SQL: write it and you see it; delegate it, it runs unseen Non-blank lines; JPA: 2 entities + interface; MyBatis: XML + holder
3

Same answer, different SQL

We fetched Arai's claims four ways (JPA both “as is” and with “join fetch”). The JSON returned was identical to the byte in all four, but the SQL reaching the database was not.

# Arai's 103 claims with lines, four ways (five runs each, median)
  JdbcClient          SQL   1     18.0 ms   (16.7, 17.7, 18.0, 18.0, 19.2)
  JPA (as is)         SQL 104     63.5 ms   (49.9, 62.2, 63.5, 71.2, 91.5)
  JPA (join fetch)    SQL   1     15.8 ms   (14.1, 14.3, 15.8, 19.6, 22.0)
  MyBatis             SQL   1     13.4 ms   (9.7, 12.1, 13.4, 16.6, 17.6)
# SQL JPA (as is) sent: the first statement, and the one repeated 103 times after it
select ee1_0.id,ee1_0.amount,ee1_0.applicant,ee1_0.applied_on,ee1_0.item,ee1_0.status from expense ee1_0 where ee1_0.applicant=$1 order by ee1_0.id
select l1_0.expense_id,l1_0.line_no,l1_0.amount,l1_0.item from expense_line l1_0 where l1_0.expense_id=$1 order by l1_0.line_no   × 103
# The single statement after join fetch
select distinct ee1_0.id,ee1_0.amount,ee1_0.applicant,ee1_0.applied_on,ee1_0.item,l1_0.expense_id,l1_0.line_no,l1_0.amount,l1_0.item,ee1_0.status from expense ee1_0 left join expense_line l1_0 on ee1_0.id=l1_0.expense_id where ee1_0.applicant=$1 order by ee1_0.id,l1_0.line_no

Plain JPA first sends one statement for the list of claims. Then, every time a claim's lines are touched, one more statement fetches “this claim's lines”: 103 of them. Hibernate's SQL uses aliases like ee1_0 and l1_0, a shape no person writes. The photo shows a single run and the table the median of five, so the times differ a little.

4

N+1: 104 queries where one will do

Arai's 103 claims with lines: SQL reaching the DB (measured)

Arai's 103 claims with lines: SQL reaching the DB (measured) JPA (as is) 1 + 103 = 104 statements, 63.5 ms JPA (join fetch) 1 statement, 15.8 ms JdbcClient, MyBatis 1 statement, 18.0 ms / 13.4 ms Touch the lines and each claim sends its own SQL: that's N+1 Lab DB: 0.04 ms away; at 0.5 ms that alone adds ~50 ms (calculated) It grows with the data: unseen with 3 test rows, stuck with 10,000

One statement for the list (1), plus one per item (N): that's N+1. @OneToMany defaults to fetching lines “when touched” (LAZY), so converting each claim to the screen's shape sends another statement. Not one line of SQL appears in the code, so code review won't catch it.

The fix is to say up front that the lines come along. Here the query with join fetch (findWithLines) made it one statement and 15.8 ms. There's also @EntityGraph and batch-fetch settings. Whichever you use, the best medicine is the habit of counting the SQL that reaches the database.

AraiThe test had three claims, so I never noticed…

FuruiN+1 wakes up at month end, the day accounting opens a full year.

5

An update without save

We gave the JPA clerk a job: “read a claim and return its item with ‘(確認済み)’ (checked) appended, for display.” No intention of writing to the database, and no save().

A terminal. At the bottom, the item reads 出張-001; POST /api/v8/decorate/EXP-2026-5001 replies 出張-001(確認済み); checking again, the database also reads 出張-001(確認済み). At the top, the database log shows a select followed by update expense set amount=$1,applicant=$2,applied_on=$3,item=$4,status=$5 where id=$6
The clerk only read the item and renamed it for display, never calling save. Even so, an update reached the database and the item was rewritten (Captured by the author on a cloud Linux machine on 31 October 2026. The bar across the top of the window was added for the illustration.)
# The clerk: just read it and renamed it for display; save() is never called
@Transactional  // just read it and renamed it for display (or so we thought)
public String decorate(String id) {
    ExpenseEntity e = jpa.findById(id).orElseThrow();
    e.setItem(e.getItem() + "(確認済み)");
    return e.getItem();
}
# DB before / SQL during the call / DB after
  出張-001
  select ee1_0.id,ee1_0.amount,ee1_0.applicant,ee1_0.applied_on,ee1_0.item,ee1_0.status from expense ee1_0 where ee1_0.id=$1
  update expense set amount=$1,applicant=$2,applied_on=$3,item=$4,status=$5 where id=$6
  出張-001(確認済み)

The database's item was rewritten to 出張-001(確認済み). JPA watches the entities read inside a transaction as things it holds; at commit it compares them with snapshots taken when read and sends an update if anything changed. And the update rewrote every column, not just the item. An entity is a thing tied to a database row, not something to rewrite for display.

JPA's “watching what it holds”, and open-in-view

JPA's “watching what it holds” (the persistence context) inside the transaction read findById snapshot values as read change a value setItem(…) at commit compare → update Changed values are written to the DB even without save() (measured) open-in-view: keep watching until the reply is written? true (default): 104 queries false: touching lines → 500 An entity is tied to a DB row; it's not a thing to rewrite for display Return a separate shape (a record), converted inside the clerk Set open-in-view to false; gather what you need inside the clerk
6

open-in-view

The application with JPA printed a warning every time it started.

# Warning at startup
spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
# A counter returning entities as they are (open-in-view at its default, true)
  200  rows 103  SQL 104  62.7 ms
# Started with spring.jpa.open-in-view=false
  500  SQL 1
  Could not write JSON: Cannot lazily initialize collection of role 'example.expenses.v8.ExpenseEntity.lines' with key 'EXP-2026-0101' (no session)

By Spring Boot's default, the watching (open-in-view) lasts until the reply has been written. So when a counter returns entities as they are, Jackson touches the lines while writing JSON and 104 statements run from the counter. With spring.jpa.open-in-view=false, the watching ends with the clerk's transaction, and touching the lines in the counter gave a 500 with “no session”.

Which is better? False, so the 500 tells you early. Convert what the screen gets into a separate shape inside the clerk (here ClaimView) and gather the needed lines there. It's part 5's “the counter handles only HTTP shapes” again.

7

MyBatis's #{} and ${}

MyBatis XML has two ways of filling in values: #{} and ${}.

A terminal. Line 23 of ClaimMapper.xml is where e.applicant = #{applicant}, and line 30 is where e.applicant = '${applicant}'. unsafe.py shows mybatis returning 103 claims for 新井 and 0 for 新井' or '1'='1, and mybatis-unsafe returning 103 for 新井 and 126 for 新井' or '1'='1
MyBatis's #{} is part 4's placeholder; ${} is string gluing. With ${}, a quote-laden name returned all 126 claims (Captured by the author on a cloud Linux machine on 31 October 2026. The bar across the top of the window was added for the illustration.)
# Two ways of writing it in MyBatis
  #{}  where e.applicant = #{applicant}
  ${}  where e.applicant = '${applicant}'
# Passing the same names
  mybatis         新井' or '1'='1   → 200  0
  mybatis-unsafe  新井              → 200  103
  mybatis-unsafe  新井' or '1'='1   → 200  126

#{} becomes part 4's placeholder (?), so the quote-laden name is just “someone with that name”: 0 rows. ${} pastes straight into the SQL text, so, as in part 4, all 126 claims came back. Use ${} only for values chosen from an allowed list, like a sort column (part 4, section 6). When reading old MyBatis code, search for ${ first.

8

The weight of the tools

# The toolkit (compared with part 7's application)
  part 7: jar 24,340,548 bytes, libraries 48, startup 3.667 s / 3.558 s / 3.651 s
  part 8: jar 55,029,971 bytes, libraries 75, startup 7.846 s / 8.0 s / 7.589 s

With JPA and MyBatis added, the jar grew from 24 MB to 55 MB, libraries from 48 to 75, and startup from 3.6 to about 7.8 seconds. This part's application carries both JPA and MyBatis, where a real one would carry one. Even so, Hibernate inspects entities and prepares at startup, which makes starting heavier.

9

Which to choose

ToolSuitsWatch for
JdbcClientfew tables, building while looking at SQL, fine performance tuningwriting row assembly yourself; SQL scattered through Java
JPAtables shaped like objects, mostly create/update/delete, many tablesN+1, change tracking (silent updates), open-in-view; always look at the SQL sent
MyBatiscomplex SQL, existing databases, SQL-strong teams (common in Japanese business systems)${}; moving between XML and Java
POINT

Whichever you choose, the SQL reaching the database was written by someone. Log the SQL and count it, don't return entities to the screen as they are, and always use placeholders for values; these three hold whatever the tool. The rest of this series stays with part 1's JdbcClient, where the SQL is in plain sight. The same can be written in JPA or MyBatis.

10

Checking on your own PC

What to tryHow to write it
Show Hibernate's SQL in the application loglogging.level.org.hibernate.SQL=DEBUG
Log every SQL statement reaching the DB (practice DB only)alter system set log_statement = 'all';
Fetch the lines along with the claimsselect distinct e from … left join fetch e.lines …
End the watching inside the clerkspring.jpa.open-in-view=false
Find MyBatis string gluinggrep -rn '\${' src/main/resources

The easiest way to spot N+1: open a list screen once and count the lines in the database log. Increase the data from 3 to 100 rows; if the number of statements grows too, it's N+1.

§ — On one page, and next time

  1. JdbcClient, JPA and MyBatis can return the same answer. The difference is who writes the SQL.
  2. Plain JPA gives N+1: 104 statements where one will do, 63.5 ms; join fetch makes it one, 15.8 ms.
  3. JPA watches the entities it read. Change one and it's updated, save or no save. Don't rewrite entities for display.
  4. Set open-in-view to false and build the screen's shape inside the clerk.
  5. MyBatis's ${} glues strings. Use #{} for values.
  6. Whatever the tool, count the SQL reaching the database.

AraiOpening the SQL log is starting to be fun.

FuruiA good sign. Next time, back to transactions: what happens when you and I change the same claim at the same time? The old-school “last save wins” problem.

References

The recordings were made on 31 October 2026 on a cloud Linux machine (Ubuntu 24.04, kernel 6.18) with Spring Boot 4.1.2 (Hibernate 7.4, Spring Data JPA 4.1, MyBatis 3.5.19, mybatis-spring-boot 4.1.0), OpenJDK 25.0.4 and PostgreSQL 16.15. The claims server and the database server are separate network spaces (network namespaces) inside one machine, about 0.04 ms apart. Times are the median of five runs (the photo is a single run) and vary every run. Line counts exclude blank lines. Every number and output in the text is a real value read from the commands' output at the time. The characters are fictional.

Old-School Backend, part 8 — Database access / 2026.10

← Back to all posts