State Changes
State Changes are WebAssembly components that handle commands in EvidentSource, implementing the write-side of CQRS (Command Query Responsibility Segregation). They process incoming commands, validate them against current state, and emit events that modify the system.
Overview
Section titled “Overview”State Changes serve as command handlers that:
- Accept commands with structured data (typically JSON)
- Query current state via State Views for decision making
- Emit CloudEvents to record state transitions
- Enforce consistency with append conditions (DCB constraints)
- Execute in a sandboxed WebAssembly environment with resource limits
The Decide Function
Section titled “The Decide Function”Every State Change implements a single function with this signature:
decide(database, command, metadata) -> Result<(events, constraints), error>Where:
- database: Read-only access to query state views and events
- command: The incoming command data (JSON body, content type)
- metadata: Context like database revision, effective timestamp, principal info
- events: Zero or more prospective events to emit
- constraints: Append conditions for optimistic concurrency control
Key Concepts
Section titled “Key Concepts”Command Processing Flow
Section titled “Command Processing Flow”- Command Reception: Command arrives via HTTP or gRPC API
- State Query: Component queries State Views to read current state
- Business Logic: Component validates and decides on events
- Event Emission: One or more prospective events are returned
- Constraint Evaluation: Append conditions are checked before commit
- Atomic Commit: Events are appended or transaction is rejected
Execution Limits
Section titled “Execution Limits”State Changes run with configurable resource limits:
- Memory: Default 100MB limit
- Timeout: Default 30 seconds (via WebAssembly epochs)
- No network/filesystem: Pure computation only
Writing a State Change
Section titled “Writing a State Change”Rust Example
Section titled “Rust Example”Here’s a complete example of an account opening State Change:
use evidentsource_functions::prelude::*;use serde::{Deserialize, Serialize};
// Command type - parsed from JSON request body#[derive(Debug, Deserialize)]#[serde(rename_all = "camelCase")]pub struct OpenAccountCommand { pub account_id: String, pub customer_name: String, pub initial_deposit: f64,}
impl TryFrom<Command> for OpenAccountCommand { type Error = String; fn try_from(cmd: Command) -> Result<Self, Self::Error> { serde_json::from_slice(cmd.body()) .map_err(|e| format!("Failed to parse command: {}", e)) }}
// Domain event#[derive(Debug, Serialize)]#[serde(rename_all = "camelCase")]pub struct AccountOpened { pub account_id: String, pub customer_name: String,}
// The state change function#[evidentsource_functions::state_change(name = "open-account")]fn decide( db: &Database, cmd: OpenAccountCommand, _metadata: &StateChangeMetadata,) -> Result<(NonEmpty<impl Into<ProspectiveEvent>>, Vec<AppendCondition>), StateChangeError> { // Validate input if cmd.customer_name.is_empty() { return Err(StateChangeError::Validation( "Customer name is required".into(), )); }
if cmd.initial_deposit < 0.0 { return Err(StateChangeError::Validation( "Initial deposit cannot be negative".into(), )); }
// Query existing state via State View let existing = db.view_state("account-summary", 1, &[("account_id", &cmd.account_id)])?; if existing.is_some() { return Err(StateChangeError::Conflict("Account already exists".into())); }
// Build prospective event let events = vec![ AccountOpened { account_id: cmd.account_id.clone(), customer_name: cmd.customer_name, }.into_prospective_event("com.banking.account.opened", &cmd.account_id), ];
// DCB constraint: account must not already exist let constraints = vec![ AppendCondition::max( EventSelector::subject_equals(&cmd.account_id) .map_err(|e| StateChangeError::Internal(e.to_string()))? .and( EventSelector::event_type_equals("com.banking.account.opened") .map_err(|e| StateChangeError::Internal(e.to_string()))?, ), 0, // Max 0 matching events ), ];
Ok((NonEmpty::from_vec(events).expect("Events produced"), constraints))}Go Example
Section titled “Go Example”package main
import ( "encoding/json" "fmt"
"github.com/evidentsystems/evidentsource-sdks/go/packages/evidentsource-core/domain" "github.com/evidentsystems/evidentsource-sdks/go/packages/evidentsource-functions/adapters" "github.com/evidentsystems/evidentsource-sdks/go/packages/evidentsource-functions/database")
type OpenAccountCommand struct { AccountID string `json:"accountId"` CustomerName string `json:"customerName"` InitialDeposit float64 `json:"initialDeposit"`}
type AccountOpened struct { AccountID string `json:"accountId"` CustomerName string `json:"customerName"`}
type OpenAccountStateChange struct { adapters.StateChangeAdapter}
func (sc *OpenAccountStateChange) Decide( db database.Database, cmd adapters.Command, metadata adapters.StateChangeMetadata,) (*adapters.DecideResult, error) { // Parse command var command OpenAccountCommand if err := json.Unmarshal(cmd.Body(), &command); err != nil { return nil, adapters.ValidationError(fmt.Sprintf("Invalid command: %v", err)) }
// Validate if command.CustomerName == "" { return nil, adapters.ValidationError("Customer name is required") }
// Check if account exists existing, err := db.ViewState("account-summary", 1, map[string]string{ "account_id": command.AccountID, }) if err != nil { return nil, adapters.InternalError(err.Error()) } if existing != nil { return nil, adapters.ConflictError("Account already exists") }
// Create event eventData, _ := json.Marshal(AccountOpened{ AccountID: command.AccountID, CustomerName: command.CustomerName, })
events := []domain.ProspectiveEvent{ { EventType: "com.banking.account.opened", Subject: command.AccountID, Data: eventData, }, }
// DCB constraint constraints := []domain.AppendCondition{ domain.MaxCondition( domain.EventSelector{}. WithSubjectEquals(command.AccountID). WithEventTypeEquals("com.banking.account.opened"), 0, ), }
return &adapters.DecideResult{ Events: events, Constraints: constraints, }, nil}.NET Example
Section titled “.NET Example”using EvidentSource.Functions.Adapters;using EvidentSource.Functions.Database;using EvidentSource.Core.Domain.Events;using System.Text.Json;
public record OpenAccountCommand( string AccountId, string CustomerName, decimal InitialDeposit);
public record AccountOpened(string AccountId, string CustomerName);
public sealed class OpenAccountStateChange : JsonStateChangeAdapter<OpenAccountCommand>{ protected override DecideResult Decide( IDatabase db, OpenAccountCommand cmd, StateChangeMetadata metadata) { // Validate if (string.IsNullOrWhiteSpace(cmd.CustomerName)) throw StateChangeException.Validation("Customer name is required");
if (cmd.InitialDeposit < 0) throw StateChangeException.Validation("Initial deposit cannot be negative");
// Check existing var existing = db.ViewState<object>("account-summary", 1, [("account_id", cmd.AccountId)]);
if (existing?.Data is not null) throw StateChangeException.Conflict("Account already exists");
// Create event var events = new List<ProspectiveEvent> { new ProspectiveEvent { Id = Guid.NewGuid().ToString(), Stream = $"accounts/{cmd.AccountId}", EventType = "com.banking.account.opened", Subject = cmd.AccountId, Data = EventData.FromString(JsonSerializer.Serialize( new AccountOpened(cmd.AccountId, cmd.CustomerName))), DataContentType = "application/json" } };
return new DecideResult(events); }}Building WASM Components
Section titled “Building WASM Components”# Add the WASM targetrustup target add wasm32-wasip2
# Build the componentcargo build --release --target wasm32-wasip2Go (TinyGo)
Section titled “Go (TinyGo)”# Requires TinyGo 0.40.1+tinygo build -target=wasip2 -o state-change.wasm ..NET (Windows only)
Section titled “.NET (Windows only)”# Install WASI workloaddotnet workload install wasi-experimental
# Build the componentdotnet publish -c Release -p:BuildingForWasm=trueDeploying State Changes
Section titled “Deploying State Changes”Upload the compiled WASM component to the server:
curl -X POST http://localhost:3000/api/v1/databases/banking/state-changes/open-account/versions/1 \ --data-binary @target/wasm32-wasip2/release/open_account.wasm \ -H "Content-Type: application/wasm"Executing State Changes
Section titled “Executing State Changes”Via REST API
Section titled “Via REST API”curl -X POST http://localhost:3000/api/v1/databases/banking/state-changes/open-account/versions/1 \ -H "Content-Type: application/json" \ -d '{ "accountId": "acct-001", "customerName": "Alice Smith", "initialDeposit": 1000.00 }'Via gRPC (TypeScript)
Section titled “Via gRPC (TypeScript)”import { EvidentSource, databaseName, stateChangeName, jsonCommandRequest } from "@evidentsource/client";
const es = EvidentSource.connect("http://localhost:50051");const conn = await es.connectDatabase(databaseName("banking"));
const db = await conn.executeStateChange( stateChangeName("open-account"), 1, jsonCommandRequest({ accountId: "acct-001", customerName: "Alice Smith", initialDeposit: 1000.00, }));
console.log(`New revision: ${db.revision}`);Append Conditions (DCB)
Section titled “Append Conditions (DCB)”Append conditions implement DCB (Dynamic Consistency Boundary) for optimistic concurrency:
Max Condition
Section titled “Max Condition”Ensure a maximum number of matching events exist:
// Account must not exist (max 0 opened events)AppendCondition::max( EventSelector::subject_equals(&account_id)? .and(EventSelector::event_type_equals("com.banking.account.opened")?), 0,)Min Condition
Section titled “Min Condition”Ensure a minimum number of matching events exist:
// Account must exist (at least 1 opened event)AppendCondition::min( EventSelector::subject_equals(&account_id)? .and(EventSelector::event_type_equals("com.banking.account.opened")?), 1,)Range Condition
Section titled “Range Condition”Ensure event count falls within a range:
// Exactly 1 matching eventAppendCondition::range( EventSelector::subject_equals(&order_id)?, 1, // min 1, // max)Error Handling
Section titled “Error Handling”State Changes return structured errors:
| Error Type | HTTP Status | When to Use |
|---|---|---|
| Validation | 400 | Invalid input data |
| Conflict | 409 | Business rule violation, constraint failure |
| NotFound | 404 | Referenced entity doesn’t exist |
| Internal | 500 | System errors |
// Validation errorreturn Err(StateChangeError::Validation("Amount must be positive".into()));
// Conflict errorreturn Err(StateChangeError::Conflict("Account already exists".into()));
// Not found errorreturn Err(StateChangeError::NotFound("Account not found".into()));Testing State Changes
Section titled “Testing State Changes”Use the mock database for unit testing:
#[cfg(test)]mod tests { use super::*; use evidentsource_functions::testing::MockDatabase;
#[test] fn test_open_account_success() { let db = MockDatabase::new("test-db").with_revision(100); let cmd = OpenAccountCommand { account_id: "acct-001".to_string(), customer_name: "Alice".to_string(), initial_deposit: 100.0, };
let result = open_account(&db, cmd, StateChangeMetadata::default());
assert!(result.is_ok()); let (events, constraints) = result.unwrap(); assert_eq!(events.len(), 1); }
#[test] fn test_open_account_duplicate() { let mut db = MockDatabase::new("test-db"); // Pre-populate existing account db.insert_state_view_with_params( "account-summary", 1, &[("account_id", "acct-001")], &AccountSummary { id: "acct-001".into(), balance: 0.0 }, 50 ).unwrap();
let cmd = OpenAccountCommand { account_id: "acct-001".to_string(), customer_name: "Bob".to_string(), initial_deposit: 100.0, };
let result = open_account(&db, cmd, StateChangeMetadata::default());
assert!(matches!(result, Err(StateChangeError::Conflict(_)))); }}Best Practices
Section titled “Best Practices”- Validate Early: Check inputs before querying state
- Query Minimally: Only fetch state you actually need
- Use DCB Constraints: Always add appropriate append conditions
- Emit Fine-Grained Events: One event per state transition
- Handle Idempotency: Design for potential retry scenarios
- Test Edge Cases: Empty inputs, duplicates, concurrent access
Next Steps
Section titled “Next Steps”- Learn about State Views for the read side
- Understand DCB constraints for consistency
- Explore bi-temporal queries for historical access
- See API Reference for deployment details