Old-School Backend / 2026 ・ No.10
いにしえのバックエンド入門
第10回 状態遷移 ── 承認済みの申請を、だれが直せるのか
公開
経費精算の申請は、下書き → 申請中 → 承認 → 支払済み と進みます。この「状態」を、ただの文字の列として扱うと、承認済みの申請の金額が変わり、ありえない状態が DB に入り、2人の承認者が同時に押すと履歴が食い違います。
今回は状態と操作を表にし、MyBatis の where と DB の CHECK で守り、履歴を残します。どこで何が止まるかを、6×5 の全組み合わせと同時押しで実測しました。
承認済みの申請を直せる
新井前回の版番号で、同時に直しても安全になりましたよね。
古井同時はね。では、私が承認したあとの申請を、新井くんが直したら?
新井……版番号さえ合っていれば、通っちゃいます。
古井試してみよう。
全15回の路線図(いまここ:第10回)
# 承認済みの申請(EXP-2026-5002)を、第9回の窓口(版番号つき)で直す GET /api/v9/claims/EXP-2026-5002 → 200 amount=24,160 version=0 (DB status=approved) PUT /api/v9/claims/EXP-2026-5002 amount=34,160 → 200 DB status|item|amount|version = approved|出張-002|34160.00|1
承認済みの出張の申請(24,160 円)を、前回の窓口で開き、版番号を合わせて 10,000 円足して保存しました。200 が返り、DB は「承認済みで 34,160 円」になりました。承認したのは 24,160 円の申請なのに、です。
- 状態を文字のまま ── 打ち間違いの「aproved」が 500 を返しながら DB に入った(実測)
- 状態遷移表 ── 6 つの状態 × 5 つの操作、30 通り中 6 通りだけを許す(実測)
- 同時押し ── 承認と差戻しを同時に送ると、素朴版は 200 が 3〜9 件(実測)
- 守る段 ── 画面・窓口・担当者・SQL の where・DB の CHECK
アプリは前回の続きで、MyBatis で書いています。DB には、状態の履歴の表 expense_history を足しました。操作する人は、第5回と同じく X-User ヘッダーで名乗ります(arai・furui・keiri=経理)。
状態を文字のまま持つ
まず、ありがちな書き方です。状態は status 列の文字。画面から届いた文字を、そのまま入れます。
# 状態を文字のまま受け取る窓口と担当者(素朴版)
@PutMapping("/api/v10/naive/claims/{id}/status") // 状態を文字で受け取る(素朴版)
public ClaimView10 setStatus(@PathVariable String id, @RequestBody Map<String, String> body,
@RequestHeader("X-User") String actor) {
return ClaimView10.of(naive.setStatus(id, body.get("status"), actor));
}
@Transactional // 届いた文字を、そのまま状態にする
public ClaimState setStatus(String id, String status, String actor) {
ClaimState cur = states.find(id);
if (cur == null) throw new ExpenseNotFoundException(id);
states.setStatus(id, status);
states.insertHistory(id, cur.status(), status, actor);
return states.find(id);
}
<update id="setStatus">
update expense set status = #{to} where id = #{id}
</update>
# 届いた文字をそのまま状態にする(EXP-2026-5003) "aproved" → 500 DB status = aproved "承認" → 500 DB status = 承認 "paid" → 200 DB status = paid # 状態ごとの件数(ほかの2件にも aproved と 承認 を入れたあと) approved|1 aproved|1 paid|1 submitted|122 承認|1 # 500 の中身(アプリのログ) java.lang.IllegalArgumentException: No enum constant example.expenses.v10.ClaimStatus.APROVED
「aproved」(p が1つ足りない)と「承認」は、どちらも 500 を返したのに、DB には入っていました。担当者のトランザクションはコミット済みで、そのあと窓口が返事を作るときに、知らない状態を enum に直せず落ちたからです。画面には「エラー」と出て、DB には打ち間違いが残る。いちばん気づきにくい形です。
「paid」は 200 でした。申請中の申請が、承認を飛ばして支払済みになっています。文字の列は、どんな文字でも、どんな順番でも受け入れます。
状態遷移表
状態は6つ、操作は5つと決めました。どの状態から、どの操作で、どの状態へ進めるか。それを1枚の図にします。
申請の状態と操作
この図を、そのまま Java の enum にします。操作ごとに「行き先」と「出発できる状態」を書くだけです。窓口は URL の approve を Action.APPROVE に変えて受け取ります。
# 状態と操作を enum にする(ClaimStatus.java と Action.java)
public enum ClaimStatus {
DRAFT, SUBMITTED, APPROVED, RETURNED, REJECTED, PAID;
…
}
/** 状態遷移表:操作ごとに「どの状態から」「どの状態へ」 */
public enum Action {
SUBMIT(SUBMITTED, DRAFT, RETURNED), // 申請する(下書き・差戻しから)
APPROVE(APPROVED, SUBMITTED), // 承認する
RETURN(RETURNED, SUBMITTED), // 差し戻す
REJECT(REJECTED, SUBMITTED), // 却下する
PAY(PAID, APPROVED); // 支払う
…
public boolean allowedFrom(ClaimStatus s) {
return from.contains(s);
}
}
6 つの状態 × 5 つの操作、30 通りを全部試しました。
# 6 つの状態 × 5 つの操作(EXP-2026-5004、毎回状態を置きなおして) 今の状態 \ 操作 submit approve return reject pay draft 200 → submitted 409 409 409 409 submitted 409 200 → approved 200 → returned 200 → rejected 409 returned 200 → submitted 409 409 409 409 approved 409 409 409 409 200 → paid rejected 409 409 409 409 409 paid 409 409 409 409 409
200 になったのは表に書いた 6 通りだけで、残りの 24 通りは 409 でした。支払済みと却下からは何もできず、承認済みからは支払いだけ。「できること」を列挙し、それ以外はすべて断るのが、状態遷移表の考え方です。
遷移は where で守る
遷移表で確かめても、まだ穴があります。古井さんと経理が同じ申請を見ていて、古井さんは承認、経理は差戻しを、同時に押したら? 第9回と同じ「読んで、確かめて、書く」の競争です。
# 素朴版:遷移表で確かめるが、書くときは id だけ
@Transactional // 遷移表で確かめるが、書くときは確かめない
public ClaimState act(String id, Action action, String actor) {
ClaimState cur = states.find(id);
if (cur == null) throw new ExpenseNotFoundException(id);
ClaimStatus from = ClaimStatus.fromDb(cur.status());
if (!action.allowedFrom(from)) throw new IllegalTransitionException(id, from, action);
states.setStatus(id, action.to().db());
states.insertHistory(id, from.db(), action.to().db(), actor);
return states.find(id);
}
# where でも確かめる版(ClaimFlowService.java と StatusMapper.xml)
@Transactional
public ClaimState act(String id, Action action, String actor) {
ClaimStatus from = ClaimStatus.fromDb(find(id).status());
if (!action.allowedFrom(from)) throw new IllegalTransitionException(id, from, action);
if (states.changeStatus(id, from.db(), action.to().db()) == 0) { // 読んだあとで、だれかが状態を変えた
throw new IllegalTransitionException(id, ClaimStatus.fromDb(find(id).status()), action);
}
states.insertHistory(id, from.db(), action.to().db(), actor); // 同じトランザクションで履歴も
return find(id);
}
<update id="changeStatus">
update expense set status = #{to}, version = version + 1
where id = #{id} and status = #{from}
</update>
# 申請中の1件に、承認 50 件(furui)と差戻し 50 件(keiri)を同時に(各3回)
やり方 承認 200 差戻し 200 最後の状態 履歴の行
naive 3 2 returned 5
2 1 approved 3
4 5 returned 9
guarded 0 1 returned 1
0 1 returned 1
0 1 returned 1
# 素朴版の3回目の履歴(はじめの 6 行)
submitted→approved furui
submitted→approved furui
submitted→approved furui
submitted→returned keiri
submitted→returned keiri
submitted→returned keiri
素朴版は、承認と差戻しの両方に 200 を返しました(3回で 5・3・9 件)。履歴には「申請中から承認」と「申請中から差戻し」が並び、最後の状態は回によって承認だったり差戻しだったりします。承認した古井さんの画面には「承認しました」と出ているのに、申請は差戻しになっている、ということが起きます。
同時押しで起きていること
where 版は、update に and status = #{from} を足しただけです。先に書いた1件だけが行に当たり、あとの人は 0 行になって 409。3回とも 200 はちょうど1件、履歴も1行でした(この3回は差戻しが先でしたが、どちらが先かは回によって変わります)。
中身を変えられる状態
第一章の「承認済みを直せる」に戻ります。中身(品目や金額)を変えてよいのは、下書きと差戻しのときだけ。これも update の where に入れます。状態が2つあるので、MyBatis の <foreach> で in (…) を組み立てました。
# 中身を変えられる状態を where に入れる(foreach)
<update id="updateIfEditable">
update expense set item = #{item}, amount = #{amount}, version = version + 1
where id = #{id} and version = #{version}
and status in <foreach collection="editable" item="s" open="(" separator="," close=")">#{s}</foreach>
</update>
@Transactional // 中身を変えられるのは、下書きと差戻しのときだけ
public ClaimState edit(String id, EditRequest req) {
if (states.updateIfEditable(id, req.item(), req.amount(), req.version(), EDITABLE) == 0) {
ClaimState c = find(id);
if (!EDITABLE.contains(c.status())) throw new NotEditableException(id, ClaimStatus.fromDb(c.status()));
throw new StaleClaimException(id);
}
return find(id);
}
# 同じ承認済みの申請を、第10回の窓口で直そうとすると
PUT /api/v10/claims/EXP-2026-5002 → 409 この申請は変更できません(受付番号 EXP-2026-5002 は今 approved です)
0 行だったときは、もう一度読んで理由を分けます。状態が理由なら「この申請は変更できません」、版番号が理由なら前回の「ほかの人が先に変更しました」。どちらも 409 ですが、画面に出す言葉が変わります。
新井承認のあとで金額をまちがえたことに気づいたら、どうするんですか。
古井承認者に差し戻してもらう。直す道は、いつも表の上にあるべきだ。裏口を作ると、それが一番よく使われる。
履歴を残す
状態が変わるたびに、「どの状態から、どの状態へ、だれが、いつ」を1行ずつ残します。状態の update と同じトランザクションで書くのが大事です。状態だけ変わって履歴がない、ということが起きません。
# 履歴の表(setup10.sql)と、書く SQL
-- 第10回:状態の履歴
create table if not exists expense_history (
no bigserial primary key,
expense_id varchar(20) not null references expense(id),
from_status varchar(20) not null,
to_status varchar(20) not null,
actor varchar(50) not null,
at timestamptz not null default now()
);
<insert id="insertHistory">
insert into expense_history (expense_id, from_status, to_status, actor)
values (#{id}, #{from}, #{to}, #{actor})
</insert>
# 下書きから支払いまで(EXP-2026-5006) 新井 submit 200 submitted actions=['approve', 'return', 'reject'] 古井 return 200 returned actions=['submit'] 新井 edit 200 returned amount=25,660 新井 submit 200 submitted actions=['approve', 'return', 'reject'] 古井 approve 200 approved actions=['pay'] 新井 edit 409 approved この申請は変更できません 経理 pay 200 paid actions=[] 古井 return 409 paid actions=[] # GET /api/v10/claims/EXP-2026-5006/history draft → submitted arai 00:51:29.164Z submitted → returned furui 00:51:29.178Z returned → submitted arai 00:51:29.221Z submitted → approved furui 00:51:29.234Z approved → paid keiri 00:51:29.255Z
差戻しのあと、新井さんはタクシー代 1,500 円を足して再申請できました。承認のあとの変更は 409、支払済みへの差戻しも 409。履歴には5行、申請の一生がそのまま残っています。経理や監査が「この申請はいつ、だれが承認したのか」と聞いてきたら、この表を見せます。
画面のボタンと 409
画面は、申請を開いたときの状態でボタンを出します。返事に「押せる操作」(actions)を付けておけば、画面は遷移表を知らなくても、正しいボタンだけを出せます。でも、開いている間に状態は変わります。
# 古井さんと経理が同じ申請を開き、経理が先に差し戻す(EXP-2026-5007)
古井 open 200 status=submitted actions=['approve', 'return', 'reject']
経理 open 200 status=submitted actions=['approve', 'return', 'reject']
経理 return 200 status=returned actions=['submit']
# 古井さんが、開いたときのボタン(承認)を押すと
HTTP 409
{
"detail": "受付番号 EXP-2026-5007 は今 returned です",
"instance": "/api/v10/claims/EXP-2026-5007/approve",
"status": "returned",
"title": "この操作はできません",
"type": "https://expenses.example/problems/illegal-transition",
"actions": [
"submit"
]
}
# 係が 409 に、今の状態と押せるボタンを付ける(ApiErrors.java)
@ExceptionHandler(IllegalTransitionException.class) // その状態からは、その操作はできない → 409
ProblemDetail illegalTransition(IllegalTransitionException e) {
ProblemDetail p = problem(HttpStatus.CONFLICT, "illegal-transition", "この操作はできません",
"受付番号 " + e.id() + " は今 " + e.current().db() + " です");
p.setProperty("status", e.current().db());
p.setProperty("actions", Action.availableIn(e.current()));
return p;
}
古井さんの画面にはまだ「承認」ボタンがありましたが、申請はもう差戻しです。409 の返事には、今の状態と、今押せる操作を入れました。画面はそれを見て「経理が先に差し戻しました」と出し、ボタンを並べなおせます。
DB にも一覧を持たせる
最後の網として、DB にも「ありうる状態」を持たせます。第6回で金額に付けた CHECK と同じです。
# DB にも、ありうる状態の一覧を CHECK で持たせる
alter table expense add constraint expense_status_valid
check (status in ('draft','submitted','approved','returned','rejected','paid'));
# CHECK を足したあと
PUT …/naive/claims/EXP-2026-5003/status "aproved" → 500 DB status = submitted
ERROR: new row for relation "expense" violates check constraint "expense_status_valid"
POST …/claims/EXP-2026-5003/aproved → 400 知らない操作です
POST …/claims/EXP-2026-5003/pay(submitted) → 409 この操作はできません
CHECK を足すと、素朴版の打ち間違いは DB で止まり、状態は submitted のままでした。返事は同じ 500 でも、今度は DB に何も残りません。enum の窓口なら、そもそも 400 で入口に来ません。
状態を守る5つの段(実測)
状態は「文字の列」ではなく、表で決めた動きです。できることを列挙し、それ以外は断る。確かめは Java でも、最後の門は update の where。そして、変えたら同じトランザクションで履歴。
どこまで手で書くか
この回の遷移表は enum 1つ、空行を除いて 29 行でした。承認が1段なら、これで十分です。ただ、業務が育つと、表はすぐ大きくなります。
| やり方 | 向いている場面 | 気をつけること |
|---|---|---|
| 手で書く遷移表(この回) | 状態が10個くらいまで、承認が1〜2段、流れがあまり変わらない | where と履歴を忘れない。条件が増えたら表が読めなくなる |
| ワークフローエンジン(Flowable など) | 多段の承認、金額や部署で承認者が変わる、期限で自動的に進む、流れを図で業務の人と話したい | 道具そのものを学ぶ手間。申請の表とエンジンの中の状態が二重になる |
どちらを選んでも、「できない操作は断る」「同時押しは1件だけ通す」「履歴を残す」の3つは同じです。エンジンを入れても、申請の金額を承認後に変えられる窓口が残っていれば、穴はそのままです。
自分の PC で確かめる
| 試したいこと | 書き方 |
|---|---|
| DB にある状態の種類を数える | select status, count(*) from expense group by status; |
| ありうる状態を DB に持たせる | alter table … add constraint … check (status in (…)); |
| where に状態が入っていない update を探す | grep -n -A2 '<update' src/main/resources/mapper/*.xml |
| ある申請の一生を見る | select * from expense_history where expense_id = '…' order by no; |
状態の種類を数えて、表にない文字が1つでも出たら、どこかに「文字のまま書く」窓口があります。古いシステムを引き継いだら、最初にこの select を流してみてください。
結 — 一枚まとめと、次回
- 版番号だけでは、承認済みの申請も直せてしまう(24,160 → 34,160 円)。
- 状態を文字のまま持つと、500 なのに打ち間違いが DB に入る。
- 状態と操作は遷移表(enum)に。30 通り中 6 通りだけ許す。
- 遷移の最後の門は update の where status = #{from}。同時押しでも 200 は1件。
- 状態が変わったら同じトランザクションで履歴。
- 画面のボタン、窓口、担当者、where、CHECK。どれか1つでは足りない。
新井これで、承認済みの申請は安全ですね。
古井ところで、この回で承認できたのは、X-User に furui と書いた人だ。だれでも furui と名乗れるし、新井くんは自分の申請を自分で承認できる。次回は認証と認可。「だれか」を確かめる話だ。
参考
- MyBatis 3 — Dynamic SQL(foreach)
- MyBatis 3 — Mapper XML Files
- PostgreSQL 16 — Constraints(CHECK)
- Spring Framework — Transaction Management
- Spring Framework — Error Responses(ProblemDetail)
- Flowable Open Source Documentation
記録は、筆者がクラウド上の Linux(Ubuntu 24.04、カーネル 6.18)で、Spring Boot 4.1.2(Spring Framework 7.0.10、MyBatis 3.5.19、mybatis-spring-boot 4.1.0)、OpenJDK 25.0.4、PostgreSQL 16.15 を使って 2026年10月31日に取ったものです。申請サーバーと DB サーバーは、1台の中に作った別々のネットワーク空間(network namespace)で、往復は 0.04 ms ほどです。同時押しの実験は各3回で、結果は動かすたびに変わります(写真は別の1回分)。承認済みの状態は、実験の前に SQL で置いたものです。表の数字や出力はすべて、そのときのコマンドの出力から読んだ実際の値です。登場人物は架空の人物です。
いにしえのバックエンド入門 第10回 — 状態遷移 / 2026.10