SwadhyAI Notes

All Low-Level Design notes

Behavioral modeling

Valid and invalid state transitions

A state transition is a named move from one status to another, usually tied to a command. Legal transitions belong in a table; everything else should be refused.

Example: PAID → CLOSED is how a car leaves. CLOSED → ACTIVE is how a bug reopens a finished stay when a payment webhook fires twice. Both look like “assign a status field” if you never wrote the table. Write the table, then refuse every other arrow.

Legal

  • CREATED--activate→ACTIVE
  • ACTIVE--pay→PAID
  • PAID--close→CLOSED

Illegal (throw)

  • CLOSED --activate→ ACTIVE

    Resurrects a finished stay.

  • PAID --activate→ ACTIVE

    Unwinds payment without a refund story.

  • CREATED --close→ CLOSED

    Skips the stay and the fee.

  • CLOSED --pay→ PAID

    Charges a car that already left.

Legal
  CREATED  --activate-->  ACTIVE
  ACTIVE   --pay------>  PAID
  PAID     --close---->  CLOSED

Illegal (throw)
  CLOSED   --activate-->  ACTIVE
  PAID     --activate-->  ACTIVE
  CREATED  --close----->  CLOSED
  CLOSED   --pay------->  PAID

Optional later (only if the product says so)
  ACTIVE   --cancel--->  CANCELLED
  PAID     --refund--->  REFUNDED

Retries do not reverse arrows

A is the same command again. A second pay() on an already PAID ticket is either a no-op (idempotent — see idempotency in code) or a rejection. It is not CLOSED → ACTIVE. A webhook that “puts the ticket back to ACTIVE so we can pay again” is inventing a transition the business never agreed to.

If the business wants a refund, that is a you add to the table on purpose — REFUNDED, not a secret reverse. Write the new row. Then implement it. Do not drive backwards on the happy path and call it recovery.

Duplicate webhook

Ticket is already PAID. Second pay() returns the same receipt or throws “already paid.” Status does not move. Nobody assigns ACTIVE.

Fake reverse

ticket.setStatus(ACTIVE) after CLOSED so “we can pay again.” That is the bug this page exists to make impossible.

One table, every command

Code the table, do not hide it in a pile of ifs. Commands call one transition method. The method looks up (from, to) or (from, command). Miss → throw. Hit → assign. That is the whole implementation for a small machine like Ticket. You will read it in an interview in ten seconds. Nested flags take ten minutes and still let CLOSED go ACTIVE.

java
enum TicketStatus { CREATED, ACTIVE, PAID, CLOSED }

record Move(TicketStatus from, TicketStatus to) {}

class Ticket {
  private static final Set<Move> LEGAL = Set.of(
      new Move(TicketStatus.CREATED, TicketStatus.ACTIVE),
      new Move(TicketStatus.ACTIVE, TicketStatus.PAID),
      new Move(TicketStatus.PAID, TicketStatus.CLOSED)
  );

  private TicketStatus status = TicketStatus.CREATED;

  void activate() { moveTo(TicketStatus.ACTIVE); }
  void pay()      { moveTo(TicketStatus.PAID); }
  void close()    { moveTo(TicketStatus.CLOSED); }

  private void moveTo(TicketStatus next) {
    if (!LEGAL.contains(new Move(status, next))) {
      throw new IllegalStateException(status + " → " + next + " is not allowed");
    }
    status = next;
  }
}

// CLOSED.activate() throws. A retry cannot resurrect the ticket.

Optional rows are still rows

CANCELLED from ACTIVE, REFUNDED from PAID — only if the product says so. Add them to LEGAL. Add commands. Do not sprinkle a new else in close(). Two gates racing a close() is a different problem: concurrent state transitions. The table still decides what is legal; the lock or version decides who wins the race.

Where the table lives

On the entity. Controller catches IllegalStateException and returns an error. Repository never writes a status the entity did not accept. Strategy does not appear in this file. That pocket sort is unchanged from state machines and object lifecycle. This page is only the rows.

Common mistakes

If-else without a table

Scattered checks miss a row. A Set of legal moves is the spec in the code. Add CANCELLED in one place, not in four methods.

Treating a retry as a reverse transition

CLOSED → ACTIVE is not how you handle a duplicate webhook. Either ignore a second pay() or add a real refund arrow. Do not drive backwards on the happy path.

Controller that “fixes” status

service.forceActive(ticket) is setStatus with extra steps. The table is worthless if a second door exists.

Board drill

  1. Write every legal (from, command, to) triple.
  2. Write four illegal triples clearly, including CLOSED → ACTIVE.
  3. Say what a duplicate pay() does — no-op or throw — before you code.
  4. Refuse a reverse arrow unless they asked for refund.

Idempotent pay versus a new row

Decide before you code. If pay() on PAID returns the same receipt, you chose idempotency: the table does not grow, the command becomes a no-op when from == to and the command is pay. If pay() on PAID throws, you chose strict transitions: only CREATED→ACTIVE, ACTIVE→PAID, PAID→CLOSED. Both are legal designs. Mixing them — throw in one method, silently assign ACTIVE in another — is how CLOSED → ACTIVE returns.

java
void pay() {
  if (status == TicketStatus.PAID) return; // idempotent choice
  moveTo(TicketStatus.PAID);               // still throws from CLOSED
}

// CLOSED still cannot pay. ACTIVE still can. CREATED still cannot.
// Idempotency is a loop on the same node, not a reverse arrow.

From-command-to

Write triples, not just from→to. activate is the only door CREATED may take. close is the only door PAID may take. A nameless assignment is how illegal rows hide.

Miss means throw

Do not log and continue. Do not coerce CLOSED into PAID. The caller — controller, webhook adapter — handles the exception. The entity only knows the table.

The sentence to refuse

“We will put it back to ACTIVE so the retry can pay.” That change is a CLOSED → ACTIVE transition. Offer idempotent pay or a real REFUNDED row. Do not offer a reverse of close().

Read next

Takeaway

Write the table before the if-else chain. Legal: CREATED → ACTIVE → PAID → CLOSED. Illegal: CLOSED → ACTIVE. A retry is not a reverse. A refund is a new row you add on purpose. One Set of moves, every command.