Skip to content

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.

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

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
  1. Command Reception: Command arrives via HTTP or gRPC API
  2. State Query: Component queries State Views to read current state
  3. Business Logic: Component validates and decides on events
  4. Event Emission: One or more prospective events are returned
  5. Constraint Evaluation: Append conditions are checked before commit
  6. Atomic Commit: Events are appended or transaction is rejected

State Changes run with configurable resource limits:

  • Memory: Default 100MB limit
  • Timeout: Default 30 seconds (via WebAssembly epochs)
  • No network/filesystem: Pure computation only

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))
}
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
}
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);
}
}
Terminal window
# Add the WASM target
rustup target add wasm32-wasip2
# Build the component
cargo build --release --target wasm32-wasip2
Terminal window
# Requires TinyGo 0.40.1+
tinygo build -target=wasip2 -o state-change.wasm .
Terminal window
# Install WASI workload
dotnet workload install wasi-experimental
# Build the component
dotnet publish -c Release -p:BuildingForWasm=true

Upload the compiled WASM component to the server:

Terminal window
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"
Terminal window
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
}'
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 implement DCB (Dynamic Consistency Boundary) for optimistic concurrency:

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,
)

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,
)

Ensure event count falls within a range:

// Exactly 1 matching event
AppendCondition::range(
EventSelector::subject_equals(&order_id)?,
1, // min
1, // max
)

State Changes return structured errors:

Error TypeHTTP StatusWhen to Use
Validation400Invalid input data
Conflict409Business rule violation, constraint failure
NotFound404Referenced entity doesn’t exist
Internal500System errors
// Validation error
return Err(StateChangeError::Validation("Amount must be positive".into()));
// Conflict error
return Err(StateChangeError::Conflict("Account already exists".into()));
// Not found error
return Err(StateChangeError::NotFound("Account not found".into()));

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(_))));
}
}
  1. Validate Early: Check inputs before querying state
  2. Query Minimally: Only fetch state you actually need
  3. Use DCB Constraints: Always add appropriate append conditions
  4. Emit Fine-Grained Events: One event per state transition
  5. Handle Idempotency: Design for potential retry scenarios
  6. Test Edge Cases: Empty inputs, duplicates, concurrent access