The starter treats status like a plain field — apply() jumps straight to the command's target without checking where the task is now, so it'll happily make illegal jumps (e.g. complete a task that's still CREATED, or restart a COMPLETED one). Your job: only allow a command that's legal from the current status (else return "409"), and append an audit row only on success.
résumé · An ops task board is the same transaction-integrity shape as Opendoor's offer lifecycle and the manufacturing/financial state work at SK AX — status-change-as-business-event with validation + audit.
You'll predict the failure first, then write the fix and watch your own code drive the behavior.
제안서는 정해진 길을 따라간다: 만듦(CREATED) → 보냄(SENT) → 수락(ACCEPTED) → 종료(CLOSED). 중간에 취소되거나 만료될 수도 있다.
// CREATED -> SENT -> ACCEPTED -> CLOSED (or CANCELLED / EXPIRED) String status = "CREATED"; attempt(m, "send"); // CREATED -> SENT attempt(m, "accept"); // SENT -> ACCEPTED attempt(m, "close"); // ACCEPTED -> CLOSED
“An offer moves through fixed stages: created, sent, accepted, then closed.”
checking microphone…
처음 짠 코드는 단계를 그냥 글자 하나로 본다. "종료로 바꿔" 하면 지금 어디인지 안 보고 그냥 종료로 적어버린다.
⚖️ Trade-off: 지금 상태를 안 보면, 이미 끝난 제안서를 또 보내는 말도 안 되는 일이 그냥 통과된다.
// correct: check the CURRENT status before moving, don't just overwrite String next = table.getOrDefault(status, Map.of()).get(command); if (next == null) return "409"; // illegal from current status status = next;
“The buggy code just overwrites the status without checking where the offer is now.”
checking microphone…
그래서 막 뛴다. 예를 들어 이미 종료(CLOSED)된 제안서를 또 보내려(SENT) 해도 막힘 없이 된다. 현실엔 없는 일이 데이터엔 생긴다.
⚖️ Trade-off: 단계 건너뛰기가 허용되면 데이터가 현실과 어긋나고, 나중에 아무도 믿을 수 없게 된다.
🔧 도구:enum status (그냥 값으로만 쓰면 X)
// terminals accept nothing -> no illegal jump out of CLOSED // CLOSED / EXPIRED / CANCELLED have no entry in the table if (!table.containsKey(status)) return "409";
“So it makes illegal jumps, like sending an offer that is already closed.”
checking microphone…
고치는 핵심: "지금 단계에서 이 명령이 되나?"를 표로 정해둔다. (지금 상태 + 명령) → 다음 상태. 표에 있으면 진행, 없으면 거절.
⚖️ Trade-off: 허용되는 길을 일일이 표로 적어둬야 한다. 코드가 조금 늘어난다.
✅ Fix: 단계가 몇 개 안 되니 표는 작다. 이 작은 표 하나가 "불가능한 상태"를 아예 못 만들게 막아준다 → 충분히 남는 장사.
🔧 도구:transition table: Map<(status, command), nextStatus>FSM (finite state machine)
// allowed moves keyed on (current status, command) -> next status
Map<String, Map<String, String>> table = Map.of(
"CREATED", Map.of("send", "SENT", "cancel", "CANCELLED"),
"SENT", Map.of("accept", "ACCEPTED", "expire", "EXPIRED", "cancel", "CANCELLED"),
"ACCEPTED", Map.of("close", "CLOSED", "cancel", "CANCELLED"));“I model the allowed moves as a table keyed on the current status and the command.”
checking microphone…
안 되는 명령이 오면 조용히 무시하지 않는다. "지금 상태랑 충돌이라 안 됨"이라고 409로 분명히 알려준다.
⚖️ Trade-off: 그냥 무시하면 부른 쪽은 됐는지 안 됐는지 몰라서 더 위험하다.
✅ Fix: 409로 명확히 거절하면, 부른 쪽이 "아, 이미 상태가 바뀌었구나" 알고 다시 읽어볼 수 있다.
🔧 도구:HTTP 409 Conflict
String next = table.getOrDefault(status, Map.of()).get(command); if (next == null) return "409"; // conflict with current state, not a silent move
“An illegal command is a 409 conflict, not a silent move.”
checking microphone…
성공해서 단계가 바뀔 때마다, 누가(actor) 왜(reason) 바꿨는지 기록 한 줄을 같은 트랜잭션 안에서 남긴다. 상태 변경과 기록이 같이 되거나 같이 안 된다.
⚖️ Trade-off: 성공할 때마다 기록 한 줄씩 쌓이니 저장 공간을 조금 쓴다.
✅ Fix: 나중에 "이 제안서 왜 이렇게 됐지?"를 추적할 수 있다. 돈이 오가는 일이라 이 추적 기록이 꼭 필요하다.
🔧 도구:audit tableDB transaction (상태+감사기록 원자적)
// on a committed transition, write one audit row in the same transaction status = next; audit.add(command + " by " + actor + " reason=" + reason); return "OK";
“Every successful transition writes one audit row in the same transaction, with who and why.”
checking microphone…
실무라면 두 개를 더 붙인다: 한 집엔 살아있는 제안서가 하나만 있게 막는 규칙, 그리고 같은 요청이 두 번 와도 한 번만 처리되게 하는 키.
⚖️ Trade-off: 네트워크가 흔들리면 같은 "수락" 요청이 두 번 도착해, 두 번 처리될 위험이 있다.
✅ Fix: 멱등키(idempotency key)를 붙이면, 같은 요청은 처음 결과를 그대로 다시 돌려주고 다시 적용하진 않는다.
🔧 도구:unique constraint (one active offer per home)idempotency key
// production: enforce one active offer per home + dedup retries
UNIQUE (home_id) WHERE status NOT IN ('CLOSED','EXPIRED','CANCELLED');
if (seenKeys.containsKey(idempotencyKey)) return seenKeys.get(idempotencyKey);“In production I would add one-active-offer-per-home and an idempotency key.”
checking microphone…
7단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.
A status field is updated from many places. With no guard, illegal jumps slip in (CLOSED -> SENT) and corrupt the lifecycle.
order.status = "SHIPPED"; // set from anywhere // nothing stops CLOSED -> SHIPPED, or skipping a required step
Use when: Never for a real lifecycle with rules.
Cost / limit: Illegal transitions corrupt state; the logic is scattered everywhere.
static final Map<Status, Set<Status>> NEXT = Map.of(
CREATED, Set.of(SENT, CANCELLED),
SENT, Set.of(ACCEPTED, EXPIRED));
void to(Status next) {
if (!NEXT.getOrDefault(status, Set.of()).contains(next))
throw new IllegalStateException(status + " -> " + next);
status = next;
}Use when: Any entity with a defined lifecycle. Most cases.
Cost / limit: One table to keep correct; every transition must go through it.
// the transition table is the guard; an audit row is the trail // (entity_id, from, to, actor, at, reason) on every accepted transition
Use when: Compliance, debugging, answering 'why is this CLOSED?'.
Cost / limit: An audit table to write and store on every transition.
// store EVENTS, not the current status: // state = fold(events) [Created, Sent, Accepted] -> ACCEPTED // rebuild any past state by replaying the log
Use when: You need full history and to reconstruct any prior state.
Cost / limit: Reads need fold/snapshots; a bigger mental model.
Know the ladder — start at the simplest that meets the need, and say what you'd reach for next and why.
Say it out loud in your own words — hit these terms — then check against the gold answer.
Say it in your own words — but hit these: state machine · 409 · audit · task board
checking microphone…