Large async systems turn into a tangle fast: channels everywhere, endless select!, nested loops, shared state under mutexes. The actor model cuts that knot with one idea — every component owns its state alone and talks only through messages. Nobody reaches into anyone else's data directly; all interaction goes through a mailbox.

In this article we'll build such a framework in Rust from scratch — no off-the-shelf actor libraries, just tokio primitives. The result is a small but honest core built on traits: an actor lifecycle, type-safe messages with type erasure, events, and a request-response pattern. At the end a Calculator actor will add two numbers and prove it all works.

An actor is a trait with a lifecycle

Start with the essential question: what actually makes a struct an actor. The answer — implementing the Actor trait. A user writes their own type, slaps Actor on it, and the framework takes over the whole runtime around it.

#[async_trait]
pub trait Actor: Send + 'static {
    async fn initialize(
        &mut self,
        _ctx: &mut Context<Self>,
    ) -> Result<(), Error> {
        Ok(())
    }
    // ...
}

The trait requires Send + 'static: an actor will ride into a separate tokio task, so it has to be sendable between threads and storable for as long as it lives. The methods are async, hence the async_trait attribute on top — async methods in public traits aren't stabilized yet, and the attribute desugars them into methods returning a boxed Future.

An actor has a clear lifecycle — four methods with default bodies:

async fn event(
    &mut self,
    ctx: &mut Context<Self>,
) -> Result<(), Error> {
    if let Some(envelope) = ctx.next_envelope().await {
        envelope.handle(self, ctx).await?;
    } else {
        ctx.stop();
    }
    Ok(())
}

initialize runs once at startup, finalize runs at shutdown, and event handles a single incoming message per call. If the mailbox is empty and closed, next_envelope() returns None, and the actor asks itself to stop via ctx.stop(). The default implementations let you declare an actor in one literal line — impl Actor for TestActor {} — overriding only what you need.

Type erasure: one mailbox for different letters

Here's the crux. An actor has to accept messages of different types: send Print to one, Sum to another. But a channel in Rust is strictly typed — UnboundedSender<T> carries only T. So how do you pile heterogeneous letters into one mailbox?

The fix is type erasure via a trait object. We introduce a MessageFor trait that knows how to do exactly one thing: handle itself against a concrete actor.

#[async_trait]
pub trait MessageFor<A: Actor + ?Sized>: Send + 'static {
    async fn handle(
        self: Box<Self>,
        actor: &mut A,
        ctx: &mut Context<A>,
    ) -> Result<(), Error>;
}

pub type Envelope<A> = Box<dyn MessageFor<A>>;

The receiver self: Box<Self> means the message is consumed whole: handled, then dropped. And Envelope<A> is Box<dyn MessageFor<A>> — the envelope. The concrete letter type hides behind the trait object, so the channel now carries uniform Envelope<A> while the contents can be anything. The ?Sized on A is there so the trait works with dyn actors too.

Why does handle live on the message rather than the actor? Because only the message knows its real type. The runtime calls envelope.handle(self, ctx) blindly, and the envelope unwraps itself back into the right type and invokes the correct handler. Dispatch logic moves out of the runtime and into the messages themselves — and the core stays tiny.

Address and context — two ends of one channel

A channel has two ends. The "outer" end is the Address: held by whoever sends messages to the actor. The "inner" one is the Context: that's what the actor itself works with.

pub struct Address<A: ?Sized> {
    msg_tx: mpsc::UnboundedSender<Envelope<A>>,
    status_rx: watch::Receiver<ActorStatus>,
}

The address holds two channels. Through msg_tx we send envelopes. And status_rx is the receiver of a watch channel carrying the actor's status: Active or Done. Why the second channel? So an outsider can wait for the actor to finish:

pub async fn join(&mut self) -> Result<(), Error> {
    self.status_rx
        .wait_for(ActorStatus::is_done)
        .await?;
    Ok(())
}

A watch::Receiver keeps the latest value and can wait for it to change — the perfect primitive for "let me know when you're done." wait_for blocks until the status becomes Done. Sending a message we wrap up neatly:

pub fn send(
    &self,
    msg: impl MessageFor<A>,
) -> Result<(), Error> {
    self.msg_tx
        .send(Box::new(msg))
        .map_err(|_| Error::msg("Can't send the message"))
}

This is where the packaging into an envelope happens: Box::new(msg) erases the type. Address is also Clone — you can have as many copies of an address as you like, all sending into the same mailbox. The context holds the mirror ends of the same channels plus an active flag, which the runtime uses to decide whether to keep looping.

The runtime: a loop that brings the actor to life

The pairing of actor and context is owned by ActorRuntime, and its whole essence sits in a single method.

pub async fn entrypoint(mut self) {
    if let Err(err) =
        self.actor.initialize(&mut self.ctx).await
    {
        log::error!("init failed: {err}");
    }
    while self.ctx.active {
        if let Err(err) =
            self.actor.event(&mut self.ctx).await
        {
            log::error!("event failed: {err}");
        }
    }
    // finalize + set status to Done
}

The structure is rock solid: initialize, then the event loop while active holds, then finalize. A handler error gets logged but does not crash the actor — one faulty message shouldn't kill the whole component. At the end the runtime pushes the Done status into the watch channel — and that's what wakes up someone's join().

What's left is wiring it all up. That's the job of the Standalone extension trait with its spawn method, implemented for any actor at once:

fn spawn(self) -> Address<Self> {
    let (msg_tx, msg_rx) = mpsc::unbounded_channel();
    let (status_tx, status_rx) =
        watch::channel(ActorStatus::Active);
    let address = Address { msg_tx, status_rx };
    // build Context from msg_rx, status_tx, address.clone()
    let runtime = ActorRuntime { actor: self, ctx };
    tokio::spawn(runtime.entrypoint());
    address
}

spawn creates both channels, hands their ends to the address and the context, launches entrypoint as a separate tokio task, and returns an Address to the caller. From that moment the actor lives on its own, and all you hold is the address. The blanket impl<A: Actor> Standalone for A attaches spawn to every actor automatically.

Typed events on top of envelopes

MessageFor is the low level; writing envelopes by hand is awkward. On top of it we build a friendly fire-and-forget event layer. The user implements OnEvent<E> for each event type they're willing to accept:

#[async_trait]
pub trait OnEvent<E>: Actor {
    type Error: Into<Error> + Send + 'static;
    async fn handle(
        &mut self,
        event: E,
        ctx: &mut Context<Self>,
    ) -> Result<(), Self::Error>;
    // fallback() with a default
}

The associated type Error lets a handler return its own error type, as long as it converts into anyhow's Error. And the fallback method (with a default body) catches that error — a place for your own recovery policy. To get an event into the shared mailbox, we wrap it in an Event<E> envelope:

#[async_trait]
impl<A, E> MessageFor<A> for Event<E>
where
    A: OnEvent<E>,
    E: Send + 'static,
{
    async fn handle(
        self: Box<Self>,
        actor: &mut A,
        ctx: &mut Context<A>,
    ) -> Result<(), Error> {
        let res = actor.handle(self.event, ctx).await;
        if let Err(err) = res {
            actor.fallback(err, ctx).await
        } else {
            Ok(())
        }
    }
}

This is where the two layers meet: Event<E> implements the low-level MessageFor, and inside it calls the high-level OnEvent::handle. If that returns an error — hand it to fallback. The sending is hidden behind AddressExt::event(e), and the user never even sees the word "envelope." interrupt() is built the same way — a separate Interrupt message whose handle calls actor.interrupt(ctx) to shut the actor down gracefully from the outside.

Request-response: a custom Future over oneshot

Events return nothing. But often you need an answer: "compute a sum — give me back a number." That's the request-response pattern, and a oneshot channel from futures fits it perfectly: one sender, one receiver, exactly one value. The request itself is described by a trait that ties the request type to the response type:

pub trait Request: Send + 'static {
    type Response: Send + 'static;
}

When we send a request, we drop half of a oneshot channel into the envelope — a return address for the answer:

fn interact(
    &self,
    request: T,
) -> Result<Responder<T>, Error> {
    let (tx, rx) = oneshot::channel();
    let interaction = Interaction { request, tx };
    self.send(interaction)?;
    Ok(Responder { rx })
}

Interaction<T> carries both the request and the tx end of the channel; the handler on the actor's side computes the answer and sends it into tx. And the caller immediately gets back a Responder<T> with the rx end — a promise of a future answer. The fun part: Responder is a custom Future.

impl<T: Request> Future for Responder<T> {
    type Output = Output<T::Response>;
    fn poll(
        mut self: Pin<&mut Self>,
        cx: &mut FutContext<'_>,
    ) -> Poll<Self::Output> {
        Pin::new(&mut self.rx).poll(cx)
    }
}

Implementing Future by hand is simpler than it sounds: we merely delegate poll to the inner rx. Now responder.await waits for the answer like any other future. The Output type is a double Result: the outer Canceled (the actor died without replying), the inner one the error of the computation itself. The #[must_use] attribute on Responder warns you if the answer is accidentally ignored.

Symmetrically to events, the answer is delivered back as an ordinary message: the forward_to method spawns a task, awaits self.await, and sends the result to the recipient through a Response envelope, which fires OnResponse::on_response. We verify the whole thing with a pair of actors: Calculator implements OnRequest<Sum> and in on_request returns request.a + request.b, while User in initialize sends it Sum { a: 1, b: 2 } and prints the result. Spawn both, join() on them — and Sum: 3 shows up in the log.

Where to grow

The core is done, and it's deliberately minimal — but that's exactly its strength. The runtime knows nothing about concrete messages, so new patterns are added from the outside, in separate crates: this is precisely why we set up a workspace from the start and split request-response out into actors-ext. By the same scheme you can easily bolt on timers and intervals, subscriptions to message streams, supervision with restarts of failed actors, routing, and worker pools. None of it requires touching the core — only new implementations of MessageFor.

Why build this by hand rather than grab something ready-made? Because in a couple of hundred lines you'll understand, end to end, how trait objects, type erasure, watch and oneshot channels, and a hand-rolled Future actually work — the foundation under every grown-up actor and async runtime. And the framework you end up with isn't a teaching toy: it's a clean, extensible base you can genuinely build on, both for experimental AI agents and for production services where every component has its own responsibility and clear boundaries.