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 store | Config store | Secret store | |
|---|---|---|---|
| Purpose | Runtime business state | Non-sensitive configuration | Sensitive credentials |
| Examples | Sessions, counters, job status | Feature flags, API URLs | Passwords, tokens, certificates |
| Confidential | Sometimes | No | Yes |
| Changed by handlers | Yes | Yes | Rarely |
| Set at startup | No | No | No (fetched on demand) |
| Persistent | Yes (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
| Package | Backend | Best for |
|---|---|---|
@purista/core | In-memory | Development, testing |
@purista/redis-state-store | Redis | Production sessions, counters, caches |
@purista/nats-state-store | NATS KV | NATS-first platforms |
@purista/dapr-sdk | Dapr state store | Polyglot, 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.
DefaultStateStoreis 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