Our order can now wait for an external result without pretending that the operation has already finished.
The carrier inquiry receives a request identifier. Its answer is returned as a message. A queue consumer checks whether that answer still belongs to the outstanding inquiry before applying it.
That gives one order a controlled execution path.
But suppose the application has many active orders. An API receives commands, carrier workers produce replies, timers expire, and a support interface reads current progress.
Who owns each order?
More precisely:
Who is allowed to inspect its current condition and decide what changes next?
The transition table answers whether a change is permitted. It does not decide which component may execute that decision.
We need to make ownership explicit.
A queue orders messages, not every possible mutation
Recall the shape of our earlier runtime:
run_order(order, inbox, start_check, report_rejection)
The consumer processes messages from inbox. Its handlers call the domain operations we already defined.
But the code that created order may still retain that object:
order.status = OrderState.SHIPPED
That assignment does not pass through the queue, the transition table, or the validator.
The queue may process every message correctly while another component changes the same record directly.
There is a second, less obvious way to bypass ownership.
Our payment-request message is frozen:
@dataclass(frozen=True)
class CapturePaymentRequested:
payment: Payment
But its payment field refers to the mutable Payment object from the earlier parts.
Consider:
inbox.put_nowait(CapturePaymentRequested(payment))
payment.payment_id = "changed-after-sending"
The message wrapper does not make the nested payment immutable. The queued message and the sender still refer to the same payment object. Python assignment and ordinary reference passing do not copy objects.[3]
The consumer could therefore receive different data from what the sender intended to submit.
Processing messages sequentially does not solve this problem. The shared object can change without a new message.
A mailbox is useful only if it becomes the boundary around the state, not merely another way to reach it.
Could we protect the order with a lock?
A lock shared by every writer could prevent overlapping changes. For a small application, that may be a suitable design.
But the requirement is still that every writer participates. A caller holding the mutable order can bypass the lock just as it can bypass the queue.
We would also need the callers to agree on which lock protects which order, what belongs inside the locked section, and how external waits should be handled.
Rather than distribute those responsibilities, let’s give each order one owner.
Other components will submit requests to that owner. They will not receive the owner’s mutable record.
API command ───────────┐
Carrier result ────────┤
Timer event ───────────┼──> Order owner ──> Private Order
Read request ──────────┘
The owner still uses our existing domain functions. We are changing how those functions are reached, not replacing their rules.
This is an actor-shaped boundary
An actor is an independently addressed computational entity that responds to messages and controls its own behavior and state. A common implementation gives it a mailbox and processes messages sequentially. Other entities communicate with it through messages rather than directly changing its internal record.[1]
For our application, the pieces are already familiar:
| Actor responsibility | Our order example |
|---|---|
| Identity | order-66 |
| Private state | Its Order, including payment and shipping-check data |
| Mailbox | Incoming commands, results, and queries |
| Behavior | The code that handles those inputs |
| Outgoing communication | Carrier inquiries and replies to callers |
The distinction from a state machine matters:
The state machine defines which changes are valid. The actor boundary defines who may perform them.
An actor does not have to use a transition table. We keep ours because it makes this actor’s lifecycle explicit.
Nor does an actor require its own operating-system thread or dedicated event loop. We will host several order actors as tasks on one Python event loop.
Why message-based ownership became a model of computation
Hewitt, Bishop, and Steiger’s 1973 paper, A Universal Modular ACTOR Formalism for Artificial Intelligence, proposed actors as a common way to describe computational behavior. The paper expressed interactions through messages and emphasized local responsibility: an actor performs work itself or delegates it to another actor.[2]
Their scope was much broader than an order service. They were investigating a modular foundation for AI programs, including highly parallel ones.
The relevant connection is the boundary we have reached. A component should not need access to another component’s internal representation to request useful behavior.
Our shipping worker can ask an order to handle a result. It does not need permission to assign the order’s status.
The historical idea does not force us to make every field or function an actor. Here, one order is a useful ownership boundary because its lifecycle rules already concern that order as a whole.
Preserve the domain model; change access to it
We will keep the model established earlier:
CREATED
→ PAID
→ CHECKING_SHIPPING
→ READY_TO_SHIP
→ SHIPPED
We also keep:
capture_payment()check_shipping()apply_shipping_result()ship_order()validate_order()available_actions()
Payment capture remains a simulation that records a supplied payment. The shipping inquiry remains read-only. Shipping still updates local lifecycle state rather than booking or dispatching a real package.
The new layer will receive messages and call those functions on a privately owned copy.
There are two directions to protect:
Incoming: a sender must not be able to change an accepted command by mutating an object it still holds.
Outgoing: a caller must not receive the mutable order and change it without sending a command.
For these small dataclasses, we can copy incoming data and return an immutable summary.
Return a view, not the owned record
Let’s introduce a read request and its response:
@dataclass(frozen=True)
class GetOrder:
pass
@dataclass(frozen=True)
class OrderView:
order_id: str
status: OrderState
payment_id: str | None
pending_request_id: str | None
actions: tuple[OrderCommand, ...]
OrderView contains values the caller can inspect. It contains neither a mutable Payment nor the actor’s Order.
The pending request identifier appears only while an inquiry is outstanding. After a successful check, the order retains its accepted ShippingCheck, but that request is no longer pending.
def make_view(order: Order) -> OrderView:
actions = available_actions(order)
pending = (
order.shipping_check
if order.status is OrderState.CHECKING_SHIPPING
else None
)
return OrderView(
order_id=order.id,
status=order.status,
payment_id=(
order.payment.payment_id
if order.payment is not None
else None
),
pending_request_id=(
pending.request_id
if pending is not None
else None
),
actions=actions,
)
available_actions() validates the order before deriving its eligible commands.
The view is a snapshot of one handled input. It will not update itself when the actor later changes state.
That is intentional. A snapshot reports an observation; it is not a live reference into another component’s memory.
Give callers a reference that accepts messages
The existing Message type covers payment requests, order commands, and shipping results. We extend it with our query:
ActorMessage = Message | GetOrder
We also need a way for a caller to receive the outcome of local handling.
For this in-process example, each submission gets an asyncio.Future. Awaiting it returns an OrderView or raises the rejection produced by the actor. A future represents an eventual asynchronous result.[5]
The future is delivery bookkeeping. It is not part of the domain message.
import asyncio
from copy import deepcopy
Delivery = tuple[
ActorMessage,
asyncio.Future[OrderView],
]
class ActorUnavailable(RuntimeError):
pass
class MailboxFull(RuntimeError):
pass
Now define the reference callers will use:
class OrderRef:
def __init__(
self,
order_id: str,
inbox: asyncio.Queue[Delivery],
) -> None:
self.order_id = order_id
self._inbox = inbox
self._closed = False
self._pending: set[
asyncio.Future[OrderView]
] = set()
def send(
self,
message: ActorMessage,
) -> asyncio.Future[OrderView]:
if self._closed:
raise ActorUnavailable(
f"Actor {self.order_id} has stopped."
)
copied = deepcopy(message)
reply = asyncio.get_running_loop().create_future()
try:
self._inbox.put_nowait((copied, reply))
except asyncio.QueueFull:
raise MailboxFull(
f"Mailbox for {self.order_id} is full."
) from None
self._pending.add(reply)
reply.add_done_callback(self._pending.discard)
return reply
def _close(self) -> None:
if self._closed:
return
self._closed = True
for reply in tuple(self._pending):
if not reply.done():
reply.set_exception(
ActorUnavailable(
"Actor stopped before replying."
)
)
self._pending.clear()
while not self._inbox.empty():
self._inbox.get_nowait()
self._inbox.task_done()
send() copies the payload synchronously, before placing it in the mailbox. A deep copy recursively copies the nested data rather than retaining the same mutable payment object.[3]
This is adequate for the simple records used here. It is not a recommendation to deep-copy arbitrary sockets, database sessions, or large document collections. A larger system should define explicit message schemas and deliberate serialization or immutable value types.
The mailbox is bounded. put_nowait() rejects a submission when it is full instead of waiting indefinitely.[4] The caller must observe that failure; the message has not been admitted.
_close() is a runtime operation. It stops new submissions and fails outstanding replies rather than leaving callers waiting forever.
Those failed replies do not prove that no local changes happened. An actor may stop after changing state but before producing a reply. We have not added transactional recovery.
One registered owner per order
A mailbox per object is not sufficient if two objects represent the same order and both accept writes.
Our local runtime therefore needs an additional invariant:
One order identifier has at most one registered owner in this runtime.
Let’s enforce that when creating the owner.
The following function replaces the earlier direct use of run_order(). It expects an active asyncio.TaskGroup, a shared local directory, and the scheduling callback for carrier inquiries.
from typing import Callable
def spawn_order(
initial: Order,
tasks: asyncio.TaskGroup,
directory: dict[str, OrderRef],
start_check: Callable[
[ShippingCheck, OrderRef],
None,
],
*,
capacity: int = 128,
) -> tuple[OrderRef, asyncio.Task[None]]:
if initial.id in directory:
raise ValueError(
f"An owner is already registered for {initial.id}."
)
if capacity < 1:
raise ValueError("capacity must be positive.")
owned = deepcopy(initial)
validate_order(owned)
inbox: asyncio.Queue[Delivery] = asyncio.Queue(
maxsize=capacity
)
ref = OrderRef(owned.id, inbox)
async def consume() -> None:
try:
while True:
message, reply = await inbox.get()
try:
try:
if isinstance(message, GetOrder):
pass
elif isinstance(
message,
CapturePaymentRequested,
):
capture_payment(
owned,
message.payment,
)
elif message is OrderCommand.CHECK_SHIPPING:
request = check_shipping(owned)
start_check(request, ref)
elif message is OrderCommand.SHIP_ORDER:
ship_order(owned)
elif isinstance(
message,
ShippingCheckResult,
):
apply_shipping_result(
owned,
message,
)
else:
raise TypeError(
"Unsupported message."
)
view = make_view(owned)
except InvalidOrderOperation as exc:
if not reply.done():
reply.set_exception(exc)
else:
if not reply.done():
reply.set_result(view)
finally:
inbox.task_done()
finally:
ref._close()
worker = tasks.create_task(
consume(),
name=f"order:{owned.id}",
)
worker.add_done_callback(
lambda _: ref._close()
)
directory[owned.id] = ref
return ref, worker
There is only one consumer for this mailbox. It owns a copy of the initial order and never returns that copy.
The caller receives an OrderRef. The hosting code also receives the worker task so it can manage the actor’s lifetime.
The task’s completion callback handles the case where the worker is cancelled before its coroutine begins. Otherwise, the coroutine’s finally block closes the reference.
We deliberately retain a stopped entry in the directory. Recreating an actor from an arbitrary old object would require a recovery policy we have not designed.
This directory is local and assumes creation occurs on the same event loop. Two application processes with separate dictionaries could still create two owners for the same identifier. Distributed ownership requires another mechanism.
The code makes no claim beyond its boundary.
The actor still delegates to the state machine
Most of consume() should look familiar.
A payment request calls capture_payment(). A shipping result calls apply_shipping_result(). A shipping command calls ship_order().
The actor does not acquire a second set of domain rules.
For example, it does not decide that a paid order may ship by checking owned.status itself. The existing operation and transition table remain responsible for that decision.
Expected lifecycle rejections return to the caller without terminating the actor.
A malformed message, an inconsistent record, or an unexpected scheduling error is different. Those failures escape the handler and stop the consumer. We do not keep processing a record whose validity is uncertain.
The wrapper controls access and execution. The domain model controls legality.
External workers receive a return address, not the order
start_check() now receives:
start_check(request, ref)
The request is the immutable ShippingCheck we already defined. The reference tells the worker where to return its result.
The scheduling callback must still return promptly. It schedules external work; it does not wait for the carrier.
When that work finishes, its delivery step becomes:
await destination.send(
ShippingCheckResult(
order_id=request.order_id,
request_id=request.request_id,
event=OrderEvent.SHIPPING_AVAILABLE,
)
)
The timeout producer uses the same destination.
Neither needs the owned Order. Both report inputs that the actor evaluates through apply_shipping_result().
That function still rejects wrong routing and ignores obsolete request identifiers. Mailbox serialization does not make an old reply current.
The actor reference identifies the recipient. The request identifier identifies the particular inquiry.
We need both.
Sending, handling, and completing external work are different
Our interface distinguishes several stages:
| Observation | What it establishes |
|---|---|
send() returns a future |
The copied input entered this local mailbox. |
Awaiting the future raises InvalidOrderOperation
|
The actor handled the input and rejected the requested operation. |
Awaiting a check command returns CHECKING_SHIPPING
|
The local operation recorded the inquiry and its scheduler returned. |
A later view is READY_TO_SHIP
|
The actor accepted the matching positive result. |
None of these observations proves that a physical shipment was booked or dispatched. Those operations remain outside the example.
A result message that is obsolete returns an unchanged view. The reply means the message was handled, not that it advanced the lifecycle.
There is also a cancellation distinction.
If the caller cancels its wait for a reply, our actor does not remove the submitted message. The handler still processes it; it simply avoids completing a cancelled future.
A caller timeout is not an order-cancellation command.
Conflating the two would let a network timeout silently change business policy.
Test ownership, not just transition outcomes
Let’s use a scheduler that records requests instead of contacting a carrier. This lets us inspect exactly which external work the actor requests.
async def exercise_ownership() -> None:
checks: list[
tuple[ShippingCheck, OrderRef]
] = []
def record_check(
request: ShippingCheck,
destination: OrderRef,
) -> None:
checks.append((request, destination))
initial = Order(
id="order-66",
customer_name="Alice",
delivery_address="Istanbul",
)
payment = Payment(
method="credit_card",
provider="stripe",
payment_id="payment-81",
captured_amount=Decimal("120.00"),
)
directory: dict[str, OrderRef] = {}
async with asyncio.TaskGroup() as tasks:
ref, worker = spawn_order(
initial,
tasks,
directory,
record_check,
)
try:
reply = ref.send(
CapturePaymentRequested(payment)
)
payment.payment_id = "changed-outside"
initial.status = OrderState.SHIPPED
paid = await reply
assert paid.status is OrderState.PAID
assert paid.payment_id == "payment-81"
first = ref.send(
OrderCommand.CHECK_SHIPPING
)
duplicate = ref.send(
OrderCommand.CHECK_SHIPPING
)
waiting = await first
try:
await duplicate
except InvalidOrderOperation:
pass
else:
raise AssertionError(
"A duplicate inquiry was accepted."
)
assert len(checks) == 1
request, destination = checks[0]
ready = await destination.send(
ShippingCheckResult(
order_id=request.order_id,
request_id=request.request_id,
event=OrderEvent.SHIPPING_AVAILABLE,
)
)
assert ready.status is OrderState.READY_TO_SHIP
observed = await ref.send(GetOrder())
assert observed == ready
assert paid.status is OrderState.PAID
assert waiting.status is OrderState.CHECKING_SHIPPING
finally:
worker.cancel()
The first assertions test the ownership boundary.
Changing the caller’s original order does not change the actor’s copy. Changing the original payment after submission does not change the queued payment.
The next assertions test serialization. Both check commands enter the same mailbox, but the first moves the order into CHECKING_SHIPPING. The second is then rejected against that new state. Only one request reaches the scheduler.
Finally, the earlier views remain unchanged after the actor progresses.
The tests now examine who can affect the state, not only which transitions exist.
Reads should cross the boundary too
GetOrder() goes through the same mailbox as commands and results.
Its response therefore reflects the state after earlier mailbox entries have been handled and before later entries are handled.
That gives a useful local ordering relationship:
Record payment
↓
Read order
↓
Request shipping check
The read observes the paid state if those messages enter the mailbox in that order.
It does not promise the order will remain paid after the response is delivered. Another message may already be waiting.
Consequently, returning SHIP_ORDER in actions is still not a reservation or authorization token. The shipping operation checks eligibility again when it runs.
For dashboards, repeatedly querying every actor may be unnecessary. An actor could publish immutable views to a separate read store. Those views could lag behind current processing, so they should be treated as observations rather than permission to bypass the owner.
A read path should not become a hidden write path.
One actor does not mean one thread
Our actors are separate tasks sharing one event loop.
Python’s event loop uses cooperative scheduling: while one task is awaiting something, other tasks can run. A synchronous handler does not yield to another coroutine unless it reaches a suspension point.[5]
That is why our consumer waits at:
message, reply = await inbox.get()
but does not await the carrier inside the state-changing handler.
An order can remain logically in CHECKING_SHIPPING while its consumer handles queries or rejects inappropriate commands. Other orders can also progress.
This is concurrency, not a promise of parallel CPU execution. A long CPU-bound handler would still delay other actors sharing that event loop.
Nor do all actor runtimes treat asynchronous handlers identically. Orleans, for example, processes requests non-reentrantly by default but supports configurable interleaving. With reentrancy, another request can change state while an earlier request is suspended.[7]
Our model deliberately chooses short, non-interleaved local handlers. It does not rely on the word “actor” to supply that guarantee.
The mailbox is also a load boundary
We chose a capacity of 128 messages to make admission finite. That number is an illustrative setting, not a throughput claim.
A full mailbox causes MailboxFull. It does not silently discard the oldest input or report successful delivery.[4]
A producer must decide what to do next: wait and retry under a defined policy, reject an upstream request, or escalate a delivery failure.
This applies to external results as well as user commands. Losing a carrier response because the mailbox is full is not harmless merely because the order remains internally valid.
A bounded queue limits the number of waiting messages. It does not bound message size, total actor count, or execution time per handler.
Capacity management controls resource use. The state machine still controls business behavior.
What happens when an actor fails?
Our sample distinguishes expected command rejection from unexpected execution failure.
It does not implement independent actor supervision.
The TaskGroup used here waits for its tasks and propagates failures. An unhandled failure in one task can cancel its siblings. That is the hosting policy of this example, not proof that each actor’s failure is isolated from the others.[5]
A production actor runtime needs an explicit policy for starting, stopping, monitoring, and possibly restarting owners.
Erlang/OTP supervisors provide one concrete model: a supervisor manages child processes and applies configured restart strategies and restart-intensity limits.[6]
Those mechanisms answer which process should be restarted and when to stop retrying.
They do not, by themselves, recover our order’s business progress.
Restarting this actor from its original CREATED object would lose the payment, pending inquiry, and accepted results. Reusing an order identifier does not restore its history.
Likewise, an actor that fails after arranging a real external effect cannot assume that effect disappeared.
Restarting execution and recovering state are different problems.
We will need persistence and effect-recovery rules before automatic restart becomes safe.
Where should the ownership boundary stop?
We chose one actor per order because the invariants we currently enforce concern that order’s status, payment record, and inquiry.
That does not give the order ownership over the entire inventory system.
If two orders request the final unit of a product, each order may behave correctly according to its own state while both attempt to reserve it. The inventory service needs its own authoritative reservation mechanism.
Two individually valid actors do not create one transaction across their state.
The same caution applies to databases. If a batch job writes the order row directly, bypassing the actor, the application no longer has one effective owner.
Actor ownership is a system boundary to maintain, not a label to place on one class.
For a small CRUD application whose rules already fit inside a database transaction, adding this runtime may be unnecessary. The actor approach becomes useful when long-lived identities, asynchronous inputs, and explicit ownership make coordination easier to reason about.
What has changed?
We have not added another shipping state or replaced the transition table.
We have changed the route through which behavior occurs:
Caller
↓ copied message
Order reference
↓
Private mailbox
↓
Single consumer
↓
Existing domain operation
↓
Existing validation and transition rules
↓
Immutable reply
Within our local runtime, the directory gives an order one registered owner. The owner holds a separate record. Messages do not retain mutable caller-owned payment data. Replies do not expose the owned order.
These are application-level boundaries in trusted Python code, not a security sandbox. Code that deliberately reaches into private attributes, corrupts the directory, or bypasses the runtime can violate them.
The narrower guarantee is the useful one:
Through the supported interface, other components request changes; the order’s owner decides and applies them.
That places responsibility in one location.
It does not make the behavior inside that location simple.
But what if the owner’s behavior keeps growing?
An order may eventually have several shipping phases, an independent review process, and rules that apply across all active phases.
The actor gives those decisions an owner. It does not explain how to organize dozens of related states without repeating the same conditions.
We could place every rule inside one increasingly large message handler. The state would be isolated, but the behavior would again become difficult to inspect.
But how do we organize one owner’s behavior when some states are nested, some conditions are active simultaneously, and several phases share the same rules?
References
[1] Stately, Actors. A modern implementation reference for encapsulated actor state, asynchronous messages, mailboxes, and state-machine actors.
[2] Carl Hewitt, Peter Bishop, and Richard Steiger, “A Universal Modular ACTOR Formalism for Artificial Intelligence”, Proceedings of IJCAI, 1973, pp. 235–245. See the introduction and the discussion of intrinsic computation and local responsibility.
[3] Python Software Foundation, copy - Shallow and Deep Copy Operations. Object bindings and the distinction between shallow and recursive copying.
[4] Python Software Foundation, asyncio Queues. FIFO behavior, bounded queues, admission failure, task bookkeeping, and thread-safety limitations.
[5] Python Software Foundation, Coroutines and Tasks. Futures, cooperative scheduling, cancellation, and task-group failure behavior. The example uses APIs available in Python 3.11 and later.
[6] Erlang/OTP, Supervisor Behaviour. Child-process management, restart strategies, and restart-intensity limits.
[7] Microsoft, Request Scheduling in Orleans. Non-reentrant request handling and configurable interleaving.
Top comments (1)
The frozen-message example really stuck with me — a frozen dataclass can still carry a mutable Payment, so changing payment_id after sending quietly changes what the consumer sees. I liked the framing that the state machine decides which changes are valid, while the actor decides who gets to make them. The OrderView snapshot point landed too: a read that goes through the mailbox is useful precisely because it doesn't hand back a live reference.