Old-School Backend / 2026 ・ No.10

いにしえのバックエンド入門

第10回 状態遷移 ── 承認済みの申請を、だれが直せるのか

公開

端末の画面。Action.java の5行は SUBMIT(SUBMITTED, DRAFT, RETURNED)、APPROVE(APPROVED, SUBMITTED)、RETURN(RETURNED, SUBMITTED)、REJECT(REJECTED, SUBMITTED)、PAY(PAID, APPROVED)。show10.py matrix の表では、draft の submit、submitted の approve・return・reject、returned の submit、approved の pay だけが 200 で、ほかはすべて 409
状態遷移表(上)と、6つの状態×5つの操作を全部試した結果(下)。30 通りのうち、200 になったのは表に書いた 6 通りだけでした(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)

経費精算の申請は、下書き → 申請中 → 承認 → 支払済み と進みます。この「状態」を、ただの文字の列として扱うと、承認済みの申請の金額が変わり、ありえない状態が DB に入り、2人の承認者が同時に押すと履歴が食い違います。
今回は状態と操作を表にし、MyBatis の where と DB の CHECK で守り、履歴を残します。どこで何が止まるかを、6×5 の全組み合わせと同時押しで実測しました。

一

承認済みの申請を直せる

新井前回の版番号で、同時に直しても安全になりましたよね。

古井同時はね。では、私が承認したあとの申請を、新井くんが直したら?

新井……版番号さえ合っていれば、通っちゃいます。

古井試してみよう。

全15回の路線図(いまここ:第10回)

1 全体図 2 Java の準備 3 素の HTTP 4 素の JDBC 5 Spring Boot 6 入力チェック 7 例外と返事 8 DB アクセス 9 トランザクション 10 状態遷移 11 認証・認可 12 ファイル 13 ログと監視 14 テスト 15 動かす いまここ 基礎 素手で 受け口 データ 業務と守り 運用
# 承認済みの申請(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枚の図にします。

申請の状態と操作

申請の状態(6つ)と操作(5つ) draft 下書き submitted 申請中 approved 承認 paid 支払済み・終わり returned 差戻し rejected 却下・終わり submit approve pay return submit reject 塗りつぶし:中身を変えられる状態(draft・returned) 赤い枠:終わりの状態。ここからは何もできない 矢印のない動きは、すべて「できない」。表にすると 30 通り中 6 通りだけ この回では、承認は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>
端末の画面。StatusMapper.xml の changeStatus は update expense set status = #{to}, version = version + 1 where id = #{id} and status = #{from}。show10.py race naive guarded の結果は、naive が承認 200×6、差戻し 200×4、最後の状態 approved、履歴 10 行で、submitted→approved と submitted→returned が混ざる。guarded は承認 200×1、差戻し 200×0、履歴 1 行
申請中の1件に、承認 50 件と差戻し 50 件を同時に送りました。素朴版は 10 件が 200 になり、履歴には「申請中から承認」と「申請中から差戻し」が混ざりました。where 版で通ったのは1件だけです(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# 申請中の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 id だけ) 古井 経理 読む submitted update → approved 読む submitted update 待つ → returned 履歴 submitted→approved furui submitted→returned keiri 2人とも 200。どちらも「申請中から」 where 版(id と status) 古井 経理 読む submitted update → approved 読む submitted update 0 行 → 409 履歴 submitted→approved furui 200 は1人だけ。あとの人は 409 確かめは Java でも、最後の門は update の where。第9回と同じ形 実測:素朴版は 200 が 3〜9 件、where 版は 3 回とも 1 件

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>
端末の画面。show10.py life の結果。arai submit 200 submitted、furui return 200 returned、arai edit 200 amount=25,660、arai submit 200、furui approve 200 approved、arai edit 409 この申請は変更できません、keiri pay 200 paid、furui return 409 paid。履歴は draft→submitted arai、submitted→returned furui、returned→submitted arai、submitted→approved furui、approved→paid keiri
下書きから支払いまで、ひと回り動かしました。差戻しのあとは直せて、承認のあとは直せません。履歴の表には、だれがどの状態からどの状態へ動かしたかが残ります(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# 下書きから支払いまで(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つの段(実測)

状態を守る5つの段(上ほど親切、下ほど固い) 画面 押せるボタンだけ出す(GET の actions) submitted → [approve, return, reject] 窓口 操作名を enum で受け取る aproved → 400 知らない操作です 担当者 状態遷移表で確かめる 30 通り中 24 通りが 409 SQL where id = … and status = #{from} 同時押しでも 200 は 1 件 DB CHECK (status in (…)) aproved → 500(書かれない) 画面のボタンを消しても、窓口は守ったことにならない 上の段は使いやすさ、下の段は最後の安全網。どれか1つでは足りない
POINT

状態は「文字の列」ではなく、表で決めた動きです。できることを列挙し、それ以外は断る。確かめは 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 を流してみてください。

結 — 一枚まとめと、次回

  1. 版番号だけでは、承認済みの申請も直せてしまう(24,160 → 34,160 円)。
  2. 状態を文字のまま持つと、500 なのに打ち間違いが DB に入る。
  3. 状態と操作は遷移表(enum)に。30 通り中 6 通りだけ許す。
  4. 遷移の最後の門は update の where status = #{from}。同時押しでも 200 は1件。
  5. 状態が変わったら同じトランザクションで履歴。
  6. 画面のボタン、窓口、担当者、where、CHECK。どれか1つでは足りない。

新井これで、承認済みの申請は安全ですね。

古井ところで、この回で承認できたのは、X-User に furui と書いた人だ。だれでも furui と名乗れるし、新井くんは自分の申請を自分で承認できる。次回は認証と認可。「だれか」を確かめる話だ。

参考

記録は、筆者がクラウド上の 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

← 記事一覧へ