diff --git a/docs/API_ALTERNATIVES_SUMMARY.md b/docs/API_ALTERNATIVES_SUMMARY.md new file mode 100644 index 0000000..19f547b --- /dev/null +++ b/docs/API_ALTERNATIVES_SUMMARY.md @@ -0,0 +1,1257 @@ +# API Redesign: Alternatives Summary + +This document summarizes the different approaches explored for solving three critical API issues in spawned's actor framework. Each approach is illustrated with the **same example** — a chat room with bidirectional communication and runtime discovery — so the trade-offs in expressivity, readability, and ease of use can be compared directly. + +## Table of Contents + +- [The Three Problems](#the-three-problems) +- [The Chat Room Example](#the-chat-room-example) +- [Baseline: The Old API](#baseline-the-old-api-whats-on-main-today) +- [Approach A: Handler\ + Recipient\](#approach-a-handlerm--recipientm-actix-style) +- [Approach B: Protocol Traits](#approach-b-protocol-traits-user-defined-contracts) +- [Approach C: Typed Wrappers](#approach-c-typed-wrappers-non-breaking) +- [Approach D: Derive Macro](#approach-d-derive-macro) +- [Approach E: AnyActorRef](#approach-e-anyactorref-fully-type-erased) +- [Approach F: PID Addressing](#approach-f-pid-addressing-erlang-style) +- [Registry & Service Discovery](#registry--service-discovery) +- [Macro Improvement Potential](#macro-improvement-potential) +- [Comparison Matrix](#comparison-matrix) +- [Recommendation](#recommendation) +- [Branch Reference](#branch-reference) + +--- + +## The Three Problems + +### #144: No per-message type safety + +The original API uses a single enum for all request types and another for all reply types. Every `match` must handle variants that are structurally impossible for the message sent: + +```rust +// Old API — every request returns the full Reply enum +match actor.request(Request::GetName).await? { + Reply::Name(n) => println!("{}", n), + Reply::NotFound => println!("not found"), + Reply::Age(_) => unreachable!(), // impossible, but the compiler demands it +} +``` + +### #145: Circular dependencies between actors + +When two actors need bidirectional communication, storing `ActorRef` and `ActorRef` creates a circular module dependency: + +```rust +// room.rs — needs to send Deliver to Users +struct ChatRoom { members: Vec> } // imports User + +// user.rs — needs to send Say to the Room +struct User { room: ActorRef } // imports ChatRoom → circular! +``` + +### Service discovery: finding actors at runtime + +The examples above wire actors together in `main.rs`: `alice.join_room(room.clone())`. In real systems, actors discover each other at runtime — a new user joining doesn't have a direct reference to the room; it looks it up by name. + +The registry is a global name store (`HashMap>`) that maps names to values. But **what** you store determines what the discoverer gets back — and how much of the actor's API is available without knowing its concrete type: + +```rust +// The question: what type does the discoverer get back? +let room = registry::whereis::("general").unwrap(); + +// A: ActorRef — requires knowing the concrete actor type +// B: BroadcasterRef (Arc) — requires only the protocol +``` + +This is where the #145 solutions diverge most clearly. Approach A's `Recipient` erases at the message level, so discovery returns per-message handles. Approach B's protocol traits erase at the actor level, so discovery returns the full actor API behind a single trait object. + +--- + +## The Chat Room Example + +Every approach below implements the same scenario: + +- **ChatRoom** actor holds a list of members and broadcasts messages +- **User** actor receives messages and can speak to the room +- The room sends `Deliver` to users; users send `Say` to the room → **bidirectional** +- `Members` is a request-reply message that returns the current member list +- The room registers itself by name; a late joiner discovers it via the **registry** + +This exercises all three problems: #144 (typed request-reply), #145 (circular dependency breaking), and service discovery (registry lookup without direct references). + +--- + +## Baseline: The Old API (what's on `main` today) + +Single-enum approach inspired by Erlang's gen_server callbacks: + +```rust +trait Actor: Send + Sized + 'static { + type Request: Clone + Send; // single enum for all call messages + type Message: Clone + Send; // single enum for all cast messages + type Reply: Send; // single enum for all responses + type Error: Debug + Send; + + async fn handle_request(&mut self, msg: Self::Request, ...) -> RequestResponse; + async fn handle_message(&mut self, msg: Self::Message, ...) -> MessageResponse; +} +``` + +**The chat room cannot be built** with the old API as separate modules. There's no type-erasure mechanism, so `ChatRoom` must store `ActorRef` (imports User) while `User` must store `ActorRef` (imports ChatRoom) — circular. You'd have to put everything in a single file or use raw channels. + +Even ignoring #145, the #144 problem means this: + +```rust +// room.rs — all messages in one enum, all replies in another +#[derive(Clone)] +enum RoomRequest { Say { from: String, text: String }, Members } + +#[derive(Clone)] +enum RoomReply { Ack, MemberList(Vec) } + +impl Actor for ChatRoom { + type Request = RoomRequest; + type Reply = RoomReply; + // ... + + async fn handle_request(&mut self, msg: RoomRequest, handle: &ActorRef) -> RequestResponse { + match msg { + RoomRequest::Say { from, text } => { /* broadcast */ RequestResponse::Reply(RoomReply::Ack) } + RoomRequest::Members => RequestResponse::Reply(RoomReply::MemberList(self.member_names())), + } + } +} + +// Caller — must match impossible variants +match room.request(RoomRequest::Members).await? { + RoomReply::MemberList(names) => println!("{:?}", names), + RoomReply::Ack => unreachable!(), // ← impossible but required +} +``` + +**Readability:** The trait signature is self-contained but the enum matching is noisy. New team members must mentally map which reply variants are valid for each request variant — the compiler won't help. + +--- + +## Approach A: Handler\ + Recipient\ (Actix-style) + +**Branches:** [`feat/approach-a`](https://github.com/lambdaclass/spawned/tree/feat/approach-a) (pure Recipient\ + actor_api!), [`feat/actor-macro-registry`](https://github.com/lambdaclass/spawned/tree/de651ad21e2dd39babf534cb74174ae0fe3b399c) (adds macro + registry), [`feat/handler-api-v0.5`](https://github.com/lambdaclass/spawned/tree/34bf9a759cda72e5311efda8f1fc8a5ae515129a) (early implementation), [`feat/critical-api-issues`](https://github.com/lambdaclass/spawned/tree/1ef33bf0c463543dca379463c554ccc5914c86ff) (design doc) + +**Status:** Fully implemented on `feat/approach-a`. 34 tests passing. All examples rewritten to pure Recipient\ + actor_api! pattern. + +Each message is its own struct with an associated `Result` type. Actors implement `Handler` per message. Type erasure uses `Recipient = Arc>`. + +### Without macro (manual `impl Handler`) + +
+room.rs — defines Room's messages, imports Deliver from user + +```rust +use spawned_concurrency::message::Message; +use spawned_concurrency::messages; +use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; +use crate::user::Deliver; + +// -- Messages (Room handles these) -- + +messages! { + Say { from: String, text: String } -> (); +} + +pub struct Join { + pub name: String, + pub inbox: Recipient, +} +impl Message for Join { type Result = (); } + +// -- Actor -- + +pub struct ChatRoom { + members: Vec<(String, Recipient)>, +} + +impl Actor for ChatRoom {} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Join, _ctx: &Context) { + self.members.push((msg.name, msg.inbox)); + } +} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Say, _ctx: &Context) { + for (name, inbox) in &self.members { + if *name != msg.from { + let _ = inbox.send(Deliver { from: msg.from.clone(), text: msg.text.clone() }); + } + } + } +} +``` +
+ +
+user.rs — defines User's messages (including Deliver), imports Say from room + +```rust +use spawned_concurrency::messages; +use spawned_concurrency::tasks::{Actor, Context, Handler, Recipient}; +use crate::room::Say; + +// -- Messages (User handles these) -- + +messages! { + Deliver { from: String, text: String } -> (); + SayToRoom { text: String } -> (); +} + +// -- Actor -- + +pub struct User { + pub name: String, + pub room: Recipient, +} + +impl Actor for User {} + +impl Handler for User { + async fn handle(&mut self, msg: SayToRoom, _ctx: &Context) { + let _ = self.room.send(Say { from: self.name.clone(), text: msg.text }); + } +} + +impl Handler for User { + async fn handle(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + } +} +``` +
+ +
+main.rs + +```rust +let room = ChatRoom::new().start(); +let alice = User { name: "Alice".into(), room: room.recipient() }.start(); +let bob = User { name: "Bob".into(), room: room.recipient() }.start(); + +room.send_request(Join { name: "Alice".into(), inbox: alice.recipient::() }).await?; +room.send_request(Join { name: "Bob".into(), inbox: bob.recipient::() }).await?; + +alice.send_request(SayToRoom { text: "Hello everyone!".into() }).await?; +``` +
+ +### With `#[actor]` macro + `actor_api!` + +
+room.rs — macros eliminate both Handler and extension trait boilerplate + +```rust +use spawned_concurrency::actor_api; +use spawned_concurrency::send_messages; +use spawned_concurrency::request_messages; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler, Recipient}; +use spawned_macros::actor; +use crate::user::Deliver; + +// -- Messages -- + +send_messages! { + Say { from: String, text: String }; + Join { name: String, inbox: Recipient } +} + +request_messages! { + Members -> Vec +} + +// -- API -- + +actor_api! { + pub ChatRoomApi for ActorRef { + send fn say(from: String, text: String) => Say; + send fn add_member(name: String, inbox: Recipient) => Join; + request async fn members() -> Vec => Members; + } +} + +// -- Actor -- + +pub struct ChatRoom { + members: Vec<(String, Recipient)>, +} + +impl Actor for ChatRoom {} + +#[actor] +impl ChatRoom { + pub fn new() -> Self { + Self { members: Vec::new() } + } + + #[send_handler] + async fn handle_say(&mut self, msg: Say, _ctx: &Context) { + for (name, inbox) in &self.members { + if *name != msg.from { + let _ = inbox.send(Deliver { from: msg.from.clone(), text: msg.text.clone() }); + } + } + } + + #[send_handler] + async fn handle_join(&mut self, msg: Join, _ctx: &Context) { + self.members.push((msg.name, msg.inbox)); + } + + #[request_handler] + async fn handle_members(&mut self, _msg: Members, _ctx: &Context) -> Vec { + self.members.iter().map(|(name, _)| name.clone()).collect() + } +} +``` +
+ +
+user.rs — defines Deliver (User's inbox message) + macro version + +```rust +use spawned_concurrency::actor_api; +use spawned_concurrency::send_messages; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use spawned_macros::actor; +use crate::room::{ChatRoom, ChatRoomApi}; + +// -- Messages -- + +send_messages! { + Deliver { from: String, text: String }; + SayToRoom { text: String }; + JoinRoom { room: ActorRef } +} + +// -- API -- + +actor_api! { + pub UserApi for ActorRef { + send fn say(text: String) => SayToRoom; + send fn join_room(room: ActorRef) => JoinRoom; + } +} + +// -- Actor -- + +pub struct User { + pub name: String, + room: Option>, +} + +impl Actor for User {} + +#[actor] +impl User { + pub fn new(name: String) -> Self { + Self { name, room: None } + } + + #[send_handler] + async fn handle_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { + if let Some(ref room) = self.room { + let _ = room.say(self.name.clone(), msg.text); + } + } + + #[send_handler] + async fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { + let _ = msg.room.add_member(self.name.clone(), ctx.recipient::()); + self.room = Some(msg.room); + } + + #[send_handler] + async fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + } +} +``` +
+ +
+main.rs — extension traits make it read like plain method calls + +```rust +let room = ChatRoom::new().start(); +let alice = User::new("Alice".into()).start(); +let bob = User::new("Bob".into()).start(); + +// Register the room by name +registry::register("general", room.clone()).unwrap(); + +alice.join_room(room.clone()).unwrap(); +bob.join_room(room.clone()).unwrap(); + +let members = room.members().await.unwrap(); + +alice.say("Hello everyone!".into()).unwrap(); +bob.say("Hi Alice!".into()).unwrap(); + +// Late joiner discovers the room — must know the concrete type +let charlie = User::new("Charlie".into()).start(); +let discovered: ActorRef = registry::whereis("general").unwrap(); +charlie.join_room(discovered).unwrap(); +``` +
+ +### Analysis + +| Dimension | Non-macro | With `#[actor]` macro + `actor_api!` | +|-----------|-----------|--------------------------------------| +| **Readability** | Each `impl Handler` block is self-contained. Messages live in the module that handles them. Bidirectional imports between sibling modules (room↔user) but no shared `messages.rs`. | `#[send_handler]`/`#[request_handler]` attributes inside a single `#[actor] impl` block group all handlers together. `actor_api!` declares the caller-facing API in a compact block. Files read top-to-bottom: Messages → API → Actor. | +| **API at a glance** | Must scan all `impl Handler` blocks to know what messages an actor handles. | The `actor_api!` block is the "at-a-glance" API surface — each line declares a method, its params, and the underlying message. | +| **Boilerplate** | One `impl Handler` block per message × per actor. Message structs need manual `impl Message`. | `send_messages!`/`request_messages!` macros eliminate `Message` impls. `#[actor]` eliminates `Handler` impls. `actor_api!` reduces the extension trait + impl (~15 lines) to ~5 lines. | +| **main.rs expressivity** | Raw message structs: `room.send_request(Join { ... })` — explicit but verbose. | Extension traits: `alice.join_room(room.clone())` — reads like natural API calls. | +| **Circular dep solution** | `Recipient` — room stores `Recipient`, user stores `Recipient`. Bidirectional module imports (room imports `Deliver` from user, user imports `Say` from room) — works within a crate but not across crates. | Same mechanism. The macros don't change how type erasure works. | +| **Registry** | Register individual `Recipient` per message type. Fine-grained but requires multiple registrations for a multi-message actor. | Register `ActorRef` — gives full API via `ChatRoomApi`, but the discoverer must know the concrete actor type. | +| **Discoverability** | Standard Rust patterns. Any Rust developer can read `impl Handler`. | `#[actor]` and `actor_api!` are custom — new developers need to learn what they do, but the patterns are common (Actix uses the same approach). | + +**Key insight:** The non-macro version is already concise for handler code. The `#[actor]` macro eliminates the `impl Handler` delegation wrapper per handler. The `actor_api!` macro eliminates the extension trait boilerplate (trait definition + impl block) that provides ergonomic method-call syntax on `ActorRef`. Together, they reduce an actor definition to three declarative blocks: messages, API, and handlers. + +**Cross-crate limitation:** In the macro version, `Deliver` lives in `user.rs` (the actor that handles it) and room imports it — creating a bidirectional module dependency. This works within a single crate (sibling modules can reference each other), but Rust forbids circular crate dependencies. If Room and User were in separate crates, you'd need to extract shared types (`Deliver`, `ChatRoomApi`) into a third crate — effectively recreating Approach B's `protocols.rs` pattern. This is the main motivation for Approach B: its `protocols.rs` structure maps directly to a separate crate with zero restructuring. + +--- + +## Approach B: Protocol Traits (user-defined contracts) + +**Branch:** [`feat/approach-b`](https://github.com/lambdaclass/spawned/tree/feat/approach-b) (protocol_impl! macro + Context::actor_ref()) + +**Status:** Fully implemented on `feat/approach-b`. 34 tests passing. All examples rewritten to protocol traits with `protocol_impl!` macro. + +Uses the same `Handler` and `#[actor]` macro as Approach A for #144. Solves #145 differently: instead of `Recipient`, actors communicate across boundaries via explicit user-defined trait objects. + +**Key improvements over the initial WIP:** Type aliases (`BroadcasterRef`, `ParticipantRef`) replace raw `Arc`, conversion traits (`AsBroadcaster`, `AsParticipant`) replace `Arc::new(x.clone())`, `Response` enables async request-response on protocol traits without breaking object safety, `protocol_impl!` generates bridge impls from a compact declaration, and `Context::actor_ref()` lets actors obtain their own `ActorRef` for self-registration. + +### Response\: Envelope's counterpart on the receive side + +The existing codebase uses the **Envelope pattern** to type-erase messages on the send side: `Box>` wraps a message + a oneshot sender, allowing the actor's mailbox to hold heterogeneous messages. `Response` is the structural mirror on the receive side — it wraps a oneshot receiver and implements `Future>`: + +```rust +// Envelope (existing): type-erases on the SEND side +// Box> holds msg + response sender + +// Response (new): concrete awaitable on the RECEIVE side +// wraps oneshot::Receiver, implements Future +pub struct Response(oneshot::Receiver); + +impl Future for Response { + type Output = Result; + fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context) -> Poll { + // delegates to inner receiver + } +} +``` + +This keeps protocol traits **object-safe** — `fn members(&self) -> Response>` returns a concrete type, not `impl Future` (which would require RPITIT and break `dyn Trait`). No `BoxFuture` boxing needed either. + +### Full chat room code + +
+protocols.rs — shared contracts with type aliases, Response<T>, and conversion traits + +```rust +use spawned_concurrency::error::ActorError; +use spawned_concurrency::tasks::Response; +use std::sync::Arc; + +pub type BroadcasterRef = Arc; +pub type ParticipantRef = Arc; + +pub trait ChatBroadcaster: Send + Sync { + fn say(&self, from: String, text: String) -> Result<(), ActorError>; + fn add_member(&self, name: String, participant: ParticipantRef) -> Result<(), ActorError>; + fn members(&self) -> Response>; +} + +pub trait ChatParticipant: Send + Sync { + fn deliver(&self, from: String, text: String) -> Result<(), ActorError>; +} + +pub trait AsBroadcaster { + fn as_broadcaster(&self) -> BroadcasterRef; +} + +// Identity conversion — enables registry discovery: +// let room: BroadcasterRef = registry::whereis("general").unwrap(); +// charlie.join_room(room).unwrap(); // works because BroadcasterRef: AsBroadcaster +impl AsBroadcaster for BroadcasterRef { + fn as_broadcaster(&self) -> BroadcasterRef { + Arc::clone(self) + } +} + +pub trait AsParticipant { + fn as_participant(&self) -> ParticipantRef; +} +``` +
+ +
+room.rs — Messages → protocol_impl! → Conversion → Actor + +```rust +use spawned_concurrency::protocol_impl; +use spawned_concurrency::request_messages; +use spawned_concurrency::send_messages; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use spawned_macros::actor; +use std::sync::Arc; +use crate::protocols::{AsBroadcaster, BroadcasterRef, ChatBroadcaster, ParticipantRef}; + +// -- Internal messages -- + +send_messages! { + Say { from: String, text: String }; + Join { name: String, participant: ParticipantRef } +} + +request_messages! { + Members -> Vec +} + +// -- Protocol bridge -- + +protocol_impl! { + ChatBroadcaster for ActorRef { + send fn say(from: String, text: String) => Say; + send fn add_member(name: String, participant: ParticipantRef) => Join; + request fn members() -> Vec => Members; + } +} + +impl AsBroadcaster for ActorRef { + fn as_broadcaster(&self) -> BroadcasterRef { + Arc::new(self.clone()) + } +} + +// -- Actor -- + +pub struct ChatRoom { + members: Vec<(String, ParticipantRef)>, +} + +impl Actor for ChatRoom {} + +#[actor] +impl ChatRoom { + pub fn new() -> Self { + Self { members: Vec::new() } + } + + #[send_handler] + async fn handle_join(&mut self, msg: Join, _ctx: &Context) { + tracing::info!("[room] {} joined", msg.name); + self.members.push((msg.name, msg.participant)); + } + + #[send_handler] + async fn handle_say(&mut self, msg: Say, _ctx: &Context) { + tracing::info!("[room] {} says: {}", msg.from, msg.text); + for (name, participant) in &self.members { + if *name != msg.from { + let _ = participant.deliver(msg.from.clone(), msg.text.clone()); + } + } + } + + #[request_handler] + async fn handle_members(&mut self, _msg: Members, _ctx: &Context) -> Vec { + self.members.iter().map(|(name, _)| name.clone()).collect() + } +} +``` +
+ +
+user.rs — protocol_impl! bridge + UserActions trait + actor + +```rust +use spawned_concurrency::error::ActorError; +use spawned_concurrency::protocol_impl; +use spawned_concurrency::send_messages; +use spawned_concurrency::tasks::{Actor, ActorRef, Context, Handler}; +use spawned_macros::actor; +use std::sync::Arc; +use crate::protocols::{AsBroadcaster, AsParticipant, BroadcasterRef, ChatParticipant, ParticipantRef}; + +// -- Internal messages -- + +send_messages! { + Deliver { from: String, text: String }; + SayToRoom { text: String }; + JoinRoom { room: BroadcasterRef } +} + +// -- Protocol bridge (ChatParticipant) -- + +protocol_impl! { + ChatParticipant for ActorRef { + send fn deliver(from: String, text: String) => Deliver; + } +} + +impl AsParticipant for ActorRef { + fn as_participant(&self) -> ParticipantRef { + Arc::new(self.clone()) + } +} + +// -- Caller API -- + +pub trait UserActions { + fn say(&self, text: String) -> Result<(), ActorError>; + fn join_room(&self, room: impl AsBroadcaster) -> Result<(), ActorError>; +} + +impl UserActions for ActorRef { + fn say(&self, text: String) -> Result<(), ActorError> { + self.send(SayToRoom { text }) + } + + fn join_room(&self, room: impl AsBroadcaster) -> Result<(), ActorError> { + self.send(JoinRoom { room: room.as_broadcaster() }) + } +} + +// -- Actor -- + +pub struct User { + name: String, + room: Option, +} + +impl Actor for User {} + +#[actor] +impl User { + pub fn new(name: String) -> Self { + Self { name, room: None } + } + + #[send_handler] + async fn handle_join_room(&mut self, msg: JoinRoom, ctx: &Context) { + let _ = msg.room.add_member(self.name.clone(), ctx.actor_ref().as_participant()); + self.room = Some(msg.room); + } + + #[send_handler] + async fn handle_say_to_room(&mut self, msg: SayToRoom, _ctx: &Context) { + if let Some(ref room) = self.room { + let _ = room.say(self.name.clone(), msg.text); + } + } + + #[send_handler] + async fn handle_deliver(&mut self, msg: Deliver, _ctx: &Context) { + tracing::info!("[{}] got: {} says '{}'", self.name, msg.from, msg.text); + } +} +``` +
+ +
+main.rs — identical body to Approach A, protocol-level discovery + +```rust +let room = ChatRoom::new().start(); +let alice = User::new("Alice".into()).start(); +let bob = User::new("Bob".into()).start(); + +// Register the room's protocol — not the concrete type +registry::register("general", room.as_broadcaster()).unwrap(); + +alice.join_room(room.clone()).unwrap(); +bob.join_room(room.clone()).unwrap(); + +let members = room.members().await.unwrap(); + +alice.say("Hello everyone!".into()).unwrap(); +bob.say("Hey Alice!".into()).unwrap(); + +// Late joiner discovers the room — only needs the protocol, not the concrete type +let charlie = User::new("Charlie".into()).start(); +let discovered: BroadcasterRef = registry::whereis("general").unwrap(); +charlie.join_room(discovered).unwrap(); +``` +
+ +### Analysis + +| Dimension | Assessment | +|-----------|-----------| +| **Readability** | `protocols.rs` is an excellent summary of what crosses the actor boundary. Type aliases (`BroadcasterRef`, `ParticipantRef`) eliminate raw `Arc` noise. Files read top-to-bottom: Messages → Bridge → Conversion → Actor. | +| **API at a glance** | Protocol traits serve as the natural API contract for cross-actor boundaries. Looking at `ChatBroadcaster` tells you exactly what a room can do, with named methods and signatures — the strongest "at-a-glance" surface of all approaches. `UserActions` trait provides the direct caller API. | +| **Boilerplate** | Higher than Approach A per cross-actor boundary: protocol trait + `protocol_impl!` bridge + message structs + Handler impls. Mitigated by type aliases, conversion traits, and `protocol_impl!` macro. | +| **main.rs expressivity** | Identical body to A: `alice.join_room(room.clone())`, `room.members().await.unwrap()`, `alice.say(...)`. `join_room` accepts `impl AsBroadcaster` so callers pass `ActorRef` directly. | +| **Request-response** | `Response` keeps protocol traits object-safe while supporting async request-response. Structural mirror of the Envelope pattern — no RPITIT, no `BoxFuture` boxing. | +| **Circular dep solution** | Actors hold `BroadcasterRef` / `ParticipantRef` instead of `ActorRef`. Each new cross-boundary message requires adding a method to the protocol trait + updating the `protocol_impl!` bridge. | +| **Registry** | Register `BroadcasterRef` — one registration gives the discoverer the full protocol API (`say`, `add_member`, `members`). No concrete actor type needed. Identity `AsBroadcaster` impl on `BroadcasterRef` means discovered refs pass directly to `join_room`. | +| **Macro compatibility** | `#[actor]` for Handler impls, `protocol_impl!` for bridge impls. Direct caller APIs use manual trait impls when generic params are needed (e.g., `join_room(impl AsBroadcaster)`). | +| **Testability** | Best of all approaches — you can mock `ChatBroadcaster` or `ChatParticipant` directly in unit tests without running an actor system. | + +**Key insight:** Protocol traits define contracts at the actor level (like Erlang behaviours) rather than the message level (like Actix's `Recipient`). The duplication cost (protocol method mirrors message struct) is real but buys three things: (1) testability via trait mocking, (2) a natural "API at a glance" surface, and (3) actor-level granularity for registry and discovery. With `Response`, type aliases, conversion traits, and `protocol_impl!`, B's main.rs body is identical to A's. + +**Scaling trade-off:** In a system with N actor types and M cross-boundary message types, Approach A needs M message structs. Approach B needs M message structs + P protocol traits + P bridge impls, where P grows with distinct actor-to-actor interaction patterns. The extra cost scales with *interaction patterns*, not messages — and each protocol trait is a natural documentation + testing boundary. + +**Cross-crate scaling:** In Approach A, the bidirectional module dependency (room imports `Deliver` from user, user imports `ChatRoomApi` from room) works because they're sibling modules in the same crate. If actors lived in separate crates, this would be a circular crate dependency — which Rust forbids. The fix is extracting shared types (`Deliver`, `ChatRoomApi`) into a third crate, at which point you've essentially reinvented `protocols.rs`. Approach B's structure maps directly to separate crates with zero restructuring: `protocols` becomes a shared crate, and each actor crate depends only on it, never on each other. + +--- + +## Approach C: Typed Wrappers (non-breaking) + +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](https://github.com/lambdaclass/spawned/blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). + +Keeps the old enum-based `Actor` trait unchanged. Adds typed convenience methods that hide the enum matching. For #145, adds a second envelope-based channel to `ActorRef` alongside the existing enum channel. + +### What the chat room would look like + +
+room.rs — enum Actor + typed wrappers + dual channel + +```rust +// Old-style enum messages (unchanged from baseline) +#[derive(Clone)] +pub enum RoomMessage { + Say { from: String, text: String }, + Join { name: String }, +} + +#[derive(Clone)] +pub enum RoomRequest { Members } + +#[derive(Clone)] +pub enum RoomReply { Ack, MemberList(Vec) } + +pub struct ChatRoom { + members: Vec<(String, Recipient)>, // Recipient comes from new dual-channel +} + +impl Actor for ChatRoom { + type Request = RoomRequest; + type Message = RoomMessage; + type Reply = RoomReply; + type Error = std::fmt::Error; + + async fn handle_message(&mut self, msg: RoomMessage, handle: &ActorRef) -> MessageResponse { + match msg { + RoomMessage::Say { from, text } => { + for (name, inbox) in &self.members { + if *name != from { + let _ = inbox.send(Deliver { from: from.clone(), text: text.clone() }); + } + } + MessageResponse::NoReply + } + RoomMessage::Join { name } => { + // But wait — where does the Recipient come from? + // The enum variant can't carry it (Clone bound on Message). + // This is a fundamental limitation. + MessageResponse::NoReply + } + } + } + + async fn handle_request(&mut self, msg: RoomRequest, _: &ActorRef) -> RequestResponse { + match msg { + RoomRequest::Members => { + let names = self.members.iter().map(|(n, _)| n.clone()).collect(); + RequestResponse::Reply(RoomReply::MemberList(names)) + } + } + } +} + +// Typed wrappers hide the enum matching from callers +impl ChatRoom { + pub fn say(handle: &ActorRef, from: String, text: String) -> Result<(), ActorError> { + handle.send(RoomMessage::Say { from, text }) + } + pub async fn members(handle: &ActorRef) -> Result, ActorError> { + match handle.request(RoomRequest::Members).await? { + RoomReply::MemberList(names) => Ok(names), + _ => unreachable!(), // still exists, just hidden inside the wrapper + } + } +} + +// For #145: Handler impl on the SECOND channel (envelope-based) +// The actor loop select!s on both the enum channel and the envelope channel +impl Handler for ChatRoom { /* ... */ } +``` +
+ +### Analysis + +| Dimension | Assessment | +|-----------|-----------| +| **Readability** | Poor. Two dispatch mechanisms coexist: the old `match msg { ... }` for enum messages and `Handler` impls on the envelope channel. A reader must understand both systems and how they interact. | +| **API at a glance** | The typed wrappers (`ChatRoom::say(...)`, `ChatRoom::members(...)`) provide a clean caller API. But the implementation behind them is messy. | +| **Boilerplate** | High. Every message needs: enum variant + typed wrapper + match arm. And `unreachable!()` branches still exist inside the wrappers. Cross-boundary messages also need `Handler` impls. | +| **main.rs expressivity** | `ChatRoom::say(&room, from, text)` — associated functions, not method syntax on ActorRef. Less ergonomic than extension traits. | +| **Fundamental problem** | The old `Message` type requires `Clone`, but `Recipient` is `Arc` which doesn't implement `Clone` in all contexts. The `Join` message can't carry a Recipient through the enum channel. This forces cross-boundary messages onto the second channel, splitting the actor's logic across two systems. | + +**Key insight:** This approach tries to preserve backward compatibility, but the dual-channel architecture creates more confusion than a clean break would. The `Clone` bound on the old `Message` associated type is fundamentally incompatible with carrying type-erased handles, making the split between channels unavoidable and arbitrary. + +--- + +## Approach D: Derive Macro + +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](https://github.com/lambdaclass/spawned/blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). + +A proc macro `#[derive(ActorMessages)]` auto-generates per-variant message structs, `Message` impls, typed wrappers, and `Handler` delegation from an annotated enum. + +### What the chat room would look like + +
+room.rs — derive macro generates everything from the enum + +```rust +use spawned_derive::ActorMessages; + +// The macro generates: struct Say, struct Join, struct Members, +// impl Message for each, typed wrapper methods, and Handler delegation +#[derive(ActorMessages)] +#[actor(ChatRoom)] +pub enum RoomMessages { + #[send] + Say { from: String, text: String }, + + #[send] + Join { name: String, inbox: Recipient }, + + #[request(Vec)] + Members, +} + +pub struct ChatRoom { + members: Vec<(String, Recipient)>, +} + +impl Actor for ChatRoom {} + +// You still write the old-style handle_request/handle_message, +// but the macro routes per-struct Handler calls into it. +// OR: the macro generates Handler impls that call per-variant methods: +impl ChatRoom { + fn on_say(&mut self, msg: Say, ctx: &Context) { /* ... */ } + fn on_join(&mut self, msg: Join, ctx: &Context) { /* ... */ } + fn on_members(&mut self, msg: Members, ctx: &Context) -> Vec { /* ... */ } +} +``` +
+ +
+main.rs — generated wrapper methods + +```rust +let room = ChatRoom::new().start(); +// Generated methods (associated functions on ActorRef): +room.say("Alice".into(), "Hello!".into()).unwrap(); +let members = room.members().await.unwrap(); +``` +
+ +### Analysis + +| Dimension | Assessment | +|-----------|-----------| +| **Readability** | The enum definition is compact, but what the macro generates is invisible. Reading `room.rs` tells you the message *names*, but you can't see the generated Handler impls, wrapper methods, or error handling without running `cargo expand`. | +| **API at a glance** | The annotated enum is a good summary of all messages. `#[send]` vs `#[request(ReturnType)]` makes the distinction clear. | +| **Boilerplate** | Lowest of all approaches for defining messages — one enum covers everything. But debugging generated code is costly when things go wrong (compile errors point to generated code). | +| **main.rs expressivity** | Generated wrappers would provide method-call syntax. Comparable to Approach A's extension traits, but with less control over the API shape. | +| **Complexity** | A new proc macro crate (compilation cost). The macro must handle edge cases: messages carrying `Recipient`, mixed send/request variants, `Clone` bounds for the enum vs non-Clone fields. This is the most complex approach to implement correctly. | +| **Macro compatibility** | This IS the macro — it replaces both `send_messages!`/`request_messages!` and `#[actor]`. Larger blast radius means more things that can break. | + +**Key insight:** The derive macro trades visibility for conciseness. Approach A's `#[actor]` macro is lighter — it only generates `impl Handler` delegation from visibly-written handler methods. The derive macro tries to generate the handler methods too, making the actor's behavior harder to trace. + +--- + +## Approach E: AnyActorRef (fully type-erased) + +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](https://github.com/lambdaclass/spawned/blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). + +Replaces `Recipient` with a single fully type-erased handle `AnyActorRef = Arc` using `Box`. + +### What the chat room would look like + +
+room.rs + +```rust +pub struct ChatRoom { + members: Vec<(String, AnyActorRef)>, // no type parameter — stores anything +} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Say, _ctx: &Context) { + for (name, inbox) in &self.members { + if *name != msg.from { + // Runtime type dispatch — if inbox can't handle Deliver, it's a silent error + let _ = inbox.send_any(Box::new(Deliver { + from: msg.from.clone(), + text: msg.text.clone(), + })); + } + } + } +} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Join, _ctx: &Context) { + self.members.push((msg.name, msg.inbox)); // just stores AnyActorRef + } +} +``` +
+ +
+user.rs + +```rust +pub struct User { + pub name: String, + pub room: AnyActorRef, // no type safety — could be any actor +} + +impl Handler for User { + async fn handle(&mut self, msg: SayToRoom, _ctx: &Context) { + // Must Box the message and hope the room can handle it + let _ = self.room.send_any(Box::new(Say { + from: self.name.clone(), + text: msg.text, + })); + } +} +``` +
+ +
+main.rs + +```rust +let room = ChatRoom::new().start(); +let alice = User { name: "Alice".into(), room: room.any_ref() }.start(); + +// Joining — also type-erased +room.send(Join { name: "Alice".into(), inbox: alice.any_ref() }).unwrap(); + +// Requesting members — must downcast the reply +let reply: Box = room.request_any(Box::new(Members)).await?; +let members: Vec = *reply.downcast::>().expect("wrong reply type"); +``` +
+ +### Analysis + +| Dimension | Assessment | +|-----------|-----------| +| **Readability** | The actor code is cluttered with `Box::new()`, `send_any()`, and `downcast()`. The type information that was available at compile time is now lost, making the code harder to reason about. | +| **API at a glance** | `AnyActorRef` tells you nothing about what messages an actor can receive. You must read the `Handler` impls to know, and even then the caller has no compile-time enforcement. | +| **Boilerplate** | Low for cross-boundary wiring (just `AnyActorRef` everywhere). But higher for callers who must box/downcast. | +| **main.rs expressivity** | Poor. `room.request_any(Box::new(Members))` followed by `.downcast::>()` is verbose and error-prone. Compare to Approach A's `room.request(Members).await` → `Vec`. | +| **Safety** | Sending the wrong message type is a **runtime** error (or silently ignored). This defeats Rust's core value proposition. | + +**Key insight:** AnyActorRef is essentially what you get in dynamically-typed languages. It solves #145 by erasing all type information, but in doing so also erases the compile-time safety that Rust provides. Wrong message types become runtime panics instead of compile errors. + +--- + +## Approach F: PID Addressing (Erlang-style) + +**Branch:** Not implemented. Documented in [`docs/ALTERNATIVE_APPROACHES.md`](https://github.com/lambdaclass/spawned/blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md). + +Every actor gets a `Pid(u64)`. A global registry maps `(Pid, TypeId)` → message sender. Messages are sent by PID with explicit registration per message type. + +### What the chat room would look like + +
+room.rs + +```rust +pub struct ChatRoom { + members: Vec<(String, Pid)>, // lightweight copyable identifier +} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Say, _ctx: &Context) { + for (name, pid) in &self.members { + if *name != msg.from { + // Typed send — but resolved at runtime via global registry + let _ = spawned::send(*pid, Deliver { + from: msg.from.clone(), + text: msg.text.clone(), + }); + } + } + } +} + +impl Handler for ChatRoom { + async fn handle(&mut self, msg: Join, _ctx: &Context) { + self.members.push((msg.name, msg.pid)); + } +} +``` +
+ +
+user.rs + +```rust +pub struct User { + pub name: String, + pub room_pid: Pid, // just a u64 +} + +impl Handler for User { + async fn handle(&mut self, msg: SayToRoom, _ctx: &Context) { + let _ = spawned::send(self.room_pid, Say { + from: self.name.clone(), + text: msg.text, + }); + } +} +``` +
+ +
+main.rs — requires explicit registration + +```rust +let room = ChatRoom::new().start(); +let alice = User { name: "Alice".into(), room_pid: room.pid() }.start(); + +// Must register each message type the actor can receive via PID +room.register::(); +room.register::(); +room.register::(); +alice.register::(); +alice.register::(); + +room.send(Join { name: "Alice".into(), pid: alice.pid() }).unwrap(); + +// Typed request — but only works if Members was registered +let members: Vec = spawned::request(room.pid(), Members).await?; +``` +
+ +### Analysis + +| Dimension | Assessment | +|-----------|-----------| +| **Readability** | Actor code is clean — `spawned::send(pid, msg)` is simple and Erlang-familiar. But the registration boilerplate in `main.rs` is noisy and easy to forget. | +| **API at a glance** | `Pid` tells you nothing about what messages an actor accepts. You know less than with `ActorRef` (which at least tells you the actor type) or `Recipient` (which tells you the message type). | +| **Boilerplate** | Per-actor registration of every message type: `room.register::()`, `room.register::()`, etc. Forgetting a registration → runtime error. | +| **main.rs expressivity** | `spawned::send(pid, msg)` is concise. But registration lines are pure ceremony with no business logic value. | +| **Safety** | Sending to a dead PID or unregistered message type → **runtime** error. The compile-time guarantee "this actor handles this message" is lost. | +| **Clustering** | Best positioned for distributed systems — `Pid` is a location-transparent identifier that naturally extends to remote nodes. | + +**Key insight:** PID addressing is the most Erlang-faithful approach, and shines for clustering/distribution. But it trades Rust's compile-time type safety for runtime resolution, which is a cultural mismatch. Erlang's runtime was designed around "let it crash" — Rust's philosophy is "don't let it compile if it's wrong." + +--- + +## Registry & Service Discovery + +The registry is a global `Any`-based name store: `HashMap>` with `RwLock`. The API (`register`, `whereis`, `unregister`, `registered`) stays the same across approaches. What changes is **what you store and what you get back** — and this is where Approach A and B diverge most visibly. + +The chat room examples above demonstrate this. Both register the room as `"general"` and a late joiner (Charlie) discovers it: + +```rust +// Approach A — stores and retrieves the concrete type +registry::register("general", room.clone()).unwrap(); // ActorRef +let discovered: ActorRef = registry::whereis("general").unwrap(); // caller must know ChatRoom + +// Approach B — stores and retrieves the protocol +registry::register("general", room.as_broadcaster()).unwrap(); // BroadcasterRef +let discovered: BroadcasterRef = registry::whereis("general").unwrap(); // caller only needs the protocol +charlie.join_room(discovered).unwrap(); // works via AsBroadcaster +``` + +In A, the discoverer must import `ChatRoom` and `ChatRoomApi` to use the retrieved reference — it knows exactly which actor it's talking to. In B, the discoverer imports only `BroadcasterRef` from `protocols.rs` — it knows *what the actor can do* without knowing *what actor it is*. Any actor implementing `ChatBroadcaster` could be behind that reference. + +### How it differs per approach + +| Approach | Stored value | Retrieved as | Type safety | Discovery granularity | +|----------|-------------|-------------|-------------|----------------------| +| **Baseline** | `ActorRef
` | `ActorRef` | Compile-time, but requires knowing actor type | Per actor — defeats the point of discovery | +| **A: Recipient** | `ActorRef` or `Recipient` | Same | Compile-time, but `ActorRef` requires concrete type; `Recipient` is per-message | Per actor (full API but coupled) or per message (decoupled but fragmented) | +| **B: Protocol Traits** | `Arc` | `Arc` | Compile-time per protocol | Per protocol — one registration, full API, no concrete type | +| **C: Typed Wrappers** | `ActorRef` or `Recipient` | Mixed | Depends on channel | Unclear — dual-channel split | +| **D: Derive Macro** | `Recipient` | `Recipient` | Same as A | Same as A | +| **E: AnyActorRef** | `AnyActorRef` | `AnyActorRef` | None — runtime only | Per actor, but no type info | +| **F: PID** | `Pid` | `Pid` | None — runtime only | Per actor (Erlang-style `whereis`) | + +**Key differences:** + +- **A** faces a trade-off: register `ActorRef` for the full API (via `ChatRoomApi` extension trait), but the discoverer must know the concrete type — or register individual `Recipient` handles for type-erased per-message access, but then you need multiple registrations and the discoverer can only send one message type per handle. The chat room example uses `ActorRef` because `ChatRoomApi` provides the natural caller API. + +- **B** registers per protocol: `registry::register("general", room.as_broadcaster())`. A consumer discovers a `BroadcasterRef` (`Arc`) — it can call any method on the protocol (`say`, `add_member`, `members`). One registration, full API, no concrete type needed. This maps directly to Erlang's `register/whereis` pattern but with compile-time safety. + +- **E** is trivially simple but useless: `registry::register("room", room.any_ref())`. You get back an `AnyActorRef` that accepts `Box`. No compile-time knowledge of what messages the actor handles. + +- **F** is the most natural fit for a registry. The registry maps `name → Pid`, and PID-based dispatch handles the rest. This mirrors Erlang exactly: `register(room, Pid)`, `whereis(room) → Pid`. The registry is simple; the complexity moves to the PID dispatch table. But the same runtime safety concern applies — sending to a Pid that doesn't handle the message type fails at runtime. + +--- + +## Macro Improvement Potential + +Approach A's `actor_api!` macro eliminates extension trait boilerplate by generating a trait + impl from a compact declaration. Could similar macros reduce boilerplate in the other approaches? + +### Approach B: Protocol Traits — DONE (`protocol_impl!`) + +B now uses `protocol_impl!` to generate bridge impls from a compact declaration. What was ~12 lines of manual bridge boilerplate per actor is now ~5 lines: + +```rust +// protocol_impl! generates the full impl block +protocol_impl! { + ChatBroadcaster for ActorRef { + send fn say(from: String, text: String) => Say; + send fn add_member(name: String, participant: ParticipantRef) => Join; + request fn members() -> Vec => Members; + } +} +``` + +Each `send fn` generates `fn method(&self, ...) -> Result<(), ActorError> { self.send(Msg { ... }) }`. Each `request fn` generates `fn method(&self, ...) -> Response { Response::from(self.request_raw(Msg)) }`. The protocol trait itself remains user-defined — it IS the contract, so it should stay hand-written. + +Conversion traits (`AsBroadcaster`, `AsParticipant`) are still manual (~4 lines each) but are structurally trivial. For direct caller APIs where generic params are needed (e.g., `join_room(impl AsBroadcaster)`), manual trait impls are used instead of `protocol_impl!`. + +**Impact:** Combined with `#[actor]`, `protocol_impl!`, and conversion traits, Approach B's total code is competitive with Approach A while retaining its testability and Erlang-like actor-level granularity advantages. + +### Approach C: Typed Wrappers — NO + +The fundamental problem is the dual-channel architecture, not boilerplate. The `Clone` bound incompatibility between enum messages and `Recipient` creates a structural split that macros can't paper over. Typed wrappers still hide `unreachable!()` branches internally. + +### Approach D: Derive Macro — N/A + +This approach IS a macro. The `#[derive(ActorMessages)]` would generate message structs, `Message` impls, API wrappers, and `Handler` delegation — subsuming what `actor_api!`/`protocol_impl!`, `send_messages!`, and `#[actor]` do separately. + +### Approach E: AnyActorRef — NO + +You could wrap `send_any(Box::new(...))` in typed helper methods, but this provides false safety — the runtime dispatch can still fail. The whole point of AnyActorRef is erasing types; adding typed wrappers on top contradicts that. + +### Approach F: PID — PARTIAL + +The registration boilerplate could be automated: + +```rust +// Current: manual registration per message type +room.register::(); +room.register::(); +room.register::(); + +// Potential: derive-style auto-registration +#[actor(register(Say, Join, Members))] +impl ChatRoom { ... } +``` + +And `spawned::send(pid, Msg { ... })` could get ergonomic wrappers similar to `actor_api!`. But since `Pid` carries no type information, these wrappers can only provide ergonomics, not safety — a wrong Pid still causes a runtime error. + +### Summary + +| Approach | Macro potential | What it would eliminate | Worth implementing? | +|----------|----------------|----------------------|---------------------| +| **B: Protocol Traits** | High | Bridge impls + conversion traits | Done — `protocol_impl!` macro | +| **C: Typed Wrappers** | None | N/A — structural problem | No | +| **D: Derive Macro** | N/A | Already a macro | N/A | +| **E: AnyActorRef** | None | Would add false safety | No | +| **F: PID** | Low-Medium | Registration ceremony | Maybe — ergonomics only | + +**Takeaway:** Approach B now uses `protocol_impl!` for bridge impls, achieving competitive code volume with Approach A. With `Response`, type aliases, conversion traits, and `protocol_impl!`, main.rs bodies are identical between A and B — the approaches differ only in internal wiring and dependency structure. + +--- + +## Comparison Matrix + +### Functional Dimensions + +| Dimension | A: Recipient | B: Protocol Traits | C: Typed Wrappers | D: Derive Macro | E: AnyActorRef | F: PID | +|-----------|-------------|-------------------|-------------------|-----------------|---------------|--------| +| **Status** | Implemented | Implemented | Design only | Design only | Design only | Design only | +| **Breaking** | Yes | Yes | No | No | Yes | Yes | +| **#144 type safety** | Full | Full | Hidden `unreachable!` | Hidden `unreachable!` | Full | Full | +| **#145 type safety** | Compile-time | Compile-time | Compile-time | Compile-time | Runtime only | Runtime only | +| **Macro support** | `#[actor]` + `actor_api!` + message macros | `#[actor]` + `protocol_impl!` + message macros | N/A (enum-based) | Derive macro | `#[actor]` | `#[actor]` | +| **Dual-mode (async+threads)** | Works | Works | Complex (dual channel) | Complex | Works | Works | +| **Registry stores** | `ActorRef` (full API, coupled) or `Recipient` (per-message, decoupled) | `Arc` (full API, decoupled) | Mixed | `Recipient` | `AnyActorRef` | `Pid` | +| **Registry type safety** | Compile-time | Compile-time | Depends | Compile-time | Runtime | Runtime | +| **Registry discovery** | Discoverer must know concrete type or individual message types | Discoverer only needs the protocol trait | Depends | Same as A | No type info | No type info | + +### Code Quality Dimensions + +| Dimension | A: Recipient | B: Protocol Traits | C: Typed Wrappers | D: Derive Macro | E: AnyActorRef | F: PID | +|-----------|-------------|-------------------|-------------------|-----------------|---------------|--------| +| **Handler readability** | Clear: one `impl Handler` or `#[send_handler]` per message | Same as A for handlers. Files read Messages → Bridge → Conversion → Actor. Type aliases reduce noise. | Noisy: enum `match` arms + wrapper fns | Opaque: generated from enum annotations | Same as A, but callers use `Box::new` | Same as A, but callers use global `send(pid, msg)` | +| **API at a glance** | `actor_api!` block or scan Handler impls | Protocol traits (best) + `UserActions` trait for direct caller API | Typed wrapper functions | Annotated enum (good summary) | Nothing — `AnyActorRef` is opaque | Nothing — `Pid` is opaque | +| **main.rs expressivity** | `alice.say("Hi")` with `actor_api!`; `alice.send(SayToRoom{...})` without | Identical body to A: `alice.join_room(room.clone())`, `room.members().await`, `alice.say(...)` | `ChatRoom::say(&room, ...)` assoc fn | Generated methods: `room.say(...)` | `room.send_any(Box::new(...))` | `spawned::send(pid, ...)` + registration | +| **Boilerplate per message** | Struct + `actor_api!` line | Struct + protocol method + `protocol_impl!` line | Enum variant + wrapper + match arm | Enum variant + annotation | Struct | Struct + registration | +| **Debugging** | Standard Rust — all code visible | Standard Rust — bridge impls visible | Standard Rust | Requires `cargo expand` | Runtime errors (downcast failures) | Runtime errors (unregistered types) | +| **Testability** | Good (mock via Recipient) | Best (mock protocol trait) | Good | Good | Fair (Any-based) | Hard (global state) | + +### Strategic Dimensions + +| Dimension | A: Recipient | B: Protocol Traits | C: Typed Wrappers | D: Derive Macro | E: AnyActorRef | F: PID | +|-----------|-------------|-------------------|-------------------|-----------------|---------------|--------| +| **Framework complexity** | Medium | None (user-space) | High (dual channel) | Very high (proc macro) | High (dispatch) | Medium (registry) | +| **Maintenance burden** | Low — proven Actix pattern | Low — user-maintained | High — two dispatch systems | High — complex macro | Medium | Medium | +| **Clustering readiness** | Needs `RemoteRecipient` | Needs remote bridge impls | Hard | Hard | Possible (serialize Any) | Excellent (Pid is location-transparent) | +| **Learning curve** | Moderate (Handler pattern) | Moderate + bridge pattern | Low (old API preserved) | Low (write enum, macro does rest) | Low concept, high debugging | Low concept, high registration overhead | +| **Erlang alignment** | Actix-like | Actor-level granularity (Erlang behaviours) | Actix-like | Actix-like | Erlang-ish | Most Erlang | +| **Macro improvement potential** | Already done (`actor_api!`) | Done (`protocol_impl!`) | None (structural) | N/A (is a macro) | None (false safety) | Low (ergonomics only) | + +--- + +## Recommendation + +**Approach A (Handler\ + Recipient\)** is the most mature and balanced option: +- Fully implemented with 34 passing tests, multiple examples, proc macro, registry, and dual-mode support +- Compile-time type safety for both #144 and #145 +- The `#[actor]` macro + `actor_api!` macro provide good expressivity without hiding too much +- `actor_api!` reduces extension trait boilerplate from ~15 lines to ~5 lines per actor +- Proven pattern (Actix uses the same architecture) +- Non-macro version is already clean — the macros are additive, not essential +- Registry trade-off: register `ActorRef` for full API (discoverer must know concrete type) or register `Recipient` per message (decoupled but fragmented) + +**Approach B (Protocol Traits)** is a strong alternative with identical caller ergonomics: +- main.rs body is identical to A: `alice.join_room(room.clone())`, `room.members().await.unwrap()`, `alice.say(...)` +- `protocol_impl!` macro generates bridge impls from compact declarations, competitive boilerplate with `actor_api!` +- `Response` keeps protocol traits object-safe while supporting async request-response — structural mirror of the Envelope pattern (no RPITIT, no `BoxFuture`) +- `Context::actor_ref()` lets actors self-register with protocol traits (e.g., `ctx.actor_ref().as_participant()`) +- Protocol traits define contracts at the actor level (like Erlang behaviours), giving actor-level granularity for registry and discovery +- Best testability — protocol traits can be mocked directly without running an actor system +- Zero cross-actor dependencies — both Room and User depend only on `protocols.rs` +- Only requires `Response` and `Context::actor_ref()` from the framework; protocol traits and bridge impls are purely user-space +- Best registry story: one registration per protocol, full API, no concrete type needed — discoverer depends only on the protocol trait + +**Approaches C and D** try to preserve the old enum-based API but introduce significant complexity (dual-channel, or heavy code generation) to work around its limitations. + +**Approaches E and F** sacrifice Rust's compile-time type safety for runtime flexibility. F (PID) may become relevant later for clustering, but is premature as the default API today. + +--- + +## Branch Reference + +| Branch | Base | Description | +|--------|------|-------------| +| `main` | — | Old enum-based API (baseline) | +| [`feat/approach-a`](https://github.com/lambdaclass/spawned/tree/feat/approach-a) | main | **Approach A** — Pure Recipient\ + actor_api! pattern (all examples rewritten) | +| [`feat/approach-b`](https://github.com/lambdaclass/spawned/tree/feat/approach-b) | main | **Approach B** — Protocol traits + protocol_impl! macro + Context::actor_ref() (all examples rewritten) | +| [`feat/actor-macro-registry`](https://github.com/lambdaclass/spawned/tree/de651ad21e2dd39babf534cb74174ae0fe3b399c) | main | Adds `#[actor]` macro + named registry on top of Handler\ | +| [`feat/145-protocol-trait`](https://github.com/lambdaclass/spawned/tree/b0e5afb2c69e1f5b6ab8ee82b59582348877c819) | main | Original protocol traits exploration + [`docs/ALTERNATIVE_APPROACHES.md`](https://github.com/lambdaclass/spawned/blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md) | +| [`feat/critical-api-issues`](https://github.com/lambdaclass/spawned/tree/1ef33bf0c463543dca379463c554ccc5914c86ff) | main | Design doc for Handler\ + Recipient\ ([`docs/API_REDESIGN.md`](https://github.com/lambdaclass/spawned/blob/1ef33bf0c463543dca379463c554ccc5914c86ff/docs/API_REDESIGN.md)) | +| [`feat/handler-api-v0.5`](https://github.com/lambdaclass/spawned/tree/34bf9a759cda72e5311efda8f1fc8a5ae515129a) | main | Handler\ + Recipient\ early implementation | +| [`docs/add-project-roadmap`](https://github.com/lambdaclass/spawned/tree/426c1a9952b3ad440686c318882d570f2032666f) | main | Framework comparison with Actix and Ractor | + +--- + +## Detailed Design Documents + +- **[`docs/API_REDESIGN.md`](https://github.com/lambdaclass/spawned/blob/1ef33bf0c463543dca379463c554ccc5914c86ff/docs/API_REDESIGN.md)** (on `feat/critical-api-issues`) — Full design rationale for Handler\, Receiver\, Envelope pattern, RPITIT decision, and planned supervision traits. +- **[`docs/ALTERNATIVE_APPROACHES.md`](https://github.com/lambdaclass/spawned/blob/b0e5afb2c69e1f5b6ab8ee82b59582348877c819/docs/ALTERNATIVE_APPROACHES.md)** (on `feat/145-protocol-trait`) — Original comparison of all 5 alternative branches with execution order plan.