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:

  1. Clear Ownership: Each node owns and is responsible for its specific state

  2. Functional Transformations: State changes occur through pure functional transformations

  3. Event-Driven Updates: State typically changes in response to events

  4. Decentralized Intelligence: Nodes make autonomous decisions based on their local state

  5. 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:

  1. Persistence: State is preserved even if nodes or processes restart

  2. Distributed Access: State can be accessed across different processes and machines

  3. Consistency: State updates maintain causal ordering

  4. 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