StateChangeSlice
For a short summary of StateChangeSlice, see Reventless Components Overview.
This component follows the Reventless Component Structure Pattern, using separate files for interface definitions (StateChangeSlice.res), builder logic (StateChangeSlice_Builder.res), and callback/handler logic (StateChangeSlice_Callback.res).
Overview
The StateChangeSlice is a DCB (Dynamic Consistency Boundary) component that processes commands against a shared event-sourced state. It implements a decision model pattern where commands are evaluated against accumulated events to produce new events or errors.
Purpose and Responsibilities
- Responsibility: Process commands using a state (decision model) built from event history; append new events to the shared DcbEventLog; handle optimistic concurrency conflicts
- In: Commands from CommandTopic (routed by command type)
- Out: Events to DcbEventLog (via append operation)
- Key Feature: Multiple slices can coexist in a single plugin, each handling different command types but sharing the same event log
Relationship with DCB
StateChangeSlice is a core component of the DCB architecture:
Component Spec
The StateChangeSlice component requires a spec that defines its name, command type, error type, and decision logic:
module type Spec = {
let name: string
// Local subset of events this slice reads to rebuild its decision state.
// Declared locally — there is no shared DcbEventLogSpec module.
@schema
type consumedEvent
@schema
type command
@schema
type error
// Events this slice emits from `decide`.
@schema
type event
type state
let initialState: state
let evolve: (state, consumedEvent) => state
let decide: (state, command) => result<array<event>, error>
}
Spec Fields Explained
| Field | Type | Description |
|---|---|---|
name | string | Unique identifier for this slice |
consumedEvent | @schema type | Local subset of events this slice reads to build its decision state |
command | @schema type | Command type using @schema ppx for auto-generated schema |
error | @schema type | Error type for command processing failures |
event | @schema type | Events this slice emits from decide |
state | type | The state type built from accumulated events |
initialState | state | Starting state for new aggregates/entities |
evolve | (state, event) => state | Fold function to accumulate events into state |
decide | (state, command) => result<events, error> | Business logic to produce events from command |
Runtime Behavior
Command Processing Flow
Optimistic Concurrency Control
StateChangeSlice implements optimistic concurrency to handle concurrent command processing:
// The callback reads the current head position
let readResult = await dcbEventLog.read(~query)
// Uses the position as a condition for append
let condition: DcbTag.appendCondition = {
query,
after: ?readResult.headPosition,
}
// If another process appended between read and write, retry
switch await dcbEventLog.append(newEvents, ~condition) {
| Ok(position) => // Success
| Error(err) =>
if retries > 0 {
// Retry with fresh read
await attempt(~retries=retries - 1)
} else {
// Exhausted retries
Error("conflict: retries exhausted")
}
}
Key points:
- Reads event log state before processing
- Records
headPosition(sequence position of last event) - Uses conditional append: only succeeds if no events were added after
headPosition - Retries up to 3 times on conflict
- Provides detailed logging for debugging
Automatic Query Construction
The query is built automatically from the command schema via DcbTag.buildQueryFromCommand:
- Scalar tagged fields (e.g.,
itemId: stringauto-tagged by PPX) — all tags go into a single AND clause (single-entity query) - Tagged array fields (e.g.,
productId: array<string>auto-tagged on elements) — each element becomes its own OR clause (cross-entity query)
No configuration is needed — the schema determines the query mode automatically.
When a variant has multiple *Id fields, use @partitionTag on the field that should be the partition key, or @compositePartitionTag on multiple fields to form a composite key joined in declaration order — see PPX annotations.
Error Handling
Error Types
StateChangeSlice defines error types for business logic failures:
@schema
type error =
| ItemNotFound
| ItemAlreadyExists
| InsufficientStock(int) // With payload
| ValidationError(string)
Error Processing
Errors are:
- Returned from the
decidefunction - Logged with full context (slice name, command, error details)
- Converted to JSON using the error schema
- Passed back to the caller via CommandTopic reference
| Error(error) =>
let errorJson = error->S.reverseConvertToJsonOrThrow(Spec.errorSchema)->JSON.stringify
Logger.error(~loc=__LOC__, `StateChangeSlice(${Spec.name}): decide error`, errorJson)
Error(errorJson)
Conflict Resolution
When concurrent modifications cause conflicts:
| Error(err) =>
if retries > 0 {
Logger.info(~loc=__LOC__, `StateChangeSlice(${Spec.name}): conflict, retrying`, err)
await attempt(~retries=retries - 1)
} else {
Logger.error(
~loc=__LOC__,
`StateChangeSlice(${Spec.name}): conflict, retries exhausted`,
err,
)
Error("conflict: retries exhausted")
}
DCB Tags
StateChangeSlice uses DCB tags for efficient event queries. In slice files the PPX auto-injects @s.matches(DcbTag.string) on all *Id: string fields — no manual annotation needed:
// In a StateChangeSlice file — PPX auto-tags *Id fields
@schema
type command =
| CreateItem({itemId: string, name: string})
| RenameItem({itemId: string, newName: string})
The DCB tag:
- Marks the field as a DCB query key
- Automatically extracts tag values from commands at runtime
- Enables efficient querying of relevant events from DcbEventLog
Multiple *Id fields — partition key
When a variant has multiple *Id fields, use @partitionTag to mark which one is the partition key:
@schema
type event =
| DemandRecorded({
@partitionTag productId: string, // partition key
orderId: string, // also tagged as DcbTag.string
})
Composite partition keys
When the partition key should be derived from multiple fields joined in declaration order, use @compositePartitionTag. Each annotated field is still individually queryable as a regular tag:
@schema
type event =
| PluginSynced({
@compositePartitionTag environment: string, // "/" after (default)
@compositePartitionTag platformName: string, // "/" after
@compositePartitionTag pluginName: string, // last — sep ignored
version: string,
})
// Partition key: e.g. "prod/acme-platform/billing"
Use @compositePartitionTag(":") to set a different separator after a field. Cannot be combined with @partitionTag on the same schema.
Cross-Entity Queries with Tagged Arrays
When a command references multiple entities, use a *Id: array<string> field (singular name). Inside a StateChangeSlice/ folder the PPX auto-applies @s.matches(Reventless.DcbTag.string) to the element type:
@schema
type command =
| PlaceOrder({
orderId: string, // tagged automatically
productId: array<string>, // elements tagged automatically
})
For a plural-named field (productIds: array<string>) the PPX strips the trailing s and tags the elements under key productId, so a multi-value field shares a tag key with the singular-named producers.
The runtime automatically detects tagged array fields and builds multi-clause OR queries — one clause per scalar tag, one per array element. This fetches events for all referenced entities into the same state, enabling cross-entity validation at command time.
Key rule: name the array field to match the tag key on the referenced events (e.g., command field productId matches the productId tag on CatalogProductSynced events).
Best Practices
1. Keep Decision Models Focused
// Good: Focused on specific domain concern
type state = {
active: bool,
lastActivity: option<Js.Date.t>,
}
// Avoid: Bloated models trying to handle everything
type state = {
// ... 50+ fields for unrelated concerns
}
2. Match consumedEvent Exhaustively in evolve
// consumedEvent lists exactly the variants this slice reads,
// so evolve matches them all — no catch-all needed.
let evolve = (state, event) =>
switch event {
| KnownEvent1 => state // handle
| KnownEvent2 => state // handle
}
3. Idempotent Commands
Design commands to be idempotent when possible. A command that produces no state change should return Ok([]), not an error — commands may be retried under at-least-once delivery:
let decide = (state, command) =>
switch command {
| SetName({id, name}) =>
if name == state.name {
Ok([]) // idempotent — name unchanged, emit nothing
} else {
Ok([NameSet({id, name})])
}
}
4. Tag Only What's Needed
DCB tags are auto-applied to *Id fields inside StateChangeSlice/ folders. For a payload field that happens to end in Id but is not a query key, suppress tagging with @noDcbTag:
// Good: *Id fields are tagged automatically; suppress the ones that are payload only
@schema
type command = CreateItem({
itemId: string, // auto-tagged for entity lookup
@noDcbTag externalId: string, // payload data, not a DCB query key
})
Pulumi Outputs
type outputs = {
resources: array<Reventless.Adapter.resource>,
}
The StateChangeSlice reuses resources from the shared DcbEventLog:
- DynamoDB table for event storage
- SNS topic for event publishing
- Related IAM roles and policies
Related Components
- DcbEventLog - Shared event log for DCB slices
- CommandTopic - Command routing and filtering
- Plugin - Hosts DCB slices and creates shared infrastructure
- EventCollector - Consumes events from DcbEventLog
- ReadModel - Builds read models from DcbEventLog events
AWS Implementation
For detailed implementation with AWS services (DynamoDB for storage, SNS for publishing, SQS for commands), see StateChangeSlice AWS Adapter Documentation (reuses EventLog infrastructure).