Rule Engine
To express logic, prooph board offers a rule engine that is interpreted by Cody Play and Cody Engine.
This page describes how the rule engine works and what rules are available in which scopes.
Basic Rule Structure
Rules are defined as JSON objects flavoured with Jexl Expressions to make them dynamic. The various editors in the prooph board Metadata sidebar will help you to write rules by validating the structure and making suggestions.
Always vs. Condition
The most basic information about a rule is if it should always
be executed or only on certain condition
:
const rules = [
{
// This rule is always executed
"rule": "always",
"then": {
// ...
}
},
{
"rule": "condition",
"if": "...", // Jexl expression that returns true or a falsy value
"then": {
// then block is executed when condition is true
},
"else": {
// [optional] else block is executed when condition is falsy
},
// [optional] If condition is not met, you can stop the execution
"stop": true
},
{
"rule": "condition",
"if_not": "...", // Jexl expression that returns true or a falsy value
"then": {
// then block is executed when condition is NOT true
},
"else": {
// [optional] else block is executed when condition is true
},
// [optional] If condition is not met, you can stop the execution
"stop": true
}
]
Then
The then
part of a rule contains the actual processing logic. An else
part of a conditional rule is similar to then
and describes an alternative processing logic if the condition is not met.
Example
Here is an example of a log msg rule that is always executed:
const rules = [
{
"rule": "always",
"then": {
"log": {
// Please note: msg is a Jexl Expression, if we want to log a string,
// we have to enclose it in single qoutes
"msg": "'Hello from Cody Rule Engine'"
}
}
}
]
Scopes
The rule engine is used in different parts of the system. A scope defines which rules are allowed, for example you can only record events in a command handling scope aka. Business Rules.
Allowed in all scopes
Information Lookup Rules
Allowed in most backend scopes.
- count information
- find information
- find information by id
- find partial information
- find one information
- find one partial information
- find one partial information by id
- lookup user
- lookup users
Business Rules
Commands trigger business rules. The outcome of a processed command is recorded as an event and stored in the event store (@TODO: add link). Business rules should protect invariants by checking the current state of the system against the desired change represented by the command.
If you use Aggregate cards, business rules are defined on those cards, otherwise they are defined directly on Command cards. Event Modeling does not specify aggregates and encourages a design purely based on commands, events and information. Hence, prooph board can deal with both variants and gives you freedom of choice.
Available Rules
- record event
- call service
- information lookup rules
Jexl Context
command
: data of the commandmeta
: metadata of the command (incl. currentuser
)information
: current state of the aggregate, only set in aggregate business rules- Command Dependencies (@TODO: add link)
Examples
Default rule, that records a “Room Booked” event with the data of the command:
const rules = [
{
"rule": "always",
"then": {
"record": {
"event": "Room Booked",
"mapping": "command"
}
}
}
]
Same scenario as above, but this time with a business rule that a room can only be booked, if it is available. Each room is an aggregate and keeps track of its availability per day.
const rules = [
{
"rule": "condition",
// "information" is the current room aggregate state
"if": "information.bookedAt|contains(command.day)",
"then": {
"record": {
// We could also throw an error, but recording a failed event
// is the more elegant way to deal with this situation
"event": "Room Booking Failed",
"mapping": {
"roomId": "command.roomId",
"reason": "'Room is already booked at: ' + command.day"
}
}
},
"else": {
"record": {
"event": "Room Booked",
"mapping": "command"
}
}
}
]
Event Apply Rules
When working with event sourced aggregates, each aggregate event is applied to the aggregate state.
Processor Rules
- trigger command
- call service
- information lookup rules
Resolver Rules
TODO: Describe where
shortcut and default variable information
- call service
- information lookup rules
Projector Rules
TODO: Describe structure of projector rules and difference between given, when, then
.
Initialize Information Rules
TODO: Describe purpose
Only assign variable rules are allowed.
Rules
Assign Variable
Set a new or override an existing variable in the rule execution context.
interface ThenAssignVariable {
assign: {
variable: string;
value: PropMapping;
}
}
assign.variable
defines the name of the variable within the context.
assing.value
sets the value of the variable using Property Mapping
Example
const ctx = {};
const rules = [
{
"rule": "always",
"then": {
"assign": {
// After this rule, "msg" is set in ctx
"variable": "msg",
// Note: value is treated as an expression
// if we want to set a fixed string value
// we have to use single qoutes insight double qoutes
// to let the expression return a string
"value": "'Have nice day!'"
}
}
},
{
"rule": "condition",
// Check if it's Sunday
"if": "now()|weekDay() == 0",
"then": {
"assign": {
// Override variable "msg", if it's Sunday
"variable": "msg",
"value": "'Have nice Sunday!'"
}
}
}
]
await ruleEngine.exec(rules, ctx);
console.log(ctx.msg);
// On Sundays log will be: "Have a nice Sunday!"
// On all other days log will be: "Have a nice day!"
forEach
Execute a rule for each array item.
interface ThenForEach {
forEach: {
variable: string;
then: ThenType;
}
}
forEach.variable
defines the context variable that should be used for the loop.
forEach.then
defines the rule to be executed for each item of variable
.
Within the for each loop, item
(alias _
) and itemIndex
are available as variables.
💡 Tip: Use execute rules to execute more than one rule per item.
Example
// Invoice example context
const ctx = {
positions: [
{
label: 'Product A',
price: 10.99
},
{
label: 'Product B',
price: 5.50
}
],
// Initialize total with zero, calculation happens in the rules
total: 0,
}
const rules = [
{
"rule": "always",
"then": {
"forEach": {
// Iterate over all positions
"variable": "positions",
"then": {
// Calculate total, by adding each position price to current total
"assign": {
"variable": "total",
"value": "total + item.price"
}
}
}
}
}
]
await ruleEngine.exec(rules, ctx);
console.log(ctx.total);
// 16.49
Execute Rules
Defines a rule that executes a list of sub-rules. Useful to nest rules in a condition or forEach rule.
interface ThenExecuteRules {
execute: {
rules: Rule[]
}
}
Example
const rules = [
{
"rule": "condition",
"if": "order.submitted",
"then": {
"execute": {
"rules": [
{
"rule": "always",
"then": {
"call": {
"service": "Invoicing",
"method": "generate",
"arguments": ["order"]
}
}
},
{
"rule": "always",
"then": {
"call": {
"service": "Shipping",
"method": "prepare",
"arguments": ["order"]
}
}
}
]
}
}
}
]
Log Msg
Log a message to console to provide debugging information.
interface ThenLogMessage {
log: {
msg: JexlExpression | JexlExpression[],
logLevel?: 'info' | 'error' | 'warn'
}
}
log.msg
can be an expression or a list of expressions to log multiple values at once.
log.logLevel
defines the log level. Defaults to info
.
Example
const rules = [
{
"rule": "condition",
"if_not": "order.submitted",
"then": {
// js equivalent would be:
// console.error("Failed to process order: ", order)
"log": {
"msg": ["'Failed to process order: '", "order"],
"logLevel": "error"
}
},
"stop": true,
},
{
// ...
}
]
Throw Error
Stop the entire execution flow (e.g. command handling) and throw an error.
interface ThenThrowError {
throw: {
error: JexlExpression
}
}
Errors are caught on API level and translated into error responses. All errors are also logged in the server console. If an error is thrown in a live projection (@TODO: add link) all database changes are rolled back, meaning other projection changes as well as recorded events.
Example
const rules = [
{
"rule": "condition",
"if_not": "order.submitted",
"then": {
"throw": {
"error": "'Failed to process order: ' + order.orderId",
}
}
},
{
// ...
}
]
Count Information
Property Mapping
Set complex data structures using Jexl-flavoured JSON.
Most examples across this section demonstrate the functionality of Property Mapping (PropMapping) using Record Event rules, but it works in other rules as well. Check the specific rule sections for details.
Map an entire object
In a message-based system like Cody Engine you often need to copy data from one message to another to continue the data flow. Best example is when recording an event to capture the outcome of handling a command. In many cases, command data is passed 1:1 to the event. Hence, Property Mapping can be a single expression that provides the value to be set.
// Example context set up by Cody in the Business Rules scope
const ctx = {
command: command.payload,
meta: command.meta
}
const rules = [
{
"rule": "always",
"then": {
"record": {
"event": "Post Published",
// Here is the PropMapping defined for the event.
// The expression simply provides the data set in the variable "command"
// The rule logic takes the data and passes it to the event constructor.
"mapping": "command"
}
}
}
]
await ruleEngine.exec(rules, ctx);
Merge
Another common scenario is to take all data from one object and extend it with additional information.
You can use the special keyword $merge
to do so. The value of $merge
is of type PropMapping
again.
const rules = [
{
"rule": "always",
"then": {
"record": {
"event": "Post Published",
"mapping": {
// Merge all data into the object
"$merge": "command",
// + set an additional property
"publishedAt": "now()"
}
}
}
}
]
If the value of $merge
is of type array
, all array items are treated as Property Mappings and merged into the target object.
If two or more items provide objects with same property names, property values of later items override earlier ones.
const rules = [
{
"rule": "always",
"then": {
"record": {
"event": "Post Published",
"mapping": {
"$merge": [
// First, merge all data from the command
"command",
// Second, merge all user data
"meta.user"
]
}
}
}
}
]
JSON Structure with Expressions
All value strings within Property Mapping JSON are treated as expressions. This allows you to write complex structures in JSON and keep the expressions itself short and simple.
const rules = [
{
"rule": "always",
"then": {
"record": {
"event": "Post Published",
"mapping": {
"postId": "uuid()",
"title": "command.title",
"content": "command.content",
// Nested structure is possible, too
"authorInfo": {
"name": "meta.user.displayName",
"email": "meta.user.email"
},
// as well as arrays,
// whereby string items are treated as expressions again
"tags": [
"command.mainCategory",
"command.subCategory"
],
// and finally array of objects
"links": [
{
"href": "command.previousPost.link",
"title": "command.previousPost.title"
},
{
"href": "command.nextPost.link",
"title": "command.nextPost.title"
}
]
}
}
}
}
]