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
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.
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)
- 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.
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.
# 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
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.
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.
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.
Counter, clerk, storekeeper
The three roles we've used since part 1 are what the Spring world calls the “three layers”.
| Role | Spring mark | Does | Doesn't |
|---|---|---|---|
| Counter | @RestController | Translates HTTP shapes (URL, method, JSON, status) to and from Java values | Business rules, SQL |
| Clerk | @Service | Business rules (reference numbers, amounts, approval order); transaction scope | HTTP matters, SQL text |
| Storekeeper | @Repository | Writes SQL, talks to the database, translates database exceptions into Spring's | Business 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.
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)
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.
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.
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.
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.
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
}
# 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)
- 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.
Where the barehanded jobs went
What we got wrong by hand in parts 3–4, on Spring Boot (measured)
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.
Checking on your own PC
| What to try | Command 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't | java -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 dropped | server.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
- Spring Boot builds beans at startup and wires them: 196 in this part's application, 8 of them ours.
- DI: receive parts in the constructor and keep them final. Hand in a fake and you can test without Spring.
- Counter (HTTP shapes), clerk (business rules and transactions), storekeeper (SQL): split by reason to change.
- One bean. Per-request values in a field swapped the applicant (87–102 of 200).
- Tomcat, Spring MVC, Jackson and HikariCP took over parts 3 and 4's failures, but unknown fields get silently dropped.
- @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
- Spring Framework — Dependency Injection
- Spring Framework — Bean Scopes(singleton)
- Spring Framework — Using @Transactional(proxy と self-invocation)
- Spring Framework 7.1 — Rolling Back a Declarative Transaction
- Spring Boot Reference Documentation
- Apache Tomcat 11 — The HTTP Connector(connectionTimeout、maxThreads)
- Jackson Project Home(FasterXML)
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