Dual-Trait Design Explanation¶
This document explains the design rationale behind ComponentActor's dual-trait pattern, which separates lifecycle management (Child trait) from message handling (Actor trait). This separation is a core architectural decision that impacts testability, reusability, and code clarity.
The Design Problem¶
Challenge: Components need both lifecycle management AND message handling capabilities.
Naive Approach: Single trait with all methods:
// ❌ Problematic: Single trait with mixed concerns
pub trait ComponentActor {
// Lifecycle methods
fn pre_start(&mut self) -> Result<(), Error>;
fn post_start(&mut self) -> Result<(), Error>;
fn pre_stop(&mut self) -> Result<(), Error>;
fn post_stop(&mut self) -> Result<(), Error>;
// Message handling
async fn handle_message(&mut self, msg: Message) -> Result<(), Error>;
// Metadata
fn metadata(&self) -> ComponentMetadata;
}
Problems with Single Trait: 1. Mixed Concerns: Lifecycle and messaging are unrelated concerns bundled together 2. Testing Difficulty: Can't test lifecycle without implementing message handling 3. Reusability: Can't reuse lifecycle logic in non-actor contexts 4. Cognitive Load: Large trait with 7+ methods harder to understand
The Dual-Trait Solution¶
Design: Separate lifecycle (Child) from messaging (Actor):
// ✅ Solution: Separate traits for separate concerns
// Trait 1: Lifecycle management
pub trait Child {
fn pre_start(&mut self, context: &ChildContext) -> Result<(), ChildError>;
fn post_start(&mut self, context: &ChildContext) -> Result<(), ChildError>;
fn pre_stop(&mut self, context: &ChildContext) -> Result<(), ChildError>;
fn post_stop(&mut self, context: &ChildContext) -> Result<(), ChildError>;
}
// Trait 2: Message handling
#[async_trait]
pub trait Actor: Send + 'static {
type Message: Send + 'static;
type Error: Send + 'static;
async fn handle_message(
&mut self,
message: Self::Message,
context: &ActorContext,
) -> Result<(), Self::Error>;
}
// ComponentActor implements BOTH traits
#[derive(Clone)]
pub struct MyComponent {
state: Arc<RwLock<ComponentState>>,
}
impl Child for MyComponent {
// Lifecycle hooks
}
#[async_trait]
impl Actor for MyComponent {
// Message handling
}
Benefits: 1. Separation of Concerns: Lifecycle independent from messaging 2. Testable: Test lifecycle without implementing Actor 3. Reusable: Child trait usable outside actor context 4. Focused: Each trait has single responsibility (SOLID principles)
Design Rationale¶
Why Separate Child and Actor Traits?¶
Reason 1: Independent Concerns
Lifecycle and messaging are fundamentally different concerns:
| Concern | Child Trait | Actor Trait |
|---|---|---|
| Purpose | Component initialization/cleanup | Message processing |
| When | Startup/shutdown | Runtime |
| Frequency | 2-4 times (lifecycle events) | Millions of times (messages) |
| Async | No (blocking allowed) | Yes (async required) |
| Error Handling | Stop component on error | Continue processing on error |
Example - Independent Testing:
// Test lifecycle without implementing Actor
#[test]
fn test_component_lifecycle() {
struct TestComponent;
impl Child for TestComponent {
fn pre_start(&mut self, context: &ChildContext) -> Result<(), ChildError> {
println!("Component starting...");
Ok(())
}
// ... other lifecycle hooks
}
// No need to implement Actor trait just to test lifecycle
let mut component = TestComponent;
let context = ChildContext::new(ComponentId::new("test"));
component.pre_start(&context).unwrap();
// Lifecycle tested independently
}
Reason 2: Reusability Outside Actor Context
Child trait can be used for non-actor components:
// Example: Background task that needs lifecycle but not messaging
pub struct BackgroundTask {
handle: Option<tokio::task::JoinHandle<()>>,
}
impl Child for BackgroundTask {
fn pre_start(&mut self, context: &ChildContext) -> Result<(), ChildError> {
// Start background task
let handle = tokio::spawn(async {
loop {
// Background work
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
self.handle = Some(handle);
Ok(())
}
fn pre_stop(&mut self, context: &ChildContext) -> Result<(), ChildError> {
// Stop background task
if let Some(handle) = self.handle.take() {
handle.abort();
}
Ok(())
}
// ... other lifecycle hooks
}
// No Actor trait implementation needed
// Background task uses lifecycle but doesn't process messages
Reason 3: Clarity and Cognitive Load
Smaller traits are easier to understand:
// ✅ Clear: 4 methods, single purpose (lifecycle)
pub trait Child {
fn pre_start(&mut self, context: &ChildContext) -> Result<(), ChildError>;
fn post_start(&mut self, context: &ChildContext) -> Result<(), ChildError>;
fn pre_stop(&mut self, context: &ChildContext) -> Result<(), ChildError>;
fn post_stop(&mut self, context: &ChildContext) -> Result<(), ChildError>;
}
// ✅ Clear: 1 method, single purpose (messaging)
#[async_trait]
pub trait Actor {
async fn handle_message(&mut self, ...) -> Result<(), Self::Error>;
}
// vs
// ❌ Unclear: 7+ methods, mixed purposes
pub trait ComponentActor {
fn pre_start(&mut self) -> Result<(), Error>;
fn post_start(&mut self) -> Result<(), Error>;
fn pre_stop(&mut self) -> Result<(), Error>;
fn post_stop(&mut self) -> Result<(), Error>;
async fn handle_message(&mut self, ...) -> Result<(), Error>;
fn metadata(&self) -> ComponentMetadata;
fn capabilities(&self) -> CapabilitySet;
}
User Perspective: When implementing a component, separating traits makes it clear: - "I need lifecycle? Implement Child." - "I need messaging? Implement Actor." - "I need both? Implement both."
Alternative Approaches Considered¶
Alternative 1: Single Trait (Rejected)¶
pub trait ComponentActor {
fn pre_start(&mut self) -> Result<(), Error>;
fn post_start(&mut self) -> Result<(), Error>;
fn pre_stop(&mut self) -> Result<(), Error>;
fn post_stop(&mut self) -> Result<(), Error>;
async fn handle_message(&mut self, msg: Message) -> Result<(), Error>;
fn metadata(&self) -> ComponentMetadata;
}
Why Rejected: - ❌ Mixed concerns (lifecycle + messaging + metadata) - ❌ Can't test lifecycle independently - ❌ Can't reuse lifecycle outside actor context - ❌ Large trait with 6+ methods - ❌ Violates Single Responsibility Principle
Alternative 2: Builder Pattern (Rejected)¶
pub struct ComponentActorBuilder {
pre_start: Option<Box<dyn FnMut() -> Result<(), Error>>>,
post_start: Option<Box<dyn FnMut() -> Result<(), Error>>>,
pre_stop: Option<Box<dyn FnMut() -> Result<(), Error>>>,
post_stop: Option<Box<dyn FnMut() -> Result<(), Error>>>,
handle_message: Box<dyn FnMut(Message) -> BoxFuture<Result<(), Error>>>,
}
impl ComponentActorBuilder {
pub fn with_pre_start<F>(mut self, f: F) -> Self
where F: FnMut() -> Result<(), Error> + 'static {
self.pre_start = Some(Box::new(f));
self
}
// ... other builder methods
}
Why Rejected:
- ❌ Complex type signatures (Box
Alternative 3: Macro-Generated Implementation (Rejected)¶
component_actor! {
struct MyComponent {
state: Arc<RwLock<ComponentState>>,
}
lifecycle {
pre_start => |ctx| { /* ... */ },
post_start => |ctx| { /* ... */ },
pre_stop => |ctx| { /* ... */ },
post_stop => |ctx| { /* ... */ },
}
messaging {
handle_message => |msg, ctx| async { /* ... */ },
}
}
Why Rejected: - ❌ Magic (macro hides implementation details) - ❌ Poor IDE support (completion, navigation) - ❌ Difficult to debug (macro expansion errors) - ❌ Non-idiomatic (Rust favors traits over macros) - ✅ Pro: Concise syntax - Decision: Explicitness more valuable than conciseness
Alternative 4: Trait Composition with Default Impls (Partially Adopted)¶
pub trait Lifecycle {
fn pre_start(&mut self, context: &ChildContext) -> Result<(), ChildError> {
Ok(()) // Default: no-op
}
fn post_start(&mut self, context: &ChildContext) -> Result<(), ChildError> {
Ok(()) // Default: no-op
}
fn pre_stop(&mut self, context: &ChildContext) -> Result<(), ChildError> {
Ok(()) // Default: no-op
}
fn post_stop(&mut self, context: &ChildContext) -> Result<(), ChildError> {
Ok(()) // Default: no-op
}
}
pub trait Actor: Lifecycle {
async fn handle_message(&mut self, ...) -> Result<(), Self::Error>;
}
Why Partially Adopted (as Child trait): - ✅ Pro: Default implementations reduce boilerplate - ✅ Pro: Opt-in hooks (only implement what you need) - ⚠️ Con: Easy to forget hooks (silent no-op) - Decision: Use default impls but name trait "Child" (clearer than "Lifecycle")
Current Design:
// Child trait with default no-op implementations
pub trait Child {
fn pre_start(&mut self, context: &ChildContext) -> Result<(), ChildError> {
Ok(()) // Default: no-op
}
// ... other hooks with defaults
}
// Actor trait separate
#[async_trait]
pub trait Actor {
async fn handle_message(&mut self, ...) -> Result<(), Self::Error>;
}
Benefits: - ✅ Separation of concerns (Child vs Actor) - ✅ Default impls reduce boilerplate - ✅ Clear trait names (Child = lifecycle, Actor = messaging)
Tradeoffs and Benefits¶
Tradeoffs¶
Tradeoff 1: Two Trait Implementations Required
Cost: Component must implement both Child and Actor:
impl Child for MyComponent {
// Lifecycle hooks
}
#[async_trait]
impl Actor for MyComponent {
// Message handling
}
Benefit: Clear separation, testability, reusability
Verdict: Worth the cost - two small focused traits easier than one large mixed trait
Tradeoff 2: Slightly More Verbose
Cost: Two impl blocks instead of one:
// Dual-trait: 2 impl blocks
impl Child for MyComponent { /* ... */ }
impl Actor for MyComponent { /* ... */ }
// Single trait: 1 impl block
impl ComponentActor for MyComponent { /* all methods */ }
Benefit: Each impl block smaller, more focused, easier to navigate
Verdict: Minimal verbosity increase, significant clarity increase
Tradeoff 3: Async Trait Macro Required
Cost: Actor trait requires #[async_trait] macro:
Benefit: Async message handling (essential for I/O-bound operations)
Verdict: Necessary cost - async trait support in Rust requires macro (until native async fn in traits)
Benefits Summary¶
| Benefit | Impact |
|---|---|
| Separation of Concerns | High - Lifecycle and messaging independent |
| Testability | High - Test lifecycle without Actor |
| Reusability | Medium - Child trait usable outside actors |
| Clarity | High - Smaller traits easier to understand |
| Focused | High - Each trait has single responsibility |
| Performance | Neutral - No runtime overhead |
Historical Context¶
Evolution from Phase 4¶
Phase 4 Initial Design: Single trait with mixed concerns
// Phase 4: Mixed lifecycle and messaging
pub trait ComponentActor {
fn start(&mut self) -> Result<(), Error>;
fn stop(&mut self) -> Result<(), Error>;
async fn handle_message(&mut self, msg: Message) -> Result<(), Error>;
}
Problems Discovered: 1. Testing lifecycle required full Actor implementation 2. Lifecycle hooks insufficient (needed pre/post for each event) 3. Reusability limited (trait coupled to actor system)
Phase 5 Redesign: Dual-trait pattern with Child + Actor
// Phase 5: Separated concerns
pub trait Child {
fn pre_start(&mut self, context: &ChildContext) -> Result<(), ChildError>;
fn post_start(&mut self, context: &ChildContext) -> Result<(), ChildError>;
fn pre_stop(&mut self, context: &ChildContext) -> Result<(), ChildError>;
fn post_stop(&mut self, context: &ChildContext) -> Result<(), ChildError>;
}
#[async_trait]
pub trait Actor {
async fn handle_message(&mut self, ...) -> Result<(), Self::Error>;
}
Improvements: - ✅ Lifecycle testable independently - ✅ Granular hooks (pre/post for start/stop) - ✅ Child trait reusable outside actors - ✅ Clear separation of concerns
Lessons Learned¶
Lesson 1: Separation Enables Testing
Initial single trait made testing difficult: - Couldn't test lifecycle without implementing message handler - Integration tests required full component setup
Dual-trait pattern improved testing: - Test lifecycle in isolation (unit tests) - Test message handling in isolation (unit tests) - Integration tests only for full system
Lesson 2: Granular Hooks Essential
Initial design had only start() and stop():
- Insufficient for complex initialization (need pre + post)
- Difficult to coordinate with other systems
Dual-trait design added pre/post hooks:
- pre_start: Initialize internal state
- post_start: Register with external systems
- pre_stop: Unregister from external systems
- post_stop: Cleanup internal state
Lesson 3: Reusability Requires Decoupling
Lifecycle logic is useful beyond actors: - Background tasks (periodic execution) - Resource managers (file handles, connections) - Service orchestration (startup/shutdown coordination)
Child trait decoupled from Actor enables reuse: - Implement Child alone for non-actor components - Reuse lifecycle logic across different contexts
Impact on Testability¶
Before (Single Trait): Difficult Testing¶
// ❌ Must implement everything to test lifecycle
struct TestComponent;
impl ComponentActor for TestComponent {
fn pre_start(&mut self) -> Result<(), Error> {
// Test this
Ok(())
}
fn post_start(&mut self) -> Result<(), Error> {
Ok(())
}
fn pre_stop(&mut self) -> Result<(), Error> {
Ok(())
}
fn post_stop(&mut self) -> Result<(), Error> {
Ok(())
}
async fn handle_message(&mut self, msg: Message) -> Result<(), Error> {
// Must implement even though not testing this
Ok(())
}
fn metadata(&self) -> ComponentMetadata {
// Must implement even though not testing this
ComponentMetadata::default()
}
}
After (Dual-Trait): Easy Testing¶
// ✅ Implement only what you're testing
struct TestComponent;
impl Child for TestComponent {
fn pre_start(&mut self, context: &ChildContext) -> Result<(), ChildError> {
// Test this
Ok(())
}
// Other hooks have default no-op implementations
}
// No need to implement Actor trait
// No need to implement metadata method
Testing Improvement: - Before: 6+ methods required - After: 1 method required (others have defaults) - Result: 6x reduction in test boilerplate
Integration Patterns¶
Pattern 1: Full ComponentActor (Lifecycle + Messaging)¶
#[derive(Clone)]
pub struct MyComponent {
state: Arc<RwLock<ComponentState>>,
}
// Implement both traits
impl Child for MyComponent {
fn pre_start(&mut self, context: &ChildContext) -> Result<(), ChildError> {
println!("Starting component: {}", context.component_id);
Ok(())
}
}
#[async_trait]
impl Actor for MyComponent {
type Message = MyMessage;
type Error = MyError;
async fn handle_message(
&mut self,
message: Self::Message,
context: &ActorContext,
) -> Result<(), Self::Error> {
// Process message
Ok(())
}
}
Pattern 2: Lifecycle-Only Component (No Messaging)¶
pub struct BackgroundWorker {
handle: Option<tokio::task::JoinHandle<()>>,
}
// Implement only Child (no Actor)
impl Child for BackgroundWorker {
fn pre_start(&mut self, context: &ChildContext) -> Result<(), ChildError> {
let handle = tokio::spawn(async {
// Background work
});
self.handle = Some(handle);
Ok(())
}
fn pre_stop(&mut self, context: &ChildContext) -> Result<(), ChildError> {
if let Some(handle) = self.handle.take() {
handle.abort();
}
Ok(())
}
}
// No Actor trait needed
Pattern 3: Minimal Component (Defaults)¶
pub struct MinimalComponent;
// Implement Child with defaults (no-op lifecycle)
impl Child for MinimalComponent {}
#[async_trait]
impl Actor for MinimalComponent {
type Message = MyMessage;
type Error = MyError;
async fn handle_message(
&mut self,
message: Self::Message,
context: &ActorContext,
) -> Result<(), Self::Error> {
// Only message handling needed
Ok(())
}
}
Summary¶
The dual-trait pattern separates lifecycle (Child) from messaging (Actor), providing:
- Separation of Concerns: Lifecycle and messaging are independent
- Testability: Test lifecycle without Actor, test Actor without lifecycle
- Reusability: Child trait usable outside actor context
- Clarity: Two small focused traits easier than one large mixed trait
- Flexibility: Implement both (full component) or just Child (lifecycle-only)
Design Decision: Dual-trait pattern chosen over single trait, builder pattern, and macro-generated approaches for superior testability, reusability, and clarity despite requiring two impl blocks.
Historical Evolution: Phase 4 single trait had testability issues. Phase 5 dual-trait redesign resolved these issues and improved code quality.
Performance: Zero runtime overhead - trait dispatch compiled away
Recommendation: Use dual-trait pattern for all ComponentActor implementations. For lifecycle-only components, implement just Child trait.
Next Steps¶
- ComponentActor API Reference - Complete API documentation
- Lifecycle Hooks Reference - Hook execution order
- Architecture - Full system architecture