Old-School Backend / 2026 ・ No.10
Old-School Backend
Part 10: State transitions — who may change an approved claim?
Published
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.
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)
# 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).
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.
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
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.
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>
# 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
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).
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.
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>
# 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.
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.
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)
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.
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.
| Approach | Good for | Watch out for |
|---|---|---|
| A hand-written table (this part) | up to about ten statuses, one or two approval levels, a flow that rarely changes | don'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 business | learning 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.
Checking on your own PC
| What to try | How to write it |
|---|---|
| Count the kinds of status in the database | select status, count(*) from expense group by status; |
| Give the database the valid statuses | alter table … add constraint … check (status in (…)); |
| Find updates with no status in the where clause | grep -n -A2 '<update' src/main/resources/mapper/*.xml |
| See one claim's whole life | select * 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
- A version alone still lets an approved claim be edited (24,160 → 34,160 yen).
- Keep the status as a string and a typo lands in the database even though the reply is 500.
- Put statuses and actions in a transition table (enum): only 6 of 30 allowed.
- The last gate for a transition is the update's where status = #{from}: one 200 even when pressed at once.
- When the status changes, write the history in the same transaction.
- 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
- 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
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