Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Hello, World

In this chapter we build a small elfo-based application from the ground up.

The system has two actors:

  • producer sends a stream of numbers to the aggregator.
  • aggregator accumulates a running sum and answers queries about it.

Their interaction is pretty simple:

schema

Note: the full code for this example is available in the elfo repository.

Step 1: Installation

Add the following dependencies in your Cargo.toml:

[dependencies]
# The actor framework itself. The `full` feature enables all built-in batteries:
# configurer, logger, telemeter, dumper, etc.
elfo = { version = "0.2.0-alpha.20", features = ["full"] }

Note: elfo is currently in alpha. Check crates.io for the latest published version and update the version string accordingly.

Put everything in src/main.rs for now. In a real project you would split actors into separate crates, see the Project Structure recipe for guidance, but a single file is fine for learning.

Step 2: Define the protocol

Actors should not depend on each other’s implementation. Instead, they should only depend on shared messages that they exchange. It decouples the actors and makes the system more modular, easier to test.

This decoupling allows you to change the internals of an actor without breaking its clients, and it also makes it easier to reuse actors across projects.

The right place to put shared message types is a protocol module (or, in larger projects, dedicated crates).

mod protocol {
    use elfo::prelude::*;

    // A plain fire-and-forget command.
    // `#[message]` derives elfo::Message, Debug, Clone, Serialize, Deserialize.
    #[message]
    pub struct AddNum {
        pub num: u32,
    }

    // A request: `ret = u64` means the sender expects a `u64` back.
    #[message(ret = u32)]
    pub struct GetSum;
}

Every type that flows through a mailbox must be annotated with #[message]. The macro enforces necessary trait bounds.

Step 3: Write the producer

The producer sends numbers to the aggregator, then asks for the final sum.

mod producer {
    use elfo::prelude::*;

    use crate::protocol::*;

    pub fn new() -> Blueprint {
        ActorGroup::new().exec(|ctx| async move {
            // Send numbers.
            for num in 0..10u32 {
                let _ = ctx.send(AddNum { num }).await;
            }

            // Ask the aggregator for the current sum and wait for the reply.
            match ctx.request(GetSum).resolve().await {
                Ok(sum) => tracing::info!(sum, "done"),
                Err(err) => tracing::error!(%err, "request failed"),
            }
        })
    }
}

ctx.send() is fire-and-forget: it returns as soon as the message lands in the mailbox. ctx.request().resolve().await sends a request and suspends the actor until the response arrives.

Step 4: Write the aggregator

The aggregator keeps a running sum and responds to GetSum queries.

mod aggregator {
    use elfo::prelude::*;

    use crate::protocol::*;

    pub fn new() -> Blueprint {
        ActorGroup::new().exec(|mut ctx| async move {
            let mut sum = 0u32;

            // The main actor loop: receive a message, handle it, repeat.
            // Returns `None` (breaking the loop) when the mailbox is closed.
            while let Some(envelope) = ctx.recv().await {
                msg!(match envelope {
                    AddNum { num } => {
                        sum += num;
                    }
                    // The `(Request, token)` pattern handles a request-response pair.
                    (GetSum, token) => {
                        ctx.respond(token, sum);
                    }
                });
            }
        })
    }
}

msg! is required because Rust’s built-in match only works with a single type, but a mailbox can carry many different message types. The macro unpacks the envelope and dispatches on the inner type while keeping rustfmt-compatible syntax.

Step 5: Wire up the topology

A topology is the wiring diagram: which actor groups exist, how messages flow between them, and which implementations are used.

fn topology() -> elfo::Topology {
    let topology = elfo::Topology::empty();

    // Set up built-in actors (logging, config distribution).
    let logger = elfo::batteries::logger::init();
    let loggers = topology.local("system.loggers");
    let configurers = topology.local("system.configurers").entrypoint();

    // Declare your own groups.
    let producers = topology.local("producers");
    let aggregators = topology.local("aggregators");

    // Messages sent by the producer are forwarded to the aggregator.
    producers.route_all_to(&aggregators);

    // Bind blueprints to groups.
    producers.mount(producer::new());
    aggregators.mount(aggregator::new());
    loggers.mount(logger);
    configurers.mount(elfo::batteries::configurer::fixture(
        &topology,
        elfo::config::AnyConfig::default(),
    ));

    topology
}

We use route_all_to here to forward every message that the producer sends into the aggregator’s mailbox.

The topology itself doesn’t start anything, so we need to call elfo::init::start() to do it:

#[tokio::main]
async fn main() {
    elfo::init::start(topology()).await;
}

elfo::init::start blocks until the system shuts down. The configurer starts first, loads config.toml, and then the rest of the actors begin their exec functions.

Step 6: Run the program

$ cargo run
   Compiling elfo-examples v0.0.0 (/home/code/fave/elfo/examples)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running `target/debug/hello_world`
2026-03-05 09:19:00.228283494  INFO [7661362937482706945] system.configurers/_ - started	addr=3/312073041333thread=tokio-runtime-worker
2026-03-05 09:19:00.228325401  INFO [7661362937482706945] system.configurers/_ - status changed	status=Normal
2026-03-05 09:19:00.228350429  INFO [7661362937482706945] system.configurers/_ - using a fixture
2026-03-05 09:19:00.228381124  INFO [7661362937482706945] system.configurers/_ - status changed	status=Normal	details=validating
2026-03-05 09:19:00.228573146  INFO [7661362937482706945] system.configurers/_ - status changed	status=Normal	details=updating
2026-03-05 09:19:00.228757835  INFO [7661362937482706945] system.configurers/_ - status changed	status=Normal
2026-03-05 09:19:00.228768493  INFO [7661362937482706945] system.loggers/_ - started	addr=2/312073041334	thread=tokio-runtime-worker
2026-03-05 09:19:00.228788056  INFO [7661362937482706945] system.configurers/_ - groups' configs are updated	groups=["system.loggers", "system.configurers", "producers", "aggregators"]
2026-03-05 09:19:00.228800092  INFO [7661362937482706945] producers/_ - started	addr=4/312073041335	thread=tokio-runtime-worker
2026-03-05 09:19:00.228843383  INFO [7661362937482706945] system.loggers/_ - status changed	status=Normal
2026-03-05 09:19:00.228872997  INFO [7661362937482706945] system.configurers/_ - config updated
2026-03-05 09:19:00.228909195  INFO [7661362937482706945] aggregators/_ - started	addr=5/312073041336	thread=tokio-runtime-worker
2026-03-05 09:19:00.228920874  INFO [7661362937482706945] system.init/_ - status changed	status=Normal
2026-03-05 09:19:00.228923888  INFO [7661362937482706945] aggregators/_ - status changed	status=Normal
2026-03-05 09:19:00.228991147  INFO [7661362937482706945] producers/_ - done	sum=45
2026-03-05 09:19:00.229008241  INFO [7661362937482706945] producers/_ - status changed	status=Terminated

Press Ctrl+C to gracefully terminate your program.

What’s next?

You now have a working elfo system. The following chapters go deeper into each concept introduced here.

Check the usage example for a more complex application that demonstrates more features of the framework.