Old-School Backend / 2026 ・ No.05

Old-School Backend

Part 5: Back to Spring Boot — DI, three layers, and “there is only one bean”

Published

A terminal. Running ./race.sh bad twice reports that 91 and then 99 of 200 claims had their applicant swapped (for example, EXP-2026-2219 was submitted by arai but saved as furui). ./race.sh good reports 0
With a clerk (Service) that keeps the applicant in a field, arai and furui submitting 200 claims at once had the applicant swapped on 91 and 99 of them. The version that takes the applicant as a parameter: 0 (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.)

Part 3 handled HTTP by hand and part 4 the database. Now we return to Spring Boot. Does Spring Boot really take over the things we got wrong by hand? And what new things do you have to watch for when you use it?
We add an experimental counter and clerks to part 1's application and measure DI (dependency injection), the three layers of counter, clerk and storekeeper, “only one bean”, and proxies and @Transactional.

1

Back from bare hands

AraiSpring Boot at last. Now we can leave everything to it.

FuruiHalf of it. The other half is accidents that happen precisely because you use Spring Boot.

AraiIt got more convenient and there are more accidents?

FuruiMore things run where you can't see them. Today we start by counting them.

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

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
  • The roster — 196 beans after startup, 8 of them ours (measured)
  • DI — parts are handed in through the constructor, not built; without Spring the clerk runs in 0.23 s (measured)
  • One bean — keeping the applicant in the clerk's field swapped the applicant on 87–102 of 200 claims (measured)
  • Jackson and Tomcat — none of part 3's or 4's failures happened (measured)
  • Proxies — called from inside the same class, @Transactional did nothing and half a claim remained (measured)

The stage is part 1's three machines. The application is part 1's Spring Boot 4.1.2 (Tomcat 11, Jackson 3, JDK 25) plus an experimental counter (DemoController) and three clerks. The database is PostgreSQL 16 with part 4's applicant column.

2

The roster inside Spring Boot

When a Spring Boot application starts, Spring builds the parts it needs, wires them together and keeps them in one container (the ApplicationContext). The parts are called beans. After startup we had the application count its own beans.

A terminal showing the output of java -jar app.jar --demo.inspect=true: Started ExpensesApplication in 3.787 seconds, a bean count of 196 with 8 written by us, a roster such as expenseService = ExpenseService$$SpringCGLIB$$0 ← [expenseRepository], DataSource as HikariDataSource and JsonMapper as tools.jackson.databind.json.JsonMapper
The running application counting its own beans: 196, of which we wrote 8. The clerk's and storekeeper's names carry $$SpringCGLIB$$0, meaning what was handed out is a proxy, not the real object (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.)
# After startup, a roster of what Spring created (beans); ← shows what each was given
Started ExpensesApplication in 4.12 seconds (process running for 4.83)
Bean の数 196(うち自分で書いたもの 8)
expenseController = ExpenseController ← [expenseService]
demoController = DemoController ← [expenseService, badExpenseService, tripService]
expenseService = ExpenseService$$SpringCGLIB$$0 ← [expenseRepository]
badExpenseService = BadExpenseService ← [expenseRepository]
tripService = TripService$$SpringCGLIB$$0 ← [expenseRepository]
expenseRepository = ExpenseRepository$$SpringCGLIB$$0 ← [jdbcClient]
DataSource : dataSource = com.zaxxer.hikari.HikariDataSource
JdbcClient : jdbcClient = org.springframework.jdbc.core.simple.DefaultJdbcClient
PlatformTransactionManager : transactionManager = org.springframework.jdbc.support.JdbcTransactionManager
JsonMapper : jacksonJsonMapper = tools.jackson.databind.json.JsonMapper
DispatcherServlet : dispatcherServlet = org.springframework.web.servlet.DispatcherServlet
ServletWebServerFactory : tomcatServletWebServerFactory = org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory

The beans one claim passes through, and the helpers

Tomcat receives HTTP DispatcherServlet picks the counter ExpenseController counter (ours) ExpenseService clerk (ours) ExpenseRepository storekeeper (ours) JdbcClient sends SQL JsonMapper Jackson TransactionManager @Transactional DataSource HikariCP 196 beans: 8 we wrote; Spring Boot provided the rest Red arrows: what Spring passed to constructors; dashed: helpers

196 beans. We wrote 8 (the application itself, two counters, three clerks, the storekeeper and the roster-keeper); Spring Boot provided the other 188. The HTTP receiving we did by hand in part 3 is Tomcat's, choosing the counter is DispatcherServlet's, JSON is Jackson 3's JsonMapper. Part 4's connection pool is HikariCP and transactions are JdbcTransactionManager. And the clerk's and storekeeper's names end in an unfamiliar mark, $$SpringCGLIB$$0. Section 8 reveals the trick.

3

DI: don't build it, be handed it

Look at the clerk's code again. It doesn't build the storekeeper (ExpenseRepository) with new; it receives it as a constructor argument.

@Service
public class ExpenseService {
    private static final Logger log = LoggerFactory.getLogger(ExpenseService.class);
    private final ExpenseRepository repo;

    public ExpenseService(ExpenseRepository repo) {      // the storekeeper is handed in, not built here
        this.repo = repo;
    }

    @Transactional
    public String submit(ExpenseRequest req) {
        return submitAs("新井", req);
    }

    @Transactional
    public String submitAs(String applicant, ExpenseRequest req) {   // the applicant arrives as a parameter
        String id = "EXP-2026-%04d".formatted(repo.nextNumber());
        log.info("担当者:受付番号を決めた {} {}", id, applicant);
        repo.insert(id, applicant, req);
        return id;
    }

“Don't build the parts you use; have them handed in from outside.” That is DI (dependency injection). The roster's arrows (←) showed what Spring handed to whom. Written by hand, what Spring does at startup looks like this.

# What Spring does at startup, written by hand
JdbcClient jdbc = …;                                    // from the DataSource (HikariCP)
ExpenseRepository repo = new ExpenseRepository(jdbc);
ExpenseService service = new ExpenseService(repo);
TripService trips = new TripService(repo);             // one storekeeper, shared
ExpenseController controller = new ExpenseController(service);

Disappointingly ordinary Java. Because the parts are handed in, you can hand in different ones. With no Spring and no database, we gave the clerk a fake storekeeper and ran it alone.

# Running the clerk alone, with no Spring and no database (the gist of FakeRun.java)
List<String> written = new ArrayList<>();
ExpenseRepository fake = new ExpenseRepository(null) {          // a fake storekeeper (never touches the DB)
    long n = 12;
    @Override long nextNumber() { return n++; }
    @Override void insert(String id, String applicant, ExpenseRequest r) { written.add(id + " " + applicant + " " + r.item()); }
};
ExpenseService service = new ExpenseService(fake);                 // DI by hand = just pass it to the constructor
String id = service.submitAs("古井", new ExpenseRequest(LocalDate.of(2026, 10, 31), "交通費", new BigDecimal("1280")));
# Runs (three)
返ってきた受付番号 EXP-2026-0012、偽の倉庫番に書かれたもの [EXP-2026-0012 古井 交通費](163.5 ms)  JVM の起動から終わりまで 0.23 s
返ってきた受付番号 EXP-2026-0012、偽の倉庫番に書かれたもの [EXP-2026-0012 古井 交通費](201.7 ms)  JVM の起動から終わりまで 0.25 s
返ってきた受付番号 EXP-2026-0012、偽の倉庫番に書かれたもの [EXP-2026-0012 古井 交通費](184.4 ms)  JVM の起動から終わりまで 0.23 s

0.23–0.25 s from JVM start to finish. Starting Spring Boot alone took 3.6–4.1 s. Had the clerk built its own storekeeper with new, you couldn't try the clerk without a database. DI is also the “easy to test” shape, and part 14's tests rely on it.

POINT

Receive parts in the constructor and keep them in final fields. With a single constructor you don't even need @Autowired. The old habit of putting @Autowired directly on fields leaves no way to hand in parts without Spring, which hurts in tests.

4

Counter, clerk, storekeeper

The three roles we've used since part 1 are what the Spring world calls the “three layers”.

RoleSpring markDoesDoesn't
Counter@RestControllerTranslates HTTP shapes (URL, method, JSON, status) to and from Java valuesBusiness rules, SQL
Clerk@ServiceBusiness rules (reference numbers, amounts, approval order); transaction scopeHTTP matters, SQL text
Storekeeper@RepositoryWrites SQL, talks to the database, translates database exceptions into Spring'sBusiness decisions

They are split because they change for different reasons. When the screen needs a different JSON shape the clerk stays the same; when a table is split the counter stays the same. Being able to try each one alone is thanks to DI plus the layers. If you feel like writing SQL in the counter, that's a sign some clerk's or storekeeper's job has wandered off.

5

One bean: nothing left on the desk

Unless told otherwise, a Spring bean exists once in the whole application (a singleton). The roster's expenseRepository was the very same one handed to all three clerks. Each clerk is one too, so simultaneous requests have different threads using the same clerk at once. So we built a clerk that keeps the applicant in a field, on its desk. In place of a login, the applicant is sent in an X-User header (real login comes in part 11).

# A bad clerk: keeping the applicant in a field
@Service
public class BadExpenseService {
    private final ExpenseRepository repo;
    private String applicant;                             // ← one bean: everyone shares this desk

    public BadExpenseService(ExpenseRepository repo) {
        this.repo = repo;
    }

    public String submit(String who, ExpenseRequest req) {
        this.applicant = who;
        String id = "EXP-2026-%04d".formatted(repo.nextNumber());   // during one round trip to the DB…
        repo.insert(id, this.applicant, req);
        return id;
    }
}
# 200 requests at once (50 at a time), arai and furui alternating, compared with the applicant in the DB (three runs)
field version: 200 件中、申請者が入れ替わったもの 87 件(例:EXP-2026-1002 は furui が出したのに arai)
field version: 200 件中、申請者が入れ替わったもの 102 件(例:EXP-2026-1209 は arai が出したのに furui)
field version: 200 件中、申請者が入れ替わったもの 94 件(例:EXP-2026-1402 は furui が出したのに arai)
parameter version: 200 件中、申請者が入れ替わったもの 0 件
parameter version: 200 件中、申請者が入れ替わったもの 0 件
parameter version: 200 件中、申請者が入れ替わったもの 0 件

One bean: two requests at once share the same desk (measured)

One bean: two requests at once share the same desk (field) arai desk (applicant) furui write arai DB round trip read desk, save arai furui furui write furui time → While arai waits for the DB, furui overwrites the same desk arai's claim is saved under furui's name Measured: 87–102 of 200 swapped; the parameter version, 0

87–102 of 200, nearly half, had their applicant swapped. While arai wrote a name on the desk and went to the database for a reference number, furui overwrote the same desk. Trying one request at a time in a test, it never happens. The clerk that takes the applicant as a parameter scored 0 all three times.

AraiMy claim went in under Furui's name…

FuruiLucky it wasn't the other way round. My sushi bill nearly went in under yours.

POINT

A bean's fields may hold only the parts handed in (final) and settings that never change. Values that differ per request (who, what, how much) travel in parameters and local variables. Like part 2's SimpleDateFormat: anything shared by everyone must not hold state.

6

JSON goes to Jackson

We sent Spring Boot the same claims part 3's regular expression garbled.

# Part 3's slightly mean claims, sent to Spring Boot
## a ふつう
{"status":"submitted","id":"EXP-2026-2201"}
  -> 201
## b 品目に引用符
{"status":"submitted","id":"EXP-2026-2202"}
  -> 201
## c json.dumps(\u)
{"status":"submitted","id":"EXP-2026-2203"}
  -> 201
## d 内訳つき(入れ子の amount)
{"status":"submitted","id":"EXP-2026-2204"}
  -> 201
## e 閉じかっこがない
{"timestamp":"2026-10-30T15:25:47.433Z","status":400,"error":"Bad Request","path":"/api/expenses"}
  -> 400
## f 金額が文字
{"timestamp":"2026-10-30T15:25:47.462Z","status":400,"error":"Bad Request","path":"/api/expenses"}
  -> 400
# What landed in the DB
EXP-2026-2201|交通費|1280.00
EXP-2026-2202|会議費 "A社"|3300.00
EXP-2026-2203|交通費|1280.00
EXP-2026-2204|交通費|1280.00
# In the list reply (GET), the quoted item was written like this
{"id":"EXP-2026-2202","date":"2026-10-31","item":"会議費 \"A社\"","amount":3300.00,"status":"submitted"}

The quoted item went in as 会議費 "A社" and was written back correctly as \". 交通費 came back as 交通費, and the amount stayed ¥1,280 despite the amount inside the breakdown. JSON without its closing brace and an amount written as 千二百八十円 were turned away with 400.

Two things still nag, though. First, Jackson silently dropped receipts, which isn't in the record (ExpenseRequest): the screen thinks it sent a breakdown, and it's kept nowhere. Second, the 400 reply says only “Bad Request”, not what was wrong. Part 6 (input checks) takes the first, part 7 (exceptions and replies) the second.

7

What Tomcat took over

# A broken request, a wrong method, a missing path
## HELLO
HTTP/1.1 400 
Content-Type: text/html;charset=utf-8
Content-Language: en
Content-Length: 435
Date: Fri, 30 Oct 2026 15:25:47 GMT
Connection: close

<!doctype html><html lang="en"><head><title>HTTP Status 400 – Bad Request</title><style>…</style></head><body><h1>HTTP Status 400 – Bad Request</h1></body></html>
## PUT
{"timestamp":"2026-10-30T15:25:47.802Z","status":405,"error":"Method Not Allowed","path":"/api/expenses"}
  -> 405
## ない場所
{"timestamp":"2026-10-30T15:25:47.828Z","status":404,"error":"Not Found","path":"/api/nothing"}
  -> 404
# Headers of the list reply (no length counted up front; sent in chunks)
HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 30 Oct 2026 15:25:47 GMT

Tomcat turned away the meaningless HELLO with a 400 (an HTML page); Spring MVC answered PUT with 405 and the missing path with 404, both in JSON. The list reply has no Content-Length; it has Transfer-Encoding: chunked instead, which skips counting the length up front and labels each chunk with “this many bytes follow”. Part 3's “length written in characters” bug has no way to happen.

# The silent client
黙っている客:60.1 秒後にサーバーが切った(受け取ったバイト 0)
黙っている客がいる間の申請 -> 201 (0.033746 s)

The silent client was dropped by the server after 60.1 seconds. Tomcat's default wait (connectionTimeout) is 60 seconds, and Spring Boot changes it with server.tomcat.connection-timeout. Meanwhile other claims got their 201 in 0.034 s. Tomcat keeps up to 200 workers (threads) and assigns arriving clients to them.

8

Proxies and @Transactional

Now the trick behind the roster's ExpenseService$$SpringCGLIB$$0. For a bean with @Transactional methods, Spring builds a proxy wrapping the real object and hands that out instead. When called, the proxy begins a transaction, calls the real object, and commits on success or rolls back on an exception. The setAutoCommit(false), commit and rollback we wrote by hand in part 4 are done by the proxy. The storekeeper's proxy translates database exceptions into Spring's (not part 4's SQLState, but DataIntegrityViolationException and friends).

We sent part 4's trip claim (a ¥1,000,000 hotel bill too big for the lines column) three ways.

# The trip clerk (the gist of TripService)
@Transactional
public String submit(TripRequest t) {
    log.info("出張:トランザクションの中? {}", TransactionSynchronizationManager.isActualTransactionActive());
    String id = "EXP-2026-%04d".formatted(repo.nextNumber());
    repo.insert(id, "新井", new ExpenseRequest(t.date(), t.item(), t.total()));
    int no = 1;
    for (TripRequest.Line l : t.lines()) repo.insertLine(id, no++, l.item(), l.amount());
    return id;
}

public String submitFromInside(TripRequest t) {     // calls submit from inside the same class
    return submit(t);
}

@Transactional
public String submitChecked(TripRequest t) throws ReceiptMissingException {
    String id = submit(t);
    throw new ReceiptMissingException("領収書の画像がない " + id);   // a checked exception
}
A terminal. The top pane is the application log: Creating new transaction, 出張:トランザクションの中? true, Rolling back JDBC transaction, PSQLException: numeric field overflow, then 出張:トランザクションの中? false. In the bottom pane ./trips.sh tx leaves 0 header rows and 0 lines, ./trips.sh inside leaves 1 header row and 1 line
The same submit, called through the proxy (tx), ran in a transaction and rolled everything back; called from inside the same class (inside), it ran without one and left half a claim (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.)
# Shinkansen ¥12,860 + hotel ¥1,000,000 (too big for the lines column); in checked the lines are fine and a checked exception comes last
## mode=tx
  -> 500
  残ったもの:expense 0 / expense_line 0
## mode=inside
  -> 500
  残ったもの:expense 1 / expense_line 1
## mode=checked(明細は正しい。最後にチェック例外)
  -> 500
  残ったもの:expense 1 / expense_line 2
# Application log (excerpt)
15:26:47.982 Creating new transaction with name [example.expenses.TripService.submit]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
15:26:47.983 出張:トランザクションの中? true
15:26:47.987 倉庫番:1行書いた EXP-2026-2206
15:26:47.992 倉庫番:明細を書いた EXP-2026-2206 1
15:26:48.004 Rolling back JDBC transaction on Connection [HikariProxyConnection@1350144235 wrapping org.postgresql.jdbc.PgConnection@59b65dce]
15:26:48.005 Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springf
org.postgresql.util.PSQLException: ERROR: numeric field overflow
15:26:48.170 出張:トランザクションの中? false
15:26:48.173 倉庫番:1行書いた EXP-2026-2207
15:26:48.174 倉庫番:明細を書いた EXP-2026-2207 1
15:26:48.177 Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springf
org.postgresql.util.PSQLException: ERROR: numeric field overflow
15:26:48.308 Creating new transaction with name [example.expenses.TripService.submitChecked]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
15:26:48.309 出張:トランザクションの中? true
15:26:48.310 倉庫番:1行書いた EXP-2026-2208
15:26:48.311 倉庫番:明細を書いた EXP-2026-2208 1
15:26:48.312 倉庫番:明細を書いた EXP-2026-2208 2
15:26:48.312 Committing JDBC transaction on Connection [HikariProxyConnection@1851115318 wrapping org.postgresql.jdbc.PgConnection@59b65dce]
15:26:48.315 Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: example.exp
example.expenses.ReceiptMissingException: 領収書の画像がない EXP-2026-2208

Through the proxy, it rolls back; around it, it's just a method (measured)

Controller proxy TripService$$SpringCGLIB$$0 ① begin transaction ③ commit / rollback the real TripService submit() submitFromInside() this.submit() Called from inside the class, the proxy is skipped mode=tx through the proxy → rollback, 0 rows left mode=inside proxy skipped → 1 header, 1 line left mode=checked checked exception → commit, all 3 rows kept What DI hands you may be a proxy wrapping the real object Calls via this skip the proxy; checked exceptions don't roll back
  • tx — the counter calls submit through the proxy; it runs in a transaction and the failure rolls everything back: 0 rows
  • inside — the counter calls submitFromInside, which calls this.submit(). this is the real object, so the proxy is skipped. The log says “トランザクションの中? false” (in a transaction? false), and part 4's half claim is back
  • checked — after everything was written, a checked exception (a subclass of Exception) said the receipt was missing. By default Spring doesn't roll back on checked exceptions; it commits. All three rows stayed

@Transactional isn't magic that works wherever you put it; it's a promise that holds only when the call goes through the proxy. Put work that should be transactional in a separate bean and call it from outside. To roll back on checked exceptions too, write rollbackFor or use a runtime exception. Part 9 comes back to transactions with two people changing the same claim at once.

9

Where the barehanded jobs went

What we got wrong by hand in parts 3–4, on Spring Boot (measured)

What we tried By hand (3–4) Spring Boot (measured) Who Quotes in the item × 会議費 \ 会議費 "A社" Jackson \u escapes × stored as is 交通費 Jackson Nested amount × ¥780 ¥1,280, lines dropped Jackson Broken JSON, ¥ as text (not tried) 400 Spring MVC A lone HELLO × 404 400 Tomcat PUT ○ 405 by hand 405 Spring MVC A silent client × all stall dropped at 60.1 s Tomcat Fails mid-lines × half remains 0 rows, rolled back @Transactional None of part 3's or 4's failures happened on Spring Boot Jackson silently drops unknown fields (receipts) by default; part 6

None of part 3's or part 4's failures happened on Spring Boot. HTTP is Tomcat's, choosing the counter Spring MVC's, JSON Jackson's, connections HikariCP's, and rolling back the proxy's. Because we did the jobs by hand, each name now means “oh, that job”. In exchange there are two promises peculiar to Spring: one bean and proxies.

10

Checking on your own PC

What to tryCommand or code
Generate a project (a browser works too)https://start.spring.io
Run without packaging./mvnw spring-boot:run
What auto-configuration built, and didn'tjava -jar app.jar --debug
Is what you were handed a proxy?log.info("{}", service.getClass());
Are we in a transaction right now?TransactionSynchronizationManager.isActualTransactionActive()
How long until silent clients are droppedserver.tomcat.connection-timeout=20s

With --debug, startup prints a “CONDITIONS EVALUATION REPORT” listing Spring Boot's decisions, such as building a DataSource because a JDBC driver was present. It's the way in when you wonder why a bean exists.

§ — On one page, and next time

  1. Spring Boot builds beans at startup and wires them: 196 in this part's application, 8 of them ours.
  2. DI: receive parts in the constructor and keep them final. Hand in a fake and you can test without Spring.
  3. Counter (HTTP shapes), clerk (business rules and transactions), storekeeper (SQL): split by reason to change.
  4. One bean. Per-request values in a field swapped the applicant (87–102 of 200).
  5. Tomcat, Spring MVC, Jackson and HikariCP took over parts 3 and 4's failures, but unknown fields get silently dropped.
  6. @Transactional works only through the proxy: not via this, and checked exceptions commit.

AraiThe breakdown being silently dropped quietly bothers me.

FuruiGood eye. Next time, input checks: empty items, negative amounts, full-width digits, unknown fields. We decide what to check, and how far, before the counter.

References

The recordings were made on 30–31 October 2026 on a cloud Linux machine (Ubuntu 24.04, kernel 6.18) with Spring Boot 4.1.2 (Tomcat 11, Jackson 3), OpenJDK 25.0.4 and PostgreSQL 16.15. The PC, the claims server and the database server are separate network spaces (network namespaces) inside one machine. The number of swapped applicants and the startup times vary every run. 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 5 — Back to Spring Boot / 2026.10

← Back to all posts