Tutorial: Your First Actor¶
Learning Objectives:
- Create a custom actor from scratch
- Understand actor state management
- Implement message handling logic
- Test your actor in isolation
Prerequisites:
- Complete Getting Started tutorial
- Basic Rust knowledge (structs, enums, traits)
- Understanding of async/await
Estimated time: 25-30 minutes
What You'll Build¶
A GreeterActor that:
- Maintains a greeting counter
- Personalizes greetings based on history
- Demonstrates state management patterns
- Shows proper error handling
By the end, you'll understand how to design and implement production-ready actors.
Step 1: Plan Your Actor's Behavior¶
Before writing code, define what your actor does:
State:
- Count of greetings sent
- Map of person names to greeting count
Messages it handles:
Greet(name)- Send a greetingGetStats- Return greeting statisticsReset- Clear all state
Responses:
- Greeting messages (personalized by count)
- Statistics summary
- Confirmation of reset
Step 2: Define Message Types¶
Create a new file greeter.rs in your src/ directory:
use airssys_rt::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// Message enum with all supported operations
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GreeterMessage {
Greet { name: String },
GetStats,
Reset,
}
impl Message for GreeterMessage {
const MESSAGE_TYPE: &'static str = "greeter";
}
// Response types for clarity
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GreeterResponse {
Greeting(String),
Stats {
total: usize,
per_person: HashMap<String, usize>,
},
ResetConfirmed,
}
Key design decisions:
- Enum for messages: Each variant = one operation
- Struct fields: Use named fields for clarity (not tuples)
- Response types: Explicit types make API clear
- Clone: Messages must be cloneable for routing
Step 3: Design Actor State¶
Define the internal state your actor maintains:
pub struct GreeterActor {
total_greetings: usize,
greetings_per_person: HashMap<String, usize>,
}
impl GreeterActor {
// Constructor with sensible defaults
pub fn new() -> Self {
Self {
total_greetings: 0,
greetings_per_person: HashMap::new(),
}
}
// Helper: Generate personalized greeting
fn generate_greeting(&self, name: &str, count: usize) -> String {
match count {
1 => format!("Hello, {name}! Nice to meet you!"),
2 => format!("Welcome back, {name}!"),
3..=5 => format!("Hey {name}! Great to see you again!"),
_ => format!("Hi {name}! You're a regular now!"),
}
}
// Helper: Calculate statistics
fn get_statistics(&self) -> GreeterResponse {
GreeterResponse::Stats {
total: self.total_greetings,
per_person: self.greetings_per_person.clone(),
}
}
// Helper: Reset all state
fn reset_state(&mut self) {
self.total_greetings = 0;
self.greetings_per_person.clear();
}
}
Design principles:
- Private state: Fields are not
pub(encapsulation) - Helper methods: Keep
handle_messageclean - Descriptive names: Code reads like documentation
- Immutability where possible: Clone for reads, mutate only when needed
Step 4: Define Error Type¶
Actors need clear error handling:
use std::fmt;
#[derive(Debug)]
pub enum GreeterError {
InvalidName(String),
TooManyRequests,
}
impl fmt::Display for GreeterError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidName(name) => write!(f, "Invalid name: {name}"),
Self::TooManyRequests => write!(f, "Too many greeting requests"),
}
}
}
impl std::error::Error for GreeterError {}
Error design:
- Enum for error types: Different error scenarios
- Display trait: Human-readable error messages
- std::error::Error: Standard Rust error trait
Step 5: Implement the Actor Trait¶
Now bring it all together:
use async_trait::async_trait;
#[async_trait]
impl Actor for GreeterActor {
type Message = GreeterMessage;
type Error = GreeterError;
async fn handle_message<B: MessageBroker<Self::Message>>(
&mut self,
message: Self::Message,
context: &mut ActorContext<Self::Message, B>,
) -> Result<(), Self::Error> {
match message {
GreeterMessage::Greet { name } => {
// Validation
if name.trim().is_empty() {
return Err(GreeterError::InvalidName(name));
}
// Rate limiting (example business logic)
if self.total_greetings > 1000 {
return Err(GreeterError::TooManyRequests);
}
// Update state
self.total_greetings += 1;
let count = self.greetings_per_person
.entry(name.clone())
.and_modify(|c| *c += 1)
.or_insert(1);
// Generate and "send" response
let greeting = self.generate_greeting(&name, *count);
println!("{greeting}");
// Record metrics
context.record_message();
}
GreeterMessage::GetStats => {
let stats = self.get_statistics();
println!("Stats: {stats:?}");
context.record_message();
}
GreeterMessage::Reset => {
self.reset_state();
println!("State reset successfully");
context.record_message();
}
}
Ok(())
}
// Optional: Custom initialization
async fn pre_start<B: MessageBroker<Self::Message>>(
&mut self,
_context: &mut ActorContext<Self::Message, B>,
) -> Result<(), Self::Error> {
println!("GreeterActor starting up...");
Ok(())
}
// Optional: Custom cleanup
async fn post_stop<B: MessageBroker<Self::Message>>(
&mut self,
_context: &mut ActorContext<Self::Message, B>,
) -> Result<(), Self::Error> {
println!("GreeterActor shutting down. Total greetings: {}", self.total_greetings);
Ok(())
}
}
Implementation highlights:
- Pattern matching: Clean separation of message handling
- Validation first: Check inputs before processing
- State updates: Encapsulated in one place
- Metrics tracking:
context.record_message()after each message - Lifecycle hooks:
pre_startandpost_stopfor setup/cleanup
Step 6: Test Your Actor¶
Create a test in your main.rs or tests/:
use airssys_rt::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("=== Greeter Actor Test ===\n");
// Create actor
let mut actor = GreeterActor::new();
// Setup context
let address = ActorAddress::named("greeter");
let broker = InMemoryMessageBroker::<GreeterMessage>::new();
let mut context = ActorContext::new(address, broker);
// Start actor
actor.pre_start(&mut context).await?;
// Test case 1: First greeting
println!("Test 1: First greeting");
let msg = GreeterMessage::Greet { name: "Alice".to_string() };
actor.handle_message(msg, &mut context).await?;
// Test case 2: Repeat greeting
println!("\nTest 2: Repeat greeting");
let msg = GreeterMessage::Greet { name: "Alice".to_string() };
actor.handle_message(msg, &mut context).await?;
// Test case 3: New person
println!("\nTest 3: New person");
let msg = GreeterMessage::Greet { name: "Bob".to_string() };
actor.handle_message(msg, &mut context).await?;
// Test case 4: Get statistics
println!("\nTest 4: Statistics");
let msg = GreeterMessage::GetStats;
actor.handle_message(msg, &mut context).await?;
// Test case 5: Invalid name (error handling)
println!("\nTest 5: Error handling");
let msg = GreeterMessage::Greet { name: "".to_string() };
match actor.handle_message(msg, &mut context).await {
Ok(()) => println!("✗ Should have failed"),
Err(e) => println!("✓ Expected error: {e}"),
}
// Test case 6: Reset
println!("\nTest 6: Reset");
let msg = GreeterMessage::Reset;
actor.handle_message(msg, &mut context).await?;
// Test case 7: Verify reset worked
println!("\nTest 7: Verify reset");
let msg = GreeterMessage::GetStats;
actor.handle_message(msg, &mut context).await?;
// Cleanup
actor.post_stop(&mut context).await?;
println!("\n=== Test Complete ===");
Ok(())
}
Step 7: Run and Observe¶
Expected output:
=== Greeter Actor Test ===
GreeterActor starting up...
Test 1: First greeting
Hello, Alice! Nice to meet you!
Test 2: Repeat greeting
Welcome back, Alice!
Test 3: New person
Hello, Bob! Nice to meet you!
Test 4: Statistics
Stats: Stats { total: 3, per_person: {"Alice": 2, "Bob": 1} }
Test 5: Error handling
✓ Expected error: Invalid name:
Test 6: Reset
State reset successfully
Test 7: Verify reset
Stats: Stats { total: 0, per_person: {} }
GreeterActor shutting down. Total greetings: 0
=== Test Complete ===
What You Built¶
Let's review the key components:
1. Message Design¶
enum GreeterMessage {
Greet { name: String }, // Named fields (not tuples)
GetStats, // No data needed
Reset, // Simple command
}
2. State Management¶
- ✅ Private fields (encapsulation) - ✅ Owned data (no shared state) - ✅ Standard Rust collections3. Error Handling¶
- ✅ Specific error types - ✅ Contextual error information - ✅ Implementsstd::error::Error
4. Actor Implementation¶
impl Actor for GreeterActor {
async fn handle_message(...) -> Result<(), Self::Error> {
match message {
// Handle each message type
}
}
}
context.record_message()
Best Practices You Applied¶
✅ Separation of Concerns: Helper methods keep handle_message clean
✅ Validation: Check inputs before processing
✅ Error Handling: Return Err instead of panicking
✅ Encapsulation: Private state, public interface
✅ Lifecycle Management: pre_start and post_stop hooks
✅ Metrics: Track message processing
Common Mistakes to Avoid¶
❌ Don't: Use panic! in message handlers¶
✅ Do: Return errors¶
❌ Don't: Forget to record metrics¶
✅ Do: Always record message processing¶
async fn handle_message(...) {
// Process message
context.record_message(); // ✅ Metrics tracked
Ok(())
}
❌ Don't: Share mutable state¶
✅ Do: Own your state¶
Next Steps¶
Congratulations! You've built a complete actor with: - ✅ State management - ✅ Message handling - ✅ Error handling - ✅ Lifecycle management
Continue Learning:¶
- Message Handling Tutorial - Advanced messaging patterns
- Supervision Setup Tutorial - Add fault tolerance
- Actor Development Guide - Production patterns
Explore Examples:¶
examples/actor_basic.rs- Simple actor patternsexamples/actor_lifecycle.rs- Lifecycle management- API Reference: Actors - Complete API docs
Quick Reference¶
Actor Implementation Checklist¶
// 1. Define messages
#[derive(Debug, Clone, Serialize, Deserialize)]
enum MyMessage { /* variants */ }
impl Message for MyMessage { /* ... */ }
// 2. Define errors
#[derive(Debug)]
enum MyError { /* variants */ }
impl Display for MyError { /* ... */ }
impl std::error::Error for MyError {}
// 3. Define actor state
struct MyActor { /* fields */ }
// 4. Implement Actor trait
#[async_trait]
impl Actor for MyActor {
type Message = MyMessage;
type Error = MyError;
async fn handle_message(...) -> Result<(), Self::Error> {
match message {
// Handle messages
}
context.record_message();
Ok(())
}
}
Ready for message handling patterns? Continue to Message Handling Tutorial!