Old-School Backend / 2026 ・ No.10

Old-School Backend

Part 10: State transitions — who may change an approved claim?

Published

A terminal. The five lines of Action.java are SUBMIT(SUBMITTED, DRAFT, RETURNED), APPROVE(APPROVED, SUBMITTED), RETURN(RETURNED, SUBMITTED), REJECT(REJECTED, SUBMITTED) and PAY(PAID, APPROVED). In the show10.py matrix table, only submit from draft, approve, return and reject from submitted, submit from returned, and pay from approved give 200; everything else is 409
The transition table (top) and every one of 6 statuses × 5 actions tried (bottom). Of 30 combinations, only the 6 written in the table got 200 (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.)

An expense claim moves from draft to submitted to approved to paid. Treat that “status” as just a string column and an approved claim's amount can change, impossible statuses land in the database, and two approvers pressing at once leave a contradictory history.
This time we put statuses and actions in a table, guard them with MyBatis's where clause and a database CHECK, and keep a history. Where each thing is stopped is measured with all 6×5 combinations and with simultaneous presses.

1

An approved claim can be edited

AraiWith last time's version numbers, simultaneous edits are safe now, right?

FuruiSimultaneous ones, yes. But what if you edit a claim after I've approved it?

Arai…As long as the version matches, it goes through.

FuruiLet's try it.

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

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
# Editing an approved claim (EXP-2026-5002) through part 9's counter (with a version)
  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

We opened an approved business-trip claim (24,160 yen) through last part's counter, matched the version, added 10,000 yen and saved. It replied 200, and the database now says “approved, 34,160 yen”, though what was approved was a 24,160-yen claim.

  • Status as a string — a typo, “aproved”, got a 500 yet landed in the database (measured)
  • The transition table — 6 statuses × 5 actions; only 6 of 30 allowed (measured)
  • Pressed at once — approve and return together: the naive way gave 3–9 replies of 200 (measured)
  • Layers of defence — screen, counter, clerk, the SQL where clause, the database CHECK

The application continues from last time, written with MyBatis. In the database we added a status history table, expense_history. As in part 5, whoever acts names themselves in the X-User header (arai, furui, keiri = accounts).

2

Status as a plain string

First, a common way to write it. The status is a string in the status column, and whatever string the screen sends goes straight in.

# A counter and clerk that take the status as a string (naive)
@PutMapping("/api/v10/naive/claims/{id}/status")  // takes the status as a string (naive)
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  // make whatever string arrived the status
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>
# Making whatever string arrives the status (EXP-2026-5003)
  "aproved"   → 500   DB status = aproved
  "承認"      → 500   DB status = 承認
  "paid"      → 200   DB status = paid
# Counts by status (after writing aproved and 承認 into two more claims)
  approved|1   aproved|1   paid|1   submitted|122   承認|1
# What the 500 was (app log)
java.lang.IllegalArgumentException: No enum constant example.expenses.v10.ClaimStatus.APROVED

“aproved” (one p short) and “承認” both got a 500, yet were already in the database. The clerk's transaction had committed; the counter then failed while building the reply, unable to turn an unknown status into the enum. The screen says “error” while the database keeps the typo: the hardest kind to notice.

“paid” got 200: a submitted claim skipped approval and became paid. A string column accepts any string, in any order.

3

The transition table

We settled on six statuses and five actions, and drew in one picture which action takes which status where.

Claim statuses and actions

A claim's statuses (6) and actions (5) draft draft submitted submitted approved approved paid final returned sent back rejected final submit approve pay return submit reject Filled: contents can be changed (draft, returned) Red frame: final; nothing can happen after this Any move without an arrow is “not allowed”: 6 of 30 in a table Here approval has one step (Furui); accounts pays

That picture becomes a Java enum as it is: for each action, its destination and the statuses it may start from. The counter turns approve in the URL into Action.APPROVE.

# Statuses and actions as enums (ClaimStatus.java and Action.java)
public enum ClaimStatus {
    DRAFT, SUBMITTED, APPROVED, RETURNED, REJECTED, PAID;
    …
}

/** The transition table: for each action, from which states, to which state */
public enum Action {
    SUBMIT(SUBMITTED, DRAFT, RETURNED),  // submit (from draft or returned)
    APPROVE(APPROVED, SUBMITTED),  // approve
    RETURN(RETURNED, SUBMITTED),  // return
    REJECT(REJECTED, SUBMITTED),  // reject
    PAY(PAID, APPROVED);  // pay
    …
    public boolean allowedFrom(ClaimStatus s) {
        return from.contains(s);
    }
}

We tried all 30 combinations of 6 statuses × 5 actions.

# 6 statuses × 5 actions (EXP-2026-5004, status reset each time)
  status \ action  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

Only the 6 in the table got 200; the other 24 got 409. Nothing is possible from paid or rejected, and only paying from approved. List what may happen, and refuse everything else: that's the idea of a transition table.

4

Guard transitions in the where clause

Checking the table still leaves a hole. Furui and accounts are looking at the same claim; Furui presses approve and accounts presses return at the same moment. It's part 9's read-check-write race again.

# Naive: checks the table, but writes by id alone
@Transactional  // checks the table, but not when writing
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);
}
# Also checked in the where clause (ClaimFlowService.java and 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) {  // someone changed the status after we read it
        throw new IllegalTransitionException(id, ClaimStatus.fromDb(find(id).status()), action);
    }
    states.insertHistory(id, from.db(), action.to().db(), actor);  // history in the same transaction
    return find(id);
}

<update id="changeStatus">
  update expense set status = #{to}, version = version + 1
  where id = #{id} and status = #{from}
</update>
A terminal. In StatusMapper.xml, changeStatus is update expense set status = #{to}, version = version + 1 where id = #{id} and status = #{from}. show10.py race naive guarded shows naive with approve 200×6, return 200×4, final status approved, 10 history rows mixing submitted→approved and submitted→returned; guarded with approve 200×1, return 200×0, 1 history row
50 approvals and 50 returns sent at once to one submitted claim. The naive way gave 200 to 10 of them, and the history mixes “submitted to approved” with “submitted to returned”. With the guarded way, exactly one got through (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.)
# 50 approvals (furui) and 50 returns (keiri) at once on one submitted claim (three runs each)
  way       approve 200 return 200  final status  history rows
  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
# History from the naive way's third run (first 6 rows)
  submitted→approved furui
  submitted→approved furui
  submitted→approved furui
  submitted→returned keiri
  submitted→returned keiri
  submitted→returned keiri

The naive way answered 200 to both approvals and returns (5, 3 and 9 over three runs). The history lists “submitted to approved” next to “submitted to returned”, and the final status is approved one run and returned the next. Furui's screen says “approved” while the claim is actually returned.

What happens when both are pressed at once

Naive (where id only) Furui accts read submitted update → approved read submitted update → returned history submitted→approved furui submitted→returned keiri Both 200, both “from submitted” Guarded (id and status) Furui accts read submitted update → approved read submitted update 0 rows → 409 history submitted→approved furui Only one 200; the later one gets 409 Check in Java if you like; the last gate is the update's where Measured: naive gave 3–9 replies of 200; guarded gave 1 every run

The guarded way only adds and status = #{from} to the update. Only the first writer hits the row; the later ones update zero rows and get 409. Every run gave exactly one 200 and one history row (the return came first in these three runs, but which comes first varies).

5

When contents may change

Back to chapter 1's “an approved claim can be edited”. Contents (item, amount) may change only in draft and returned, and that goes in the update's where clause too. With two statuses, MyBatis's <foreach> builds the in (…).

# Putting the editable statuses in the where clause (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  // contents can change only in draft or returned
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);
}
# The same approved claim, through part 10's counter
  PUT  /api/v10/claims/EXP-2026-5002  → 409  この申請は変更できません(受付番号 EXP-2026-5002 は今 approved です)

When zero rows change, read again and tell the reasons apart: the status gives “this claim can't be changed”, the version gives last time's “someone changed this first”. Both are 409, but the screen shows different words.

AraiWhat if I notice a wrong amount after approval?

FuruiAsk the approver to send it back. The way to fix things should always be on the table. Build a back door, and it becomes the most-used door.

6

Keep a history

Every time the status changes, write a row: from which status, to which, who, when. The key is to write it in the same transaction as the status update, so a status can never change without its history.

# The history table (setup10.sql) and the SQL that writes it
-- Part 10: status history
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>
A terminal. 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. History: draft→submitted arai, submitted→returned furui, returned→submitted arai, submitted→approved furui, approved→paid keiri
One full lap from draft to paid. After a return the claim can be edited; after approval it can't. The history table records who moved it from which status to which (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.)
# From draft to paid (EXP-2026-5006)
  Arai     submit   200  submitted  actions=['approve', 'return', 'reject']
  Furui    return   200  returned   actions=['submit']
  Arai     edit     200  returned   amount=25,660
  Arai     submit   200  submitted  actions=['approve', 'return', 'reject']
  Furui    approve  200  approved   actions=['pay']
  Arai     edit     409  approved   この申請は変更できません
  accounts pay      200  paid       actions=[]
  Furui    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

After the return, Arai added a 1,500-yen taxi and resubmitted. Editing after approval got 409, and returning a paid claim got 409. Five history rows hold the claim's whole life. When accounts or an auditor asks who approved this claim and when, show them this table.

7

Screen buttons and 409

The screen shows buttons for the status the claim had when opened. Put the available actions (actions) in the reply and the screen can show the right buttons without knowing the table. But the status can change while the screen is open.

# Furui and accounts open the same claim; accounts returns it first (EXP-2026-5007)
  Furui    open    200  status=submitted  actions=['approve', 'return', 'reject']
  accounts open    200  status=submitted  actions=['approve', 'return', 'reject']
  accounts return  200  status=returned  actions=['submit']
# Furui presses the button he saw (approve)
  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"
  ]
}
# The handler adds the current status and available buttons to the 409 (ApiErrors.java)
@ExceptionHandler(IllegalTransitionException.class)  // that action isn't allowed from that state → 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;
}

Furui's screen still had an approve button, but the claim had already been returned. The 409 carries the current status and the actions available now, so the screen can say “accounts returned it first” and lay out its buttons again.

8

Give the database the list too

As a last net, the database also gets the list of valid statuses, just like the CHECK we put on the amount in part 6.

# Giving the database the list of valid statuses too, with CHECK
alter table expense add constraint expense_status_valid
  check (status in ('draft','submitted','approved','returned','rejected','paid'));
# After adding the 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   この操作はできません

With the CHECK in place, the naive typo stopped at the database and the status stayed submitted. The reply is still a 500, but this time nothing is left behind. The enum counter never lets it in at all: 400 at the door.

Five layers guarding the status (measured)

Five layers guarding the status (kinder above, harder below) screen show only available buttons (GET actions) submitted → [approve, return, reject] counter take the action name as an enum aproved → 400 unknown action clerk check the transition table 24 of 30 combinations → 409 SQL where id = … and status = #{from} only one 200 even at once DB CHECK (status in (…)) aproved → 500 (not written) Hiding a button on screen doesn't mean the counter is guarded Upper layers are for usability, lower ones the last net; one isn't enough
POINT

A status isn't a string column; it's movement decided by a table. List what may happen and refuse the rest. Check in Java if you like, but the last gate is the update's where clause. And whenever it changes, write the history in the same transaction.

9

How much to write by hand

This part's transition table is one enum, 29 non-blank lines. For one level of approval that's enough. But as the business grows, the table grows fast.

ApproachGood forWatch out for
A hand-written table (this part)up to about ten statuses, one or two approval levels, a flow that rarely changesdon't forget the where clause and the history; with many conditions the table becomes unreadable
A workflow engine (Flowable and others)multi-level approval, approvers that change with amount or department, deadlines that move things on, a flow you want to discuss as a diagram with the businesslearning the tool itself; the claims table and the engine's own state become two copies

Either way, three things stay the same: refuse disallowed actions, let only one of simultaneous presses through, and keep a history. Bring in an engine but leave a counter that can change the amount after approval, and the hole is still there.

10

Checking on your own PC

What to tryHow to write it
Count the kinds of status in the databaseselect status, count(*) from expense group by status;
Give the database the valid statusesalter table … add constraint … check (status in (…));
Find updates with no status in the where clausegrep -n -A2 '<update' src/main/resources/mapper/*.xml
See one claim's whole lifeselect * from expense_history where expense_id = '…' order by no;

Count the kinds of status; if even one string that isn't in your table shows up, somewhere there's a counter that writes strings as they come. When you inherit an old system, run this select first.

§ — On one page, and next time

  1. A version alone still lets an approved claim be edited (24,160 → 34,160 yen).
  2. Keep the status as a string and a typo lands in the database even though the reply is 500.
  3. Put statuses and actions in a transition table (enum): only 6 of 30 allowed.
  4. The last gate for a transition is the update's where status = #{from}: one 200 even when pressed at once.
  5. When the status changes, write the history in the same transaction.
  6. Screen buttons, counter, clerk, where clause, CHECK: no single layer is enough.

AraiSo approved claims are safe now.

FuruiExcept that here, whoever wrote furui in X-User could approve. Anyone can claim to be furui, and you could approve your own claim. Next time: authentication and authorisation, making sure of “who”.

References

The recordings were made on 31 October 2026 on a cloud Linux machine (Ubuntu 24.04, kernel 6.18) with Spring Boot 4.1.2 (Spring Framework 7.0.10, MyBatis 3.5.19, mybatis-spring-boot 4.1.0), OpenJDK 25.0.4 and PostgreSQL 16.15. The claims server and the database server are separate network spaces (network namespaces) inside one machine, about 0.04 ms apart. Each simultaneous-press experiment was run three times and results vary every run (the photos are separate runs). Approved statuses were set with SQL before the experiments. 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 10 — State transitions / 2026.10

← Back to all posts