Old-School Backend / 2026 ・ No.05
いにしえのバックエンド入門
第5回 Spring Boot に戻る ── DI、3層、そして「Bean は1つ」
公開
第3回で HTTP を、第4回で DB を、素手で扱いました。今回は Spring Boot に戻ります。素手で間違えたことは、本当に Spring Boot が引き受けてくれるのか。そして Spring Boot を使うときに、新しく気をつけることは何か。
第1回のアプリに実験用の窓口と担当者を足し、DI(依存性の注入)、窓口・担当者・倉庫番の3層、Bean は1つ、代理人と @Transactional を実測します。
素手から戻る
新井やっと Spring Boot です。これで全部おまかせですね。
古井半分はな。残りの半分は、Spring Boot を使うからこそ起きる事故だ。
新井便利になったのに、事故が増えるんですか。
古井見えないところで動くものが増えるからだ。今日は、その見えないものを数えるところから始める。
全15回の路線図(いまここ:第5回)
- 名簿 ── 起動すると Bean が 196 個。自分で書いたのは 8 個(実測)
- DI ── 部品は自分で作らず、コンストラクターで渡してもらう。Spring なしでも 0.23 秒で動かせる(実測)
- Bean は1つ ── 担当者のフィールドに申請者を置くと、200 件中 87〜102 件で申請者が入れ替わった(実測)
- Jackson と Tomcat ── 第3・4回の失敗は、どれも起きなかった(実測)
- 代理人 ── 同じクラスの中から呼ぶと @Transactional が効かず、半分だけの申請が残った(実測)
舞台は第1回と同じ3台です。アプリは第1回の Spring Boot 4.1.2(Tomcat 11、Jackson 3、JDK 25)に、実験用の窓口(DemoController)と担当者を3つ足しました。DB は第4回で申請者の列を足した PostgreSQL 16 です。
Spring Boot の中の名簿
Spring Boot のアプリが起動すると、Spring は必要な部品を作り、つなぎ合わせて、1つの入れ物(ApplicationContext)にしまいます。この部品を Bean と呼びます。起動のあとで、アプリ自身に Bean を数えさせました。
# 起動したあと、Spring が作ったもの(Bean)の名簿(← は、そのBean に渡したもの) 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
申請1件が通る Bean と、脇役たち
Bean は 196 個。自分で書いたのは 8 個(アプリ本体、窓口2つ、担当者3つ、倉庫番、名簿係)で、残りの 188 個は Spring Boot が用意しました。第3回で素手でやった HTTP の受け取りは Tomcat、窓口選びは DispatcherServlet、JSON は Jackson 3 の JsonMapper。第4回の接続の池は HikariCP、トランザクションは JdbcTransactionManager です。そして、担当者と倉庫番の名前の後ろには $$SpringCGLIB$$0 という見慣れない印が付いています。これは第八章で種明かしします。
DI:自分で作らず、渡してもらう
担当者のコードをもう一度見ます。倉庫番(ExpenseRepository)を new で作らず、コンストラクターの引数で受け取っています。
@Service
public class ExpenseService {
private static final Logger log = LoggerFactory.getLogger(ExpenseService.class);
private final ExpenseRepository repo;
public ExpenseService(ExpenseRepository repo) { // 倉庫番は、自分で作らずに渡してもらう
this.repo = repo;
}
@Transactional
public String submit(ExpenseRequest req) {
return submitAs("新井", req);
}
@Transactional
public String submitAs(String applicant, ExpenseRequest req) { // 申請者は引数で受け取る
String id = "EXP-2026-%04d".formatted(repo.nextNumber());
log.info("担当者:受付番号を決めた {} {}", id, applicant);
repo.insert(id, applicant, req);
return id;
}
「使う部品を自分で作らず、外から渡してもらう」。これが DI(Dependency Injection、依存性の注入)です。名簿の矢印(←)は、Spring がだれに何を渡したかでした。Spring が起動のときにやっていることを手で書くと、こうなります。
# Spring が起動のときにやっていることを、手で書くと JdbcClient jdbc = …; // DataSource(HikariCP)から ExpenseRepository repo = new ExpenseRepository(jdbc); ExpenseService service = new ExpenseService(repo); TripService trips = new TripService(repo); // 倉庫番は1つを使い回す ExpenseController controller = new ExpenseController(service);
拍子抜けするほど普通の Java です。渡してもらう形にしておくと、渡すものを替えられます。Spring も DB も使わず、偽の倉庫番を渡して担当者だけを動かしました。
# Spring も DB も使わずに、担当者だけを動かす(FakeRun.java の要点)
List<String> written = new ArrayList<>();
ExpenseRepository fake = new ExpenseRepository(null) { // 偽の倉庫番(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 を手でやる=コンストラクターに渡すだけ
String id = service.submitAs("古井", new ExpenseRequest(LocalDate.of(2026, 10, 31), "交通費", new BigDecimal("1280")));
# 実行(3回)
返ってきた受付番号 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
JVM の起動から終わりまで 0.23〜0.25 秒。Spring Boot を起動すると、それだけで 3.6〜4.1 秒かかりました。担当者が倉庫番を自分で new していたら、DB なしでは担当者を試せません。DI は「テストしやすい形」でもあり、第14回のテストはこの形を前提にします。
部品は コンストラクターで受け取り、final のフィールドに入れる。コンストラクターが1つなら @Autowired も要りません。いにしえのコードによくある、フィールドに直接 @Autowired を付ける書き方は、Spring なしでは部品を渡す口がなく、テストで困ります。
窓口・担当者・倉庫番
第1回から使っている3つの役は、Spring の世界では「3層」と呼ばれる分け方です。
| 役 | Spring の印 | やること | やらないこと |
|---|---|---|---|
| 窓口 | @RestController | HTTP の形(URL、メソッド、JSON、状態コード)を、Java の値と行き来させる | 業務のきまり、SQL |
| 担当者 | @Service | 業務のきまり(受付番号、金額の計算、承認の順番)。トランザクションの範囲 | HTTP のこと、SQL の文 |
| 倉庫番 | @Repository | SQL を書き、DB と話す。DB の例外を Spring の例外に訳す | 業務の判断 |
分ける理由は、変わる理由が違うからです。画面の都合で JSON の形が変わっても担当者は変わらず、DB の表を分けても窓口は変わりません。それぞれを1人で試せるのも、DI と3層の組み合わせのおかげです。窓口に SQL を書きたくなったら、それは担当者か倉庫番の仕事が迷子になっている合図です。
Bean は1つ:机の上に置かない
Spring の Bean は、特に指定しなければアプリ全体で1つ(シングルトン)です。名簿の expenseRepository は、3人の担当者に同じ1つが渡されていました。担当者も1つで、同時に来た頼みは、別々のスレッドが同じ担当者を同時に使います。そこで、申請者を担当者のフィールド(机の上)に置く担当者を作りました。ログインの代わりに、申請者を X-User ヘッダーで送ります(本物のログインは第11回)。
# だめな担当者:申請者をフィールドに置く
@Service
public class BadExpenseService {
private final ExpenseRepository repo;
private String applicant; // ← Bean は1つ。全員が同じ机を使う
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()); // DB と1往復するあいだに……
repo.insert(id, this.applicant, req);
return id;
}
}
# 200 件を同時に(50 本ずつ)、arai と furui が交互に申請。DB の申請者と比べる(3回) フィールドに置く版:200 件中、申請者が入れ替わったもの 87 件(例:EXP-2026-1002 は furui が出したのに arai) フィールドに置く版:200 件中、申請者が入れ替わったもの 102 件(例:EXP-2026-1209 は arai が出したのに furui) フィールドに置く版:200 件中、申請者が入れ替わったもの 94 件(例:EXP-2026-1402 は furui が出したのに arai) 引数で受け取る版:200 件中、申請者が入れ替わったもの 0 件 引数で受け取る版:200 件中、申請者が入れ替わったもの 0 件 引数で受け取る版:200 件中、申請者が入れ替わったもの 0 件
Bean は1つ。同時に来た2人が、同じ机を使う(実測)
200 件中 87〜102 件、ほぼ半分で申請者が入れ替わりました。arai が机に名前を書き、DB に受付番号を聞きに行っているあいだに、furui が同じ机を書きかえるからです。テストで1件ずつ試しているうちは、決して起きません。申請者を引数で受け取る担当者は、3回とも 0 件でした。
新井私の申請が、古井さんの名前になってる……。
古井逆でなくてよかったな。寿司代が君の名前になるところだった。
Bean のフィールドに置いてよいのは、渡された部品(final)と、変わらない設定だけ。頼みごとごとに違う値(だれが、何を、いくら)は、引数とローカル変数で運びます。第2回の SimpleDateFormat と同じく、「1つを大勢で使う」ものに状態を持たせない。
JSON は Jackson に
第3回で正規表現が化けさせた申請を、そのまま Spring Boot に送りました。
# 第3回と同じ、ちょっと意地悪な申請を 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
# DB に入ったもの
EXP-2026-2201|交通費|1280.00
EXP-2026-2202|会議費 "A社"|3300.00
EXP-2026-2203|交通費|1280.00
EXP-2026-2204|交通費|1280.00
# 一覧の返事(GET)で、引用符入りの品目はこう書かれた
{"id":"EXP-2026-2202","date":"2026-10-31","item":"会議費 \"A社\"","amount":3300.00,"status":"submitted"}
引用符入りの品目は 会議費 "A社" のまま入り、返事では \" と正しく書かれました。交通費 は「交通費」に戻り、内訳の amount に惑わされず金額は 1280 円。閉じかっこのない JSON と、金額が「千二百八十円」の申請は 400 で断られました。
ただし、2つ気になる点があります。1つは、記録(ExpenseRequest)にない receipts を、Jackson が黙って捨てたこと。画面の人は内訳を送ったつもりでも、どこにも残りません。もう1つは、400 の返事が「Bad Request」だけで、どこが悪いのか分からないこと。前者は第6回の入力チェックで、後者は第7回の「例外と返事」で扱います。
Tomcat が引き受けたこと
# 壊れた頼みごと、ないメソッド、ない場所
## 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
# 一覧の返事のヘッダー(長さは先に数えず、区切って送る)
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 30 Oct 2026 15:25:47 GMT
意味のない HELLO は Tomcat が 400(HTML のページ)で断り、PUT には Spring MVC が 405、ない場所には 404 を JSON で返しました。一覧の返事には Content-Length がなく、代わりに Transfer-Encoding: chunked。長さを先に数えず、区切りごとに「ここから何バイト」と書いて送る方法で、第3回の「文字の数で長さを書く」バグは、そもそも起きようがありません。
# 黙っている客 黙っている客:60.1 秒後にサーバーが切った(受け取ったバイト 0) 黙っている客がいる間の申請 -> 201 (0.033746 s)
黙っている客は、60.1 秒でサーバーから切られました。Tomcat の既定の待ち時間(connectionTimeout)は 60 秒で、Spring Boot では server.tomcat.connection-timeout で変えられます。その間も、ほかの申請は 0.034 秒で 201。Tomcat は作業員(スレッド)を最大 200 人まで用意して、来た客を割り当てています。
代理人と @Transactional
名簿の ExpenseService$$SpringCGLIB$$0 の種明かしです。Spring は、@Transactional が付いたメソッドを持つ Bean の代わりに、本物を包んだ代理人(プロキシ)を作って渡します。代理人は、呼ばれたらトランザクションを始め、本物を呼び、成功なら commit、例外なら rollback します。第4回で手で書いた setAutoCommit(false)、commit、rollback を、代理人が肩代わりしているのです。倉庫番の代理人は、DB の例外を Spring の例外(第4回の SQLState ではなく DataIntegrityViolationException など)に訳す係です。
第4回の出張の申請(ホテル代 1,000,000 円が明細の列に入らない)を、3つの呼び方で送りました。
# 出張の担当者(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) { // 同じクラスの中から submit を呼ぶ
return submit(t);
}
@Transactional
public String submitChecked(TripRequest t) throws ReceiptMissingException {
String id = submit(t);
throw new ReceiptMissingException("領収書の画像がない " + id); // チェック例外
}
# 新幹線 12,860 円+ホテル 1,000,000 円(明細の列に入らない)。checked は明細が正しく、最後にチェック例外 ## mode=tx -> 500 残ったもの:expense 0 / expense_line 0 ## mode=inside -> 500 残ったもの:expense 1 / expense_line 1 ## mode=checked(明細は正しい。最後にチェック例外) -> 500 残ったもの:expense 1 / expense_line 2 # アプリのログ(抜き出し) 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
代理人を通れば巻き戻る。通らなければ、ただのメソッド(実測)
- tx ── 窓口から代理人を通って submit。トランザクションの中で動き、失敗で全部巻き戻って 0 行
- inside ── 窓口から submitFromInside を呼び、その中から this.submit()。this は本物の自分なので、代理人を通らない。ログは「トランザクションの中? false」で、第4回と同じ半分だけの申請が残った
- checked ── 全部書けたあと、領収書がないというチェック例外(Exception の子)を投げた。Spring は既定で、チェック例外では巻き戻さず commit する。3 行とも残った
@Transactional は、付ければ効く魔法ではなく、代理人を通って呼ばれたときだけ効く約束です。トランザクションの範囲にしたい処理は別の Bean に分けて、外から呼ぶ。チェック例外でも巻き戻したいなら rollbackFor を書くか、実行時例外にする。同時に2人が同じ申請を書きかえる話は、第9回でもう一度トランザクションに戻ってきます。
素手の仕事は、どこへ行ったか
第3・4回で素手でやって間違えたことを、Spring Boot で(実測)
第3回と第4回の失敗は、Spring Boot ではどれも起きませんでした。HTTP は Tomcat、窓口選びは Spring MVC、JSON は Jackson、接続は HikariCP、巻き戻しは代理人。素手でやったからこそ、それぞれの名前を聞いたときに「あの仕事だ」と分かります。その代わりに、Bean は1つと代理人という、Spring ならではの約束が増えました。
自分の PC で確かめる
| 試したいこと | コマンド・書き方 |
|---|---|
| 雛形を作る(ブラウザでも可) | https://start.spring.io |
| ビルドせずに動かす | ./mvnw spring-boot:run |
| 自動設定が何を作り、何を作らなかったか | java -jar app.jar --debug |
| 渡されたものが代理人か | log.info("{}", service.getClass()); |
| いまトランザクションの中か | TransactionSynchronizationManager.isActualTransactionActive() |
| 黙っている客を切るまでの時間 | server.tomcat.connection-timeout=20s |
--debug を付けると、起動時に「CONDITIONS EVALUATION REPORT」が出ます。DataSource が作られたのは JDBC ドライバーがあったから、といった Spring Boot の判断が一覧になり、「なぜこの Bean があるのか」を調べる入口になります。
結 — 一枚まとめと、次回
- Spring Boot は起動時に Bean を作ってつなぐ。この回のアプリで 196 個、自分で書いたのは 8 個。
- DI:部品はコンストラクターで受け取り、final に入れる。偽物を渡せば Spring なしでも試せる。
- 窓口(HTTP の形)・担当者(業務のきまりとトランザクション)・倉庫番(SQL)。変わる理由で分ける。
- Bean は1つ。頼みごとごとの値をフィールドに置くと、申請者が入れ替わった(200 件中 87〜102 件)。
- 第3・4回の失敗は、Tomcat・Spring MVC・Jackson・HikariCP が引き受けた。ただし知らない項目は黙って捨てられる。
- @Transactional は代理人を通ったときだけ効く。this から呼ぶと効かず、チェック例外では commit される。
新井内訳を黙って捨てられたのが、地味に気になります。
古井いい目だ。次回は入力チェック。空の品目、マイナスの金額、全角の数字、知らない項目。窓口の手前で、何をどこまで確かめるかを決めよう。
参考
- 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)
記録は、筆者がクラウド上の Linux(Ubuntu 24.04、カーネル 6.18)で、Spring Boot 4.1.2(Tomcat 11、Jackson 3)、OpenJDK 25.0.4、PostgreSQL 16.15 を使って 2026年10月30日〜31日に取ったものです。PC・申請サーバー・DB サーバーは、1台の中に作った別々のネットワーク空間(network namespace)です。申請者の入れ替わりの件数と起動時間は、動かすたびに変わります。表の数字や出力はすべて、そのときのコマンドの出力から読んだ実際の値です。登場人物は架空の人物です。
いにしえのバックエンド入門 第5回 — Spring Boot に戻る / 2026.10