Shopping Cart¶
In this guide you'll build a shopping cart that expires if abandoned. Along the way you'll learn how behavior recursion models state, how terminal behaviors protect invariants, and how the scheduler handles timeouts.
Messages¶
The cart uses frozen dataclasses for all messages, plus a value object for items and a result type for receipts:
@dataclass(frozen=True)
class Item:
name: str
price: float
@dataclass(frozen=True)
class Receipt:
items: tuple[Item, ...]
total: float
@dataclass(frozen=True)
class AddItem:
item: Item
@dataclass(frozen=True)
class RemoveItem:
name: str
@dataclass(frozen=True)
class GetTotal:
reply_to: ActorRef[float]
@dataclass(frozen=True)
class Checkout:
reply_to: ActorRef[Receipt]
@dataclass(frozen=True)
class CartExpired:
pass
type CartMsg = AddItem | RemoveItem | GetTotal | Checkout | CartExpired
Item is a value object. Receipt is the checkout result returned to the caller. The rest are commands the cart understands. Notice CartExpired carries no data — it's a signal sent by the scheduler when the timeout fires. The cart never creates it directly.
The Empty Cart¶
An empty cart only accepts AddItem. Everything else is ignored:
type SchedulerRef = ActorRef[ScheduleOnce | CancelSchedule]
def empty_cart(scheduler_ref: SchedulerRef) -> Behavior[CartMsg]:
async def receive(
ctx: ActorContext[CartMsg], msg: CartMsg
) -> Behavior[CartMsg]:
match msg:
case AddItem(item):
scheduler_ref.tell(
ScheduleOnce(
key="cart-timeout",
target=ctx.self,
message=CartExpired(),
delay=5.0,
)
)
return active_cart(scheduler_ref, {item.name: item})
case _:
return Behaviors.same()
return Behaviors.receive(receive)
When the first item arrives, two things happen: we schedule a 5-second timeout via ScheduleOnce, and we transition to active_cart with the item in our dict. The SchedulerRef type alias keeps the signatures readable.
The Active Cart¶
This is where the interesting logic lives. Each match arm handles one command:
def active_cart(
scheduler_ref: SchedulerRef, items: dict[str, Item]
) -> Behavior[CartMsg]:
async def receive(
ctx: ActorContext[CartMsg], msg: CartMsg
) -> Behavior[CartMsg]:
match msg:
case AddItem(item):
return active_cart(scheduler_ref, {**items, item.name: item})
case RemoveItem(name) if name in items:
remaining = {k: v for k, v in items.items() if k != name}
if remaining:
return active_cart(scheduler_ref, remaining)
scheduler_ref.tell(CancelSchedule(key="cart-timeout"))
return empty_cart(scheduler_ref)
case GetTotal(reply_to):
reply_to.tell(sum(item.price for item in items.values()))
return Behaviors.same()
case Checkout(reply_to):
scheduler_ref.tell(CancelSchedule(key="cart-timeout"))
receipt = Receipt(
items=tuple(items.values()),
total=sum(item.price for item in items.values()),
)
reply_to.tell(receipt)
return checked_out()
case CartExpired():
print("Cart expired — items discarded")
return expired()
case _:
return Behaviors.same()
return Behaviors.receive(receive)
Walking through each arm:
- AddItem — behavior recursion with a new dict (
{**items, item.name: item}). This is the state transition. No mutation, noself.items[name] = item. - RemoveItem — filters out the item. If the cart becomes empty, cancels the timer and transitions back to
empty_cart. - GetTotal — request-reply: computes the sum, sends it to
reply_to, stays in the same behavior. - Checkout — cancels the timer, builds a
Receipt, replies, and transitions tochecked_out. The cart is done. - CartExpired — the timer fired. Transitions to
expired. No cleanup needed — the behavior change is the cleanup.
Terminal States¶
Once a cart is checked out or expired, it ignores all messages:
def checked_out() -> Behavior[CartMsg]:
return Behaviors.ignore()
def expired() -> Behavior[CartMsg]:
return Behaviors.ignore()
Behaviors.ignore() accepts any message and does nothing. A checked-out cart can't accept items because the behavior simply doesn't handle them. The behavior is the state.
Wiring It Up¶
The shopping_cart function ties everything together with Behaviors.setup():
def shopping_cart() -> Behavior[CartMsg]:
async def setup(ctx: ActorContext[CartMsg]) -> Behavior[CartMsg]:
scheduler_ref = ctx.spawn(scheduler(), "scheduler")
return empty_cart(scheduler_ref)
return Behaviors.setup(setup)
Behaviors.setup() gives us an ActorContext before the first message arrives. We use it to spawn a scheduler as a child actor and pass its ref to empty_cart. Because the scheduler is a child, it shares the cart's lifecycle — when the cart stops, the scheduler stops with it.
Running It¶
The main() function spawns two carts: one that checks out successfully, and one that gets abandoned:
async def main() -> None:
async with ActorSystem() as system:
# Cart 1: successful checkout
cart1 = system.spawn(shopping_cart(), "cart-1")
cart1.tell(AddItem(Item("keyboard", 75.0)))
cart1.tell(AddItem(Item("mouse", 25.0)))
await asyncio.sleep(0.1)
total: float = await system.ask(
cart1, lambda r: GetTotal(reply_to=r), timeout=5.0
)
print(f"Total: ${total:.2f}")
receipt: Receipt = await system.ask(
cart1, lambda r: Checkout(reply_to=r), timeout=5.0
)
print(f"Receipt: {len(receipt.items)} items, ${receipt.total:.2f}")
# Cart 2: abandonment / expiration
cart2 = system.spawn(shopping_cart(), "cart-2")
cart2.tell(AddItem(Item("monitor", 300.0)))
print("Waiting for cart-2 to expire...")
await asyncio.sleep(6.0)
asyncio.run(main())
Output:
Total: $100.00
Receipt: 2 items, $100.00
Waiting for cart-2 to expire...
Cart expired — items discarded
Run the Full Example¶
git clone https://github.com/gabfssilva/casty.git
cd casty
uv run python examples/guides/02_shopping_cart.py
What you learned:
- Behavior recursion passes new state as function arguments — no mutation needed.
- Terminal behaviors (
checked_out,expired) protect invariants by construction — a checked-out cart can't accept items because the behavior simply doesn't handle them. - The scheduler sends timed messages without blocking the actor's mailbox.
- Child actors (the scheduler) share their parent's lifecycle — when the cart stops, the scheduler stops.