Skip to content

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, BoxFuture) - ❌ Runtime overhead (dynamic dispatch) - ❌ No type safety (hooks stored as trait objects) - ❌ Difficult to test (can't inspect hooks) - ❌ Poor error messages (type mismatches in closures) - ✅ Pro: Flexible (opt-in hooks) - Decision: Flexibility not worth complexity cost

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:

#[async_trait]
impl Actor for MyComponent {
    async fn handle_message(...) { /* ... */ }
}

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:

  1. Separation of Concerns: Lifecycle and messaging are independent
  2. Testability: Test lifecycle without Actor, test Actor without lifecycle
  3. Reusability: Child trait usable outside actor context
  4. Clarity: Two small focused traits easier than one large mixed trait
  5. 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