Sagas
A saga describes a long running process as a sequence of events.
Sagas Overviewā
You can define a saga as a set of event handler functions. Each function runs in response to a specific event and can do the following:
- Send a command to an aggregate
- Schedule a command at the specified moment in time
- Store intermediate data in a persistent storage
- Trigger a side effect
This functionality allows you to organize branched chains of events and side effects to describe processes of any complexity. For example, the code below demonstrates a saga that structures a web site's user registration process:
/* eslint-disable import/no-anonymous-default-export*/
...
export default {
handlers: {
Init: async ({ store }) => {
await store.defineTable('users', {
indexes: { id: 'string' },
fields: ['mail'],
})
},
USER_CREATED: async ({ store, sideEffects }, event) => {
await store.insert('users', {
id: event.aggregateId,
mail: event.payload.mail,
})
await sideEffects.executeCommand({
aggregateName: 'User',
aggregateId: event.aggregateId,
type: 'requestConfirmUser',
payload: event.payload,
})
},
USER_CONFIRM_REQUESTED: async ({ sideEffects }, event) => {
await sideEffects.sendEmail(event.payload.mail, 'Confirm mail')
await sideEffects.scheduleCommand(
event.timestamp + 1000 * 60 * 60 * 24 * 7,
{
aggregateName: 'User',
aggregateId: event.aggregateId,
type: 'forgetUser',
payload: {},
}
)
},
USER_FORGOTTEN: async ({ store }, event) => {
await store.delete('users', {
id: event.aggregateId,
})
},
},
sideEffects: {
sendEmail: async (mail, content) => {
...
},
},
}
The saga requests that a new user confirms his/her email address. If the user does not confirm the address within one week, the saga cancels the registration.
Define a Sagaā
Add a Saga to the Applicationā
You can define a saga in one of the following ways:
In one source file as an object that contains the
handlers
andsideEffects
objects.common/sagas/user-confirmation.saga.js:
export default {
handlers: {
// Event handlers implementation
}
sideEffects: {
// Side effects implementation
}
}In two separate files.
common/sagas/user-confirmation.handlers.js:
export default {
// Event handlers implementation
}common/sagas/user-confirmation.side-effects.js:
export default {
// Side effects implementation
}
Initialize the EventStoreā
Every saga should define an Init
function that initializes the saga's persistent storage:
Init: async ({ store }) => {
await store.defineTable('users', {
indexes: { id: 'string' },
fields: ['mail'],
})
},
Handle Eventsā
An event handler function runs for each occurrence of a specific event. It has the following general structure:
EVENT_NAME: async ({ store, sideEffects }, event) => {
// Event handler logic
}
As a first argument, an event handler receives an object that provides access to the following API:
store
- Provides access to the saga's persistent store (similar to the Read Model store).sideEffects
- Provides access to the saga's side effect functions.
Use Side Effectsā
You should define all functions that have side effects in the sideEffects
object.
sideEffects: {
sendEmail: async (mail, content) => {
...
},
},
You can trigger the defined side effects from an event handler as shown below:
await sideEffects.sendEmail(event.payload.mail, 'Confirm mail')
The following side effect functions are available by default:
executeCommand
- Sends a command with the specified payload to an aggregate.scheduleCommand
- Similar toexecuteCommand
, but delays command execution until a specified moment in time.
Side Effect Starting Timestampā
Each saga stores a RESOLVE_SIDE_EFFECTS_START_TIMESTAMP
property. This property's value is a timestamp that defines the latest point in time for which side effects are allowed. If an event is older than this timestamp, all side effect functions for the current event handler are replaced with stub functions that do nothing. This is required to guarantee that side effect logic is never invoked more than once for a given event. Note that if you reset the Saga, the timestamp is preserved and side effects are not re-invoked as the saga rebuilds its state.
The sideEffects
object's isEnabled
field indicates whether or not side effects are enabled for the processed event.
If your need to re-run side effects after you reset a saga's state, use the @resolve-js/module-admin CLI tool to assign the desired timestamp to the RESOLVE_SIDE_EFFECTS_START_TIMESTAMP
property:
npx @resolve-js/module-admin sagas properties set "UserConfirmation" "RESOLVE_SIDE_EFFECTS_START_TIMESTAMP" $(date +%s%3N -d "yesterday")
You can also specify a new timestamp as an option for the sagas reset
command:
npx @resolve-js/module-admin sagas reset UserConfirmation --side-effects-start-timestamp 0000-00-0000:00:00.000
Send Aggregate Commandsā
Use the executeCommand
side effect function to send aggregate commands as shown below:
await sideEffects.executeCommand({
aggregateName: 'User',
aggregateId: event.aggregateId,
type: 'requestConfirmUser',
payload: event.payload,
})
Schedule Aggregate Commandsā
The code sample below demonstrates how the command executes at a specified point in time.
await sideEffects.scheduleCommand(
event.timestamp + 1000 * 60 * 60 * 24 * 7,
{
aggregateName: 'User',
aggregateId: event.aggregateId,
type: 'forgetUser',
payload: {},
}
)
Register a Sagaā
To use a saga in your application, you need to register it in the application's configuration file. If a saga is defined in a single file, you can register it as shown below:
sagas: [
{
name: 'UserConfirmation',
source: 'common/sagas/user-confirmation.saga.js',
connectorName: 'default',
},
]
If a saga is split between two files, register it as follows:
sagas: [
{
name: 'UserConfirmation',
source: 'common/sagas/user-confirmation.handlers.js',
sideEffects: 'common/sagas/user-confirmation.side-effects.js',
connectorName: 'default',
},
]
The connectorName
option defines a Read Model storage used to store the saga's persistent data.