State Management
State management in Happen provides powerful capabilities through minimal abstractions. This guide explores patterns and approaches for effectively managing state across your Happen applications.
Core Principles
Happen's approach to state management follows these foundational principles:
Clear Ownership: Each node owns and is responsible for its specific state
Functional Transformations: State changes occur through pure functional transformations
Event-Driven Updates: State typically changes in response to events
Decentralized Intelligence: Nodes make autonomous decisions based on their local state
Composition Over Complexity: Complex state management emerges from composing simple patterns
Under the hood, Happen uses NATS JetStream's Key-Value store to provide durable, distributed state storage with high consistency guarantees.
Basic State Operations
Accessing State
Happen provides a clean, functional approach to accessing state:
// Access the complete state
const orderState = orderNode.state.get();
// Access with transformation (focused state access)
const activeOrders = orderNode.state.get(state =>
(state.orders || {}).filter(order => order.status === "active")
);
Transforming State
State transformations use a functional approach:
// Transform state with a function
orderNode.state.set(state => {
return {
...state,
orders: {
...state.orders,
"order-123": {
...state.orders["order-123"],
status: "shipped",
shippedAt: Date.now()
}
}
};
});
Composing Transformations
You can compose transformations for cleaner code:
// Define reusable transformers
const markShipped = (orderId) => (state) => ({
...state,
orders: {
...state.orders,
[orderId]: {
...state.orders[orderId],
status: "shipped",
shippedAt: Date.now()
}
}
});
// Apply transformer
orderNode.state.set(markShipped("order-123"));
View System
Views provide a window into other nodes' state, enabling coordinated operations across node boundaries.
Basic View Usage
// Transform state with views into other nodes' state
orderNode.state.set((state, views) => {
// Get customer data through views
const customerData = views.customer.get(state =>
state.customers[order.customerId]
);
// Get inventory data through views
const inventoryData = views.inventory.get(state =>
state.products[order.productId]
);
// Return updated state that incorporates external data
return {
...state,
orders: {
...state.orders,
[order.id]: {
...order,
canShip: inventoryData.inStock,
shippingAddress: customerData.address
}
}
};
});
Enhanced Collection
For more efficient collection of state from multiple nodes, use the collect pattern:
orderNode.state.set((state, views) => {
// Collect state from multiple nodes in a single operation
const data = views.collect({
customer: state => ({
name: state.customers[order.customerId].name,
address: state.customers[order.customerId].address
}),
inventory: state => ({
inStock: state.products[order.productId].quantity > 0,
leadTime: state.products[order.productId].leadTime
}),
shipping: state => ({
rates: state.ratesByRegion[customerRegion],
methods: state.availableMethods
})
});
// Use the collected data
return {
...state,
orders: {
...state.orders,
[order.id]: {
...order,
fulfillmentPlan: createFulfillmentPlan(data)
}
}
};
});
This approach:
Traverses the node graph only once
Collects specific slices of state from each relevant node
Transforms the state during collection
Returns a unified object with all the collected data
Scaling Patterns
As your application grows, these patterns help manage increasing state complexity.
Selective State Access
Access only what you need to minimize memory footprint:
// Instead of loading the entire state
const allOrders = orderNode.state.get();
// Access only what you need
const orderCount = orderNode.state.get(state =>
Object.keys(state.orders || {}).length
);
Domain Partitioning
Partition state across multiple domain-specific nodes:
// Instead of one large node with all state
const monolithNode = createNode("monolith");
// Create domain-specific nodes
const orderNode = createNode("order-service");
const customerNode = createNode("customer-service");
const inventoryNode = createNode("inventory-service");
Each node maintains state for its specific domain, communicating via events when necessary.
Event-Driven State Synchronization
Use events to communicate state changes between nodes:
// When state changes, broadcast an event
orderNode.on("update-order", event => {
// Update local state
orderNode.state.set(state => ({
...state,
orders: {
...state.orders,
[event.payload.orderId]: event.payload
}
}));
// Broadcast change event
orderNode.broadcast({
type: "order-updated",
payload: {
orderId: event.payload.orderId,
status: event.payload.status
}
});
});
// Other nodes maintain caches of relevant order data
dashboardNode.on("order-updated", event => {
// Update local cache
dashboardNode.state.set(state => ({
...state,
orderCache: {
...state.orderCache,
[event.payload.orderId]: {
status: event.payload.status,
updatedAt: Date.now()
}
}
}));
});
Chunked Processing
For large state objects, process in manageable chunks:
orderNode.on("process-large-dataset", async function* (event) {
const { dataset } = event.payload;
// Process in chunks
for (let i = 0; i < dataset.length; i += 100) {
const chunk = dataset.slice(i, i + 100);
// Process this chunk
for (const item of chunk) {
processItem(item);
}
// Yield progress
yield {
progress: Math.round((i + chunk.length) / dataset.length * 100),
processed: i + chunk.length
};
}
return { completed: true };
});
Advanced Patterns
State Versioning
Track state versions for debugging and auditing:
// Modify state with version tracking
orderNode.state.set(state => {
const newVersion = (state.version || 0) + 1;
return {
...state,
version: newVersion,
orders: {
...state.orders,
"order-123": {
...state.orders["order-123"],
status: "processing",
updatedAt: Date.now(),
version: newVersion
}
}
};
});
Computed State
Define computed values based on raw state:
orderNode.state.set((state, views) => {
// Compute derived values
const orderSummary = Object.values(state.orders || {}).reduce((summary, order) => {
summary.total += order.total;
summary.count += 1;
summary[order.status] = (summary[order.status] || 0) + 1;
return summary;
}, { total: 0, count: 0 });
// Return state with computed values
return {
...state,
computed: {
...state.computed,
orderSummary,
averageOrderValue: orderSummary.count > 0 ?
orderSummary.total / orderSummary.count : 0
}
};
});
State-Based Event Generation
Generate events based on state conditions:
orderNode.state.set((state, views) => {
// Update order state
const updatedState = {
...state,
orders: {
...state.orders,
"order-123": {
...state.orders["order-123"],
status: "payment-received"
}
}
};
// Check conditions
const order = updatedState.orders["order-123"];
const inventory = views.inventory.get(state =>
state.products[order.productId]
);
// Emit events based on state conditions
if (order.status === "payment-received" && inventory.inStock) {
orderNode.broadcast({
type: "order-ready-for-fulfillment",
payload: { orderId: "order-123" }
});
}
return updatedState;
});
NATS JetStream Key-Value Store Integration
Under the hood, Happen uses NATS JetStream's Key-Value store capability to provide durable, distributed state storage. The framework handles all the details of storing and retrieving state, ensuring:
Persistence: State is preserved even if nodes or processes restart
Distributed Access: State can be accessed across different processes and machines
Consistency: State updates maintain causal ordering
Performance: High-performance storage with minimal overhead
This integration happens transparently - your code uses the same state API regardless of where nodes are running or how state is stored.
Best Practices
Keep State Focused
Each node should maintain state relevant to its domain:
// Too broad - mixing domains
const monolithNode = createNode("app");
monolithNode.state.set({
orders: { /* ... */ },
customers: { /* ... */ },
inventory: { /* ... */ },
shipping: { /* ... */ }
});
// Better - focused domains
const orderNode = createNode("orders");
orderNode.state.set({ orders: { /* ... */ } });
const customerNode = createNode("customers");
customerNode.state.set({ customers: { /* ... */ } });
Use Immutable Patterns
Always update state immutably to maintain predictability:
// AVOID: Direct mutation
orderNode.state.set(state => {
state.orders["order-123"].status = "shipped"; // Mutation!
return state;
});
// BETTER: Immutable updates
orderNode.state.set(state => ({
...state,
orders: {
...state.orders,
"order-123": {
...state.orders["order-123"],
status: "shipped"
}
}
}));
Prefer Event Communication
Use events to communicate state changes between nodes:
// When state changes
orderNode.state.set(/* update state */);
// Broadcast the change
orderNode.broadcast({
type: "order-status-changed",
payload: {
orderId: "order-123",
status: "shipped"
}
});
Let Event Flow Drive State Changes
Instead of directly modifying state in response to external conditions, let events drive state changes:
// Respond to events with state transformations
orderNode.on("payment-received", event => {
orderNode.state.set(state => ({
...state,
orders: {
...state.orders,
[event.payload.orderId]: {
...state.orders[event.payload.orderId],
status: "paid",
paymentId: event.payload.paymentId,
paidAt: Date.now()
}
}
}));
});
By focusing on clear ownership, functional transformations, and event-driven communication, you can create systems that are both powerful and maintainable. The integration with NATS JetStream's Key-Value store provides a robust foundation for distributed state management without adding complexity to your application code.
Last updated