Low-Level Design (Object-Oriented Design) Module 5 – Modelling lifecycles, time, money and rules
Designing lifecycles as state machines
Find a lifecycle's states and events from its requirements, timeouts and failures included, then record each transition and make retries safe.
What you will learn
- Discover states and events from requirements, including timeouts and failures
- Record every transition with its time, actor and reason for auditing
- Design idempotent transitions so that a resent event changes nothing
- Undo a failed step with explicit compensating transitions
Before you start
What you’ll learn · Before you start
What you will learn
- Discover states and events from requirements, including timeouts and failures
- Record every transition with its time, actor and reason for auditing
- Design idempotent transitions so that a resent event changes nothing
- Undo a failed step with explicit compensating transitions
Before you start
On this page
Designing a lifecycle as a state machine means deciding, before writing code, every state an object can be in, every event that moves it, who sends each event, and what the object does when an event comes twice or never comes at all. Those last two cases are where many lifecycle bugs come from: a payment callback delivered twice, a seat hold that nobody confirms, a timer that fires after the customer has already paid. A good design answers each one with an explicit transition, writes every move into a history, and treats a resent request as a question already answered. This lesson finds the states and events of a cinema seat hold from its requirements, then runs the design against a clock that the code is given, so a test moves time instead of waiting for it.
Four questions that find the events
Requirements usually describe the happy path: a customer picks up to six seats, which are held for 8 minutes while they pay, and a successful payment confirms them. To find the rest of the lifecycle, ask four questions of every state. For a held seat, the answers are the design:
- What can happen next? List every event the state might receive, wanted or not. Here the payment succeeds or fails, the customer cancels, or the time runs out, so four events leave held.
- Who sends it? A person, another system or the clock. Here the payment provider, the customer and the clock all send events, and those from systems and the clock arrive at awkward moments.
- What if it happens twice? Networks resend, people double-click and payment providers retry their callbacks. Here a resent success must not confirm the seats or charge the customer again.
- What if it never happens? Then something else must end the state, usually a deadline. Here the customer walks away, so after 8 minutes the hold expires and the seats go back on sale.
The fourth question is the easiest to miss, because nobody types that event: the deadline belongs to the hold itself, and the clock delivers it.
A seat hold, with the sender of every event
Text description of the diagram
A hold starts when the customer picks seats, in the state "held", which expires 8 minutes after it starts. Three arrows leave it, each to a final state:
- "Payment succeeded", sent by the payment provider, leads to confirmed.
- "Cancel or payment failed", sent by the customer or by the provider, leads to released, and the seats go back on sale.
- "Deadline passed", sent by the clock, leads to expired.
A note beside the diagram asks two more questions of every arrow: what happens if the event arrives twice, and what happens if it never arrives. Confirmed, released and expired accept no further events.
A seat hold, run against a clock it is given
The hold below never reads the computer’s clock. It receives a clock object with a now() method, so the script can
move time to any second it likes. The hold stores its deadline as an absolute time, and every event checks the
deadline before anything else, because the scheduler that should expire the hold may run late or not at all.
"""A cinema seat hold: held for 8 minutes while the customer pays, then confirmed, released or
expired. The hold reads the time only from the clock it is given, so this script moves a fake
clock instead of waiting."""
HOLD_SECONDS = 8 * 60
class FakeClock:
"""A clock that the caller moves by hand. now() counts seconds from the start of the run."""
def __init__(self):
self.seconds = 0
def now(self):
return self.seconds
def move_to(self, minutes, seconds=0):
self.seconds = minutes * 60 + seconds
class Refused(Exception):
"""An event that the hold's current state does not accept."""
class SeatHold:
def __init__(self, seats, clock):
self.seats = seats
self.clock = clock
self.state = "held"
self.expires_at = clock.now() + HOLD_SECONDS
self.trail = [(clock.now(), "held", "customer")]
def _move(self, to, actor):
self.state = to
self.trail.append((self.clock.now(), to, actor))
def tick(self):
"""The clock's event: a scheduler calls it, and a hold past its deadline expires."""
if self.state == "held" and self.clock.now() >= self.expires_at:
self._move("expired", "clock")
def confirm(self, payment_id):
"""The payment provider's event: the customer has paid."""
self.tick() # no event may act on a hold that should already have expired
if self.state != "held":
raise Refused(f"confirm({payment_id}) refused: the hold is {self.state}")
self._move("confirmed", f"payment {payment_id}")
def release(self, actor):
"""The customer's event, or a failed payment's: the seats go back on sale."""
self.tick()
if self.state != "held":
raise Refused(f"release refused: the hold is {self.state}")
self._move("released", actor)
def clock_text(seconds):
return f"{seconds // 60}:{seconds % 60:02d}"
def show(title, hold, notes=()):
print(title)
for at, state, actor in hold.trail:
print(f" {clock_text(at)} {state:<10} by {actor}")
for note in notes:
print(f" {note}")
def run(title, steps):
"""steps: (minutes, seconds, action) in time order, with action one of the hold's events."""
clock = FakeClock()
hold = SeatHold(["F7", "F8"], clock)
notes = []
for minutes, seconds, action in steps:
clock.move_to(minutes, seconds)
try:
action(hold)
except Refused as refusal:
notes.append(f"{clock_text(clock.now())} {refusal}")
show(title, hold, notes)
run("1. Paid with a second to spare", [(7, 59, lambda h: h.confirm("P-1"))])
run("2. Nobody pays: the scheduler ticks at 7:59 and at 8:00",
[(7, 59, SeatHold.tick), (8, 0, SeatHold.tick)])
run("3. The customer gives up", [(2, 30, lambda h: h.release("customer"))])
run("4. The provider sends the same success twice",
[(3, 10, lambda h: h.confirm("P-4")), (3, 50, lambda h: h.confirm("P-4"))])
run("5. A success that arrives after the deadline", [(8, 5, lambda h: h.confirm("P-5"))]) Output
1. Paid with a second to spare 0:00 held by customer 7:59 confirmed by payment P-1 2. Nobody pays: the scheduler ticks at 7:59 and at 8:00 0:00 held by customer 8:00 expired by clock 3. The customer gives up 0:00 held by customer 2:30 released by customer 4. The provider sends the same success twice 0:00 held by customer 3:10 confirmed by payment P-4 3:50 confirm(P-4) refused: the hold is confirmed 5. A success that arrives after the deadline 0:00 held by customer 8:05 expired by clock 8:05 confirm(P-5) refused: the hold is expired
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 seat_hold.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Runs 1 to 3 are the expected paths, and run 2 checks the boundary: at 7:59 the hold is still held, and at exactly 8:00
it expires. The last two runs show what the four questions predicted. In run 4 the provider resent a success that had
already confirmed the seats, and the design answered with an error, so the provider is told that a payment it
completed has failed. In run 5 no scheduler tick came at 8:00, so the late success itself found the hold past its
deadline, expired it and was refused, and the customer has paid for seats they do not have. Both are refusals of real
events, and both need more design, not more if statements.
What a complete lifecycle design adds
Five techniques turn this sketch into a design that holds up in production, and each one answers a failure above:
- An append-only transition history records every move with its time, its actor and a reason; the current state is the last entry.
- Idempotent transitions answer a resent request id with the result it got the first time, instead of an error or a second effect.
- Conflict detection refuses a known request id that arrives with a different event, because that is a client bug, not a retry.
- Timeout policies decide, as a business rule, what happens to an event that arrives just after a deadline.
- Compensating transitions undo a reservation on purpose, with a reason, when a later step fails.
Check yourself
3 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Recording every transition, and retries that change nothing Pro: opens with a Pro, Premium or Ultimate pass
A lifecycle that keeps only its current state cannot say how it got there, and it cannot tell a resent request from a new one. Both are fixed by one small change: every accepted event becomes an entry in a history, filed under its request id.
In this part:
- Writing the history, and replaying it to check the invariant
- Duplicates measured: three designs, 10,000 deliveries
2 runnable examples (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Read a complete sample first: Low-level design: parking lot (the model)
Timeouts, late events and compensation, tested without sleeping Pro: opens with a Pro, Premium or Ultimate pass
The clock and failures send the events that nobody types, and both tend to arrive at the worst moment. This part turns a deadline into a policy the business chooses, measures what each choice costs, and undoes a half-finished booking with transitions instead of deletions.
In this part:
- A success that arrives after the deadline: two policies, measured
- Compensation: a failure after a reservation, drilled at every step
- Practise: a courier shipment with three attempts and duplicate scans
- A courier shipment with three delivery attempts and duplicate scans
- Results of the sample tests
2 runnable examples (Python) · 1 exercise with sample tests · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher · Warm-up
Which questions should you ask of every state when you design a lifecycle, and which one do designs most often miss?
Four questions, asked of each state: what can happen next, who sends each event, what happens if an event arrives twice, and what happens if it never arrives. The first lists the events, wanted or not. The second shows that people are not the only senders: other systems and the clock send events too, often at awkward moments. The third covers resends from networks, double clicks and payment callbacks that providers retry. The fourth is the one most designs miss, because nobody types that event: if a customer never pays, something else must end the hold, so the design needs a deadline stored with the object and a time event, sent by the clock, that moves it to a final state such as expired. Answer all four for every state and the diagram has most of its arrows.
-
Mid to senior
A payment provider delivered its success callback for one booking twice, 40 seconds apart. Customers received two ticket emails, and the logs show an error for the second callback. Redesign the booking's lifecycle so that duplicates are harmless, and say how you would test it.
-
Mid to senior
Seat holds expire after 8 minutes. A customer pays at 7 minutes 58 seconds, but the provider's callback reaches you at 8 minutes 3 seconds, after the hold has expired. What should happen, and how does your state machine make that outcome a decision rather than an accident?
-
Fresher to mid
Support must answer "who cancelled this order, when and why?" for any order of the last two years. Design how the order's lifecycle records its transitions, and explain how the current status and the history stay consistent.
Key takeaways
- Ask four questions of every state: what can happen next, who sends it, what if it happens twice, and what if it never happens.
- The clock is an event source. Store each deadline with its object and check it on every event, so that tests move time instead of waiting.
- An event that arrives twice or late is an ordinary input, and the design, not chance, decides what it does.
References
- Unified Modeling Language (UML) 2.5.1, formal specification (state machines, time events) (Object Management Group)
- dataclasses: data classes (frozen instances) (Python Software Foundation)
- random: generate pseudo-random numbers (reproducibility) (Python Software Foundation)
- API reference: idempotent requests (Stripe)
- Compensating Transaction pattern (Microsoft (Azure Architecture Center))
- Sagas (ACM SIGMOD (Hector Garcia-Molina and Kenneth Salem))
- PostgreSQL documentation: CREATE INDEX (unique indexes) (The PostgreSQL Global Development Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress