Accepted
2025-10-03
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?
We have two competing patterns:
-
Typed State Returns: Commands accept
Environment<S>and returnEnvironment<T>- Example:
ProvisionCommand::execute(Environment<Created>) -> Result<Environment<Provisioned>, Error>
- Example:
-
Pure Command Handler: Commands accept
Environment<S>, persist state internally, return void- Example:
ProvisionCommand::execute(Environment<Created>) -> Result<(), Error>
- Example:
Both patterns can work, but they have different implications for type safety, data flow, and future extensibility.
- 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
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)
}
}- Commands are state transformations:
Environment<S>→Environment<T> - Repository is for persistence: Save/load state, but not primary data flow
- Type safety is paramount: Leverage compile-time guarantees from type-state pattern
- Data flow is explicit: Input → transform → output (no hidden state)
✅ Compile-Time Safety: Invalid state transitions are prevented at compile time
- Cannot call
ConfigureCommandon anEnvironment<Created>(not yet provisioned) - Cannot call
ProvisionCommandon an alreadyEnvironment<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
- However, CQRS patterns allow commands to return acknowledgments/identifiers
- State transformation is a valid command output in functional paradigms
- However, this is intentional - we want the transformed state for chaining
ℹ️ 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)
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
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
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
- Type Erasure for Environment States - How we handle serialization while maintaining type safety
- Actionable Error Messages - Error handling approach for commands
- Phase 1-4 implementation of type-state pattern in
Environment<S>
-
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
- State transitions as pure transformations:
-
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.mdsrc/domain/environment/mod.rs- Type-state implementationsrc/infrastructure/persistence/- Repository layer
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.
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 timeCommands 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