Skip to content

Latest commit

 

History

History
292 lines (203 loc) · 9.53 KB

File metadata and controls

292 lines (203 loc) · 9.53 KB

Decision: Command State Return Pattern

Status

Accepted

Date

2025-10-03

Context

In Phase 5 of the environment state management feature, we need to integrate type-safe state transitions into our command handlers (ProvisionCommand and ConfigureCommand). This raises a fundamental architectural question: Should commands return the transformed Environment<S> state, or should they operate as pure command handlers that only persist state via the repository?

The Problem

We have two competing patterns:

  1. Typed State Returns: Commands accept Environment<S> and return Environment<T>

    • Example: ProvisionCommand::execute(Environment<Created>) -> Result<Environment<Provisioned>, Error>
  2. Pure Command Handler: Commands accept Environment<S>, persist state internally, return void

    • Example: ProvisionCommand::execute(Environment<Created>) -> Result<(), Error>

Both patterns can work, but they have different implications for type safety, data flow, and future extensibility.

Why This Matters

  • We've invested 4 phases of work building a sophisticated type-state pattern for compile-time state validation
  • Commands orchestrate complex multi-step workflows with clear state progressions
  • We want to enable future command chaining and orchestration with compile-time guarantees
  • The repository layer exists for persistence, not as the primary data flow mechanism

Decision

We will use typed state returns: Commands accept and return strongly-typed Environment<S> states.

impl ProvisionCommand {
    pub async fn execute(
        &self,
        environment: Environment<Created>,
    ) -> Result<Environment<Provisioned>, ProvisionCommandError> {
        // Transition to intermediate state
        let environment = environment.start_provisioning();
        self.persist_state(&environment)?; // Persistence is secondary

        // Execute provisioning steps...
        let provisioned = self.execute_steps(&environment).await?;

        // Persist final state
        self.persist_state(&provisioned)?;

        // Return transformed state
        Ok(provisioned)
    }
}

Key Principles

  1. Commands are state transformations: Environment<S>Environment<T>
  2. Repository is for persistence: Save/load state, but not primary data flow
  3. Type safety is paramount: Leverage compile-time guarantees from type-state pattern
  4. Data flow is explicit: Input → transform → output (no hidden state)

Consequences

Positive

Compile-Time Safety: Invalid state transitions are prevented at compile time

  • Cannot call ConfigureCommand on an Environment<Created> (not yet provisioned)
  • Cannot call ProvisionCommand on an already Environment<Provisioned> instance
  • Impossible to forget a state transition

Clear Data Flow: Easy to understand what's happening

let created = Environment::new(...);
let provisioned = provision_command.execute(created).await?;
let configured = configure_command.execute(provisioned).await?;

No Repeated Parsing: Avoid pattern matching on AnyEnvironmentState

  • Without typed returns: Load from repository → match on AnyEnvironmentState → extract typed state
  • With typed returns: State already typed, no parsing needed

Future Orchestration: Enables fluent command chaining

// Future possibility:
let workflow = Workflow::new()
    .then(provision_command)
    .then(configure_command)
    .then(deploy_command);

let final_state = workflow.execute(created).await?;

Type-State Pattern Reaches Full Potential: Commands leverage all the work from Phases 1-4

Negative

⚠️ Deviates from Pure CQS: Commands traditionally shouldn't return values in strict Command/Query Separation

  • However, CQRS patterns allow commands to return acknowledgments/identifiers
  • State transformation is a valid command output in functional paradigms

⚠️ Commands Return Values: Not traditional "fire and forget" command handlers

  • However, this is intentional - we want the transformed state for chaining

Neutral

ℹ️ Repository is Secondary: State persistence happens alongside transformation

  • This is by design - persistence is a cross-cutting concern, not the primary data flow
  • Failed persistence is logged but doesn't fail the command (state is still valid in memory)

Alternatives Considered

Alternative 1: Pure Command Handler Pattern

impl ProvisionCommand {
    pub async fn execute(
        &self,
        environment: Environment<Created>,
    ) -> Result<(), ProvisionCommandError> {
        let environment = environment.start_provisioning();
        self.repository.save(&environment.into_any())?;

        // Execute steps...

        let provisioned = environment.complete_provisioning(ip);
        self.repository.save(&provisioned.into_any())?;

        // No return - caller must load from repository
    }
}

