Stores — Data Persistence

State Store

Persistent application state across restarts

The state store is PURISTA’s abstraction for business state that must persist across restarts and be shared across service instances. Sessions, counters, job status, rate-limit windows, and cached computations all belong here — never in memory, never on the service instance.

State store vs. config store vs. secret store

PURISTA ships three distinct store abstractions. Use this table to pick the right one:

State storeConfig storeSecret store
PurposeRuntime business stateNon-sensitive configurationSensitive credentials
ExamplesSessions, counters, job statusFeature flags, API URLsPasswords, tokens, certificates
ConfidentialSometimesNoYes
Changed by handlersYesYesRarely
Set at startupNoNoNo (fetched on demand)
PersistentYes (with Redis/NATS)Yes (external store)Yes (managed by provider)

If the data is a credential → use a secret store.
If the data is configuration that operators tune → use a config store.
If the data is business state your handlers read and write → use a state store.

Why externalize state

PURISTA services are stateless. This means:

  • Any instance can handle any request
  • Scaling is horizontal (add instances, not faster code)
  • Restarting a service does not lose data
  • Failover is instant — no session affinity needed
flowchart LR
    S1[Service Instance 1] --> SS[State Store]
    S2[Service Instance 2] --> SS
    S3[Service Instance 3] --> SS
    SS --> DB[(Redis / NATS KV / Dapr)]

Usage

Pass a state store when creating the service instance:

import { RedisStateStore } from '@purista/redis-state-store'

const stateStore = new RedisStateStore({ url: process.env.REDIS_URL })

const userService = await userServiceV1Service.getInstance(eventBridge, {
  stateStore,
})
await userService.start()

Access from commands and subscriptions:

.setCommandFunction(async function (context, payload) {
  // Get one or more state values
  const state = await context.states.getState('session:abc123', 'counter:requests')

  // state['session:abc123'] — any JSON-serializable value
  // state['counter:requests'] — any JSON-serializable value

  // Set a state value
  await context.states.setState('session:abc123', { userId: 'user-456', cart: [] })

  // Increment a counter
  const current = await context.states.getState('counter:requests')
  await context.states.setState('counter:requests', (current['counter:requests'] || 0) + 1)

  // Remove a state value
  await context.states.removeState('session:abc123')
})

Validate state reads

State stores return unknown values. Validate them for type safety:

import { z } from 'zod'

const sessionSchema = z.object({
  userId: z.string(),
  cart: z.array(z.object({ productId: z.string(), qty: z.number() })),
})

.setCommandFunction(async function (context, payload) {
  const raw = await context.states.getState(`session:${payload.sessionId}`)
  const session = sessionSchema.parse(raw[`session:${payload.sessionId}`])

  // session is now fully typed
  const itemCount = session.cart.reduce((sum, item) => sum + item.qty, 0)
})

Official adapters

PackageBackendBest for
@purista/coreIn-memoryDevelopment, testing
@purista/redis-state-storeRedisProduction sessions, counters, caches
@purista/nats-state-storeNATS KVNATS-first platforms
@purista/dapr-sdkDapr state storePolyglot, service-mesh environments

Default state store

For local development:

import { DefaultStateStore } from '@purista/core'

const stateStore = new DefaultStateStore({
  enableGet: true,
  enableSet: true,
  enableRemove: true,
})

Custom state store

Extend StateStoreBaseClass to build your own:

import { StateStoreBaseClass, StoreBaseConfig } from '@purista/core'

type MyStateConfig = { url: string }

export class MyStateStore extends StateStoreBaseClass<MyStateConfig> {
  private client

  constructor(config: StoreBaseConfig<MyStateConfig>) {
    super('MyStateStore', config)
    this.client = customClient.connect(this.config.config.url)
  }

  protected async getStateImpl<Names extends string[]>(...names: Names) {
    const result: Record<string, unknown> = {}
    for (const name of names) {
      const value = await this.client.get(name)
      result[name] = value ? JSON.parse(value) : undefined
    }
    return result as any
  }

  protected async setStateImpl(name: string, value: unknown) {
    await this.client.set(name, JSON.stringify(value))
  }

  protected async removeStateImpl(name: string) {
    await this.client.del(name)
  }

  async destroy() {
    await this.client.disconnect()
    super.destroy()
  }
}

State patterns

Sessions

await context.states.setState(`session:${sessionId}`, {
  userId,
  createdAt: Date.now(),
})

const session = await context.states.getState(`session:${sessionId}`)

Rate limiting

const key = `rateLimit:${context.message.principalId}:${commandName}`
const current = await context.states.getState(key)
const count = (current[key]?.count || 0) + 1

if (count > 100) {
  throw new HandledError(StatusCode.TooManyRequests, 'Rate limit exceeded')
}

await context.states.setState(key, { count, windowStart: Date.now() })

Job status

await context.states.setState(`job:${jobId}`, {
  status: 'processing',
  startedAt: Date.now(),
  workerId: context.message.instanceId,
})

When to use state stores

  • User sessions that persist across requests
  • Rate-limit counters
  • Job status and progress tracking
  • Distributed locks and leader election
  • Cached computations shared across instances
  • Any data that must survive service restarts

Common pitfalls

  • In-memory state in production. DefaultStateStore is in-memory and loses data on restart. Use Redis or NATS in production.
  • Not validating reads. State stores return unknown. Always parse and validate.
  • Large state values. State stores are key-value, not document databases. Keep values small and JSON-serializable.
  • Ignoring TTL. Many backends support TTL. Use it for sessions and rate limits to prevent unbounded growth.
  • Using state for cross-service communication. State stores are for persistence, not messaging. Use commands and events for communication.

Checklist

  • Production uses a persistent state store (Redis, NATS KV, Dapr)
  • State reads are validated with Zod schemas
  • Values are JSON-serializable and reasonably sized
  • TTL is configured for ephemeral data (sessions, rate limits)
  • No business logic depends on in-memory state
  • Integration tests cover the concrete provider behavior

Related

Read Next
REST API Endpoints

from Exposing Your Service