ADR-0015: Node Abstraction Hierarchy¶
Status¶
Proposed
Date¶
2025-02-27
Context¶
The Floxide framework provides multiple node abstractions to support different programming models and use cases:
- The base
Node
trait with a singleprocess
method - The
LifecycleNode
trait with the prep/exec/post lifecycle - A planned
AsyncNode
trait for async-specific workflows
This creates confusion about which abstraction to use when implementing workflow nodes. The README currently shows an example using an AsyncNode
trait that doesn't match the actual implementation, while the examples use the base Node
trait directly.
We need to clarify the relationship between these abstractions, their intended use cases, and provide clear guidance on when to use each approach.
Decision¶
We will establish a clear hierarchy of node abstractions with well-defined relationships:
1. Base Node Trait¶
The Node
trait will remain the core abstraction that all workflow nodes must implement:
#[async_trait]
pub trait Node<Context, Action>: Send + Sync
where
Context: Send + Sync + 'static,
Action: ActionType + Send + Sync + 'static,
Self::Output: Send + Sync + 'static,
{
/// The output type produced by this node
type Output;
/// Get the unique identifier for this node
fn id(&self) -> NodeId;
/// Process the node asynchronously
async fn process(
&self,
ctx: &mut Context,
) -> Result<NodeOutcome<Self::Output, Action>, FloxideError>;
}
This trait is the foundation of the workflow system and is used by the Workflow
struct to execute nodes.
2. LifecycleNode Trait¶
The LifecycleNode
trait provides a more structured approach with three distinct phases:
#[async_trait]
pub trait LifecycleNode<Context, Action>: Send + Sync
where
Context: Send + Sync + 'static,
Action: ActionType + Send + Sync + 'static,
Self::PrepOutput: Clone + Send + Sync + 'static,
Self::ExecOutput: Clone + Send + Sync + 'static,
{
/// Output type from the preparation phase
type PrepOutput;
/// Output type from the execution phase
type ExecOutput;
/// Get the node's unique identifier
fn id(&self) -> NodeId;
/// Preparation phase - perform setup and validation
async fn prep(&self, ctx: &mut Context) -> Result<Self::PrepOutput, FloxideError>;
/// Execution phase - perform the main work
async fn exec(&self, prep_result: Self::PrepOutput) -> Result<Self::ExecOutput, FloxideError>;
/// Post-execution phase - determine the next action and update context
async fn post(
&self,
prep_result: Self::PrepOutput,
exec_result: Self::ExecOutput,
ctx: &mut Context,
) -> Result<Action, FloxideError>;
}
The LifecycleNode
trait is adapted to the base Node
trait using the LifecycleNodeAdapter
struct, which implements the process
method by calling the three lifecycle methods in sequence.
3. TransformNode Trait (Formerly AsyncNode)¶
As detailed in ADR-0016: TransformNode Renaming and Async Extension Patterns, we will rename the AsyncNode
trait to TransformNode
to better reflect its actual purpose - providing a functional transformation interface:
#[async_trait]
pub trait TransformNode<Input, Output, Error>: Send + Sync
where
Input: Send + 'static,
Output: Send + 'static,
Error: std::error::Error + Send + Sync + 'static,
{
/// Preparation phase
async fn prep(&self, input: Input) -> Result<Input, Error>;
/// Execution phase
async fn exec(&self, input: Input) -> Result<Output, Error>;
/// Post-execution phase
async fn post(&self, output: Output) -> Result<Output, Error>;
}
This trait will be adapted to the LifecycleNode
trait, which in turn adapts to the base Node
trait.
Adapter Pattern¶
We will use the adapter pattern to convert between these abstractions:
LifecycleNodeAdapter
: Converts aLifecycleNode
to aNode
TransformNodeAdapter
(formerlyAsyncNodeAdapter
): Converts aTransformNode
to aLifecycleNode
This approach allows users to choose the abstraction that best fits their use case while maintaining compatibility with the core workflow system.
Usage Guidelines¶
We will provide clear guidelines on when to use each abstraction:
-
Base Node: Use when you need complete control over the node execution process or when implementing custom node types that don't fit the lifecycle pattern.
-
LifecycleNode: Use for most workflow nodes that benefit from the clear separation of concerns provided by the prep/exec/post lifecycle.
-
TransformNode: Use for simple transformations where the input and output types are known and consistent, and you prefer a functional programming style.
Consequences¶
Advantages¶
- Clear Hierarchy: Establishes a clear relationship between the different node abstractions
- Flexibility: Allows users to choose the abstraction that best fits their use case
- Compatibility: Maintains compatibility with existing code through the adapter pattern
- Separation of Concerns: The lifecycle pattern provides clear separation of concerns for node implementation
Disadvantages¶
- Complexity: Multiple abstractions increase the learning curve for new users
- Adapter Overhead: The adapter pattern introduces some runtime overhead
- Documentation Burden: Requires clear documentation to explain the different abstractions
Migration Path¶
Existing code using the base Node
trait can continue to work without changes. For new code, we recommend:
- Use the
LifecycleNode
trait for most workflow nodes - Use the base
Node
trait for custom node types that don't fit the lifecycle pattern - Use the
TransformNode
trait for simple transformations (previously calledAsyncNode
)
Alternatives Considered¶
1. Single Node Trait¶
We considered having a single Node
trait with optional lifecycle methods, but this would make the API less clear and harder to implement correctly.
2. Complete Replacement¶
We considered completely replacing the base Node
trait with the LifecycleNode
trait, but this would break compatibility with existing code.
3. Macro-Based Approach¶
We evaluated using macros to generate the appropriate trait implementations, but this would make the code harder to understand and debug.
Implementation Notes¶
- The
LifecycleNode
trait requires prep/exec outputs to implement Clone for simplicity - The adapter automatically converts to NodeOutcome::RouteToAction
- Unit tests verify the full lifecycle and error propagation between phases
Related ADRs¶
- ADR-0003: Core Framework Abstractions
- ADR-0004: Async Runtime Selection
- ADR-0008: Node Lifecycle Methods
- ADR-0016: TransformNode Renaming and Async Extension Patterns