// Caller must load state
provision_command.execute(created).await?;
let state = repository.load(&env_name)?.expect("Must exist");
let provisioned = match state {
    AnyEnvironmentState::Provisioned(env) => env,
    _ => return Err("Wrong state!"), // Runtime error!
};

Why Rejected:

  • ❌ Loses compile-time type safety (runtime pattern matching required)
  • ❌ Awkward data flow (caller must reload what command just created)
  • ❌ Repository becomes central to data flow (not just persistence)
  • ❌ Makes command chaining difficult
  • ❌ Doesn't leverage the type-state pattern we built in Phases 1-4

Alternative 2: Hybrid - Store Environment in Command

pub struct ProvisionCommand {
    environment: RefCell<Option<Environment<Provisioning>>>,
    // ...
}

impl ProvisionCommand {
    pub async fn execute(&self, environment: Environment<Created>) -> Result<(), Error> {
        let provisioning = environment.start_provisioning();
        *self.environment.borrow_mut() = Some(provisioning);

        // Execute...

        let provisioned = self.environment.borrow().as_ref().unwrap().complete_provisioning(ip);
        self.repository.save(&provisioned.into_any())?;
    }

    pub fn get_result(&self) -> Environment<Provisioned> {
        // Complex extraction logic...
    }
}

Why Rejected:

  • ❌ Interior mutability complexity (RefCell, borrowing rules)
  • ❌ Unclear ownership semantics
  • ❌ Still requires separate getter method
  • ❌ Makes command non-Send (problematic for async)
  • ❌ More complex than straightforward transformation

Alternative 3: Builder Pattern with Fluent API

provision_command
    .with_environment(created)
    .execute()
    .await?
    .get_provisioned_environment();

Why Rejected:

  • ❌ More complex API than direct transformation
  • ❌ Still needs to return state somehow
  • ❌ Doesn't solve the fundamental return question

Related Decisions

References

  • Type-State Pattern in Rust: https://cliffle.com/blog/rust-typestate/

    • Demonstrates how to use Rust's type system for state machines
    • Our pattern follows this approach for environment lifecycle
  • CQRS Flexibility: https://martinfowler.com/bliki/CQRS.html

    • While pure CQS says commands return void, CQRS patterns often return acknowledgments
    • Command can return identifiers or confirmation objects
  • Functional Programming Perspective:

    • State transitions as pure transformations: S -> T
    • Commands as functions that transform state
    • Side effects (persistence) are secondary concerns
  • Rust Ownership Model:

    • Returning transformed data is idiomatic in Rust
    • Ownership transfer makes data flow explicit
    • No implicit state mutations
  • Phase 1-4 Implementation:

    • docs/features/environment-state-management/feature-description.md
    • src/domain/environment/mod.rs - Type-state implementation
    • src/infrastructure/persistence/ - Repository layer

Implementation Notes

Persistence Error Handling

Persistence failures are logged but don't fail the command:

if let Err(e) = self.persist_state(&environment) {
    warn!(
        "Failed to persist state: {}. Command execution continues.",
        e
    );
}

Rationale: The in-memory state transformation is valid even if persistence fails. We log for observability but don't block the workflow.

Command Chaining Pattern

This decision enables future orchestration:

// Phase 5 Subtasks 3-4: Individual commands return typed states
let provisioned = provision_cmd.execute(created).await?;
let configured = configure_cmd.execute(provisioned).await?;

// Future: Orchestration layer with compile-time guarantees
let workflow = Orchestrator::new()
    .step(provision_cmd)    // Requires Created, produces Provisioned
    .step(configure_cmd)    // Requires Provisioned, produces Configured
    .step(deploy_cmd);      // Requires Configured, produces Deployed

workflow.execute(created).await?; // Type-checked at compile time

Backward Compatibility

Commands still work with existing E2E tests by extracting values from returned states:

// Old pattern (Phase 5 Subtask 1):
let ip_address = provision_command.execute(&ssh_credentials).await?;

// New pattern (Phase 5 Subtask 3+):
let provisioned = provision_command.execute(environment).await?;
let ip_address = provisioned.instance_ip(); // Getter method