Skip to content

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, no self.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 to checked_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.