Add StellaOps.Workflow engine: 14 libraries, WebService, 8 test projects

Extract product-agnostic workflow engine from Ablera.Serdica.Workflow into
standalone StellaOps.Workflow.* libraries targeting net10.0.

Libraries (14):
- Contracts, Abstractions (compiler, decompiler, expression runtime)
- Engine (execution, signaling, scheduling, projections, hosted services)
- ElkSharp (generic graph layout algorithm)
- Renderer.ElkSharp, Renderer.ElkJs, Renderer.Msagl, Renderer.Svg
- Signaling.Redis, Signaling.OracleAq
- DataStore.MongoDB, DataStore.PostgreSQL, DataStore.Oracle

WebService: ASP.NET Core Minimal API with 22 endpoints

Tests (8 projects, 109 tests pass):
- Engine.Tests (105 pass), WebService.Tests (4 E2E pass)
- Renderer.Tests, DataStore.MongoDB/Oracle/PostgreSQL.Tests
- Signaling.Redis.Tests, IntegrationTests.Shared

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-20 19:14:44 +02:00
parent e56f9a114a
commit f5b5f24d95
422 changed files with 85428 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
# Tutorial 1: Hello World
The simplest possible workflow: initialize state from a start request, activate a single human task, and complete the workflow when the task is done.
## Concepts Introduced
- `IDeclarativeWorkflow<T>` — the contract every workflow implements
- `WorkflowSpec.For<T>()` — the builder entry point
- `.InitializeState()` — transforms the start request into workflow state
- `.StartWith(task)` — sets the first task to activate
- `WorkflowHumanTask.For<T>()` — defines a human task
- `.OnComplete(flow => flow.Complete())` — terminal step
## What Happens at Runtime
1. Client calls `StartWorkflowAsync` with `WorkflowName = "Greeting"` and payload `{ "customerName": "John" }`
2. State initializes to `{ "customerName": "John" }`
3. Task "Greet Customer" is created with status "Pending"
4. A user assigns the task to themselves, then completes it
5. `OnComplete` executes `.Complete()` — the workflow finishes
## Variants
- [C# Fluent DSL](csharp/)
- [Canonical JSON](json/)
## Next
[Tutorial 2: Service Tasks](../02-service-tasks/) — call external services before or after human tasks.

View File

@@ -0,0 +1,58 @@
using System.Collections.Generic;
using System.Text.Json;
using WorkflowEngine.Abstractions;
using WorkflowEngine.Contracts;
namespace WorkflowEngine.Tutorials;
// Start request — defines the input contract for the workflow.
public sealed class GreetingRequest
{
public string CustomerName { get; set; } = string.Empty;
}
// Workflow definition — implements IDeclarativeWorkflow<TStartRequest>.
public sealed class GreetingWorkflow : IDeclarativeWorkflow<GreetingRequest>
{
// Identity: name + version uniquely identify the workflow definition.
public string WorkflowName => "Greeting";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Customer Greeting";
// Roles: which user roles can see and interact with this workflow's tasks.
public IReadOnlyCollection<string> WorkflowRoles => ["DBA", "UR_AGENT"];
// Spec: the workflow specification built via the fluent DSL.
public WorkflowSpec<GreetingRequest> Spec { get; } = WorkflowSpec
.For<GreetingRequest>()
// InitializeState: transform the start request into the workflow's mutable state.
// State is a Dictionary<string, JsonElement> — all values are JSON-serialized.
.InitializeState(request => new Dictionary<string, JsonElement>
{
["customerName"] = JsonSerializer.SerializeToElement(request.CustomerName),
})
// StartWith: register and activate this task as the first step.
.StartWith(greetTask)
.Build();
// Tasks: expose task descriptors for the registration catalog.
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
// Task definition: defines name, type (UI component), route (navigation), and behavior.
private static readonly WorkflowHumanTaskDefinition<GreetingRequest> greetTask =
WorkflowHumanTask.For<GreetingRequest>(
taskName: "Greet Customer", // unique name within this workflow
taskType: "GreetCustomerTask", // UI component identifier
route: "customers/greet") // navigation route
.WithPayload(context => new Dictionary<string, JsonElement>
{
// Pass state values to the task's UI payload.
["customerName"] = context.StateValues
.GetRequired<string>("customerName").AsJsonElement(),
})
// OnComplete: what happens after the user completes this task.
.OnComplete(flow => flow.Complete()); // simply end the workflow
}

View File

@@ -0,0 +1,56 @@
{
"schemaVersion": "serdica.workflow.definition/v1",
"workflowName": "Greeting",
"workflowVersion": "1.0.0",
"displayName": "Customer Greeting",
"startRequest": {
"contractName": "GreetingRequest",
"allowAdditionalProperties": true
},
"workflowRoles": ["DBA", "UR_AGENT"],
"start": {
"initializeStateExpression": {
"$type": "object",
"properties": [
{
"name": "customerName",
"expression": { "$type": "path", "path": "start.customerName" }
}
]
},
"sequence": {
"steps": [
{
"$type": "activate-task",
"taskName": "Greet Customer"
}
]
}
},
"tasks": [
{
"taskName": "Greet Customer",
"taskType": "GreetCustomerTask",
"routeExpression": { "$type": "string", "value": "customers/greet" },
"taskRoles": [],
"payloadExpression": {
"$type": "object",
"properties": [
{
"name": "customerName",
"expression": { "$type": "path", "path": "state.customerName" }
}
]
},
"onCompleteSequence": {
"steps": [
{ "$type": "complete" }
]
}
}
]
}

View File

@@ -0,0 +1,29 @@
# Tutorial 2: Service Tasks
Call external services (microservices, HTTP APIs, GraphQL, RabbitMQ) from within a workflow. Handle failures and timeouts gracefully.
## Concepts Introduced
- `.Call()` — invoke a transport with payload and optional response capture
- Address types — `LegacyRabbit`, `Microservice`, `Http`, `Graphql`, `Rabbit`
- `resultKey` — store the service response in workflow state
- `whenFailure` / `whenTimeout` — recovery branches
- `WorkflowHandledBranchAction.Complete` — shorthand for "complete on error"
- `timeoutSeconds` — per-step timeout override (default: 1 hour)
## Key Points
- Each `Call` step executes synchronously within the workflow
- The per-step timeout wraps the entire call including transport-level retries
- Transport timeouts (30s default) control individual attempt duration
- If no failure/timeout handler is defined, the error propagates and the signal pump retries
## Variants
- [C# Fluent DSL](csharp/)
- [Canonical JSON](json/)
## Next
[Tutorial 3: Decisions](../03-decisions/) — branch workflow logic based on conditions.

View File

@@ -0,0 +1,83 @@
using System.Collections.Generic;
using System.Text.Json;
using WorkflowEngine.Abstractions;
using WorkflowEngine.Contracts;
namespace WorkflowEngine.Tutorials;
public sealed class PolicyValidationRequest
{
public long PolicyId { get; set; }
}
public sealed class PolicyValidationWorkflow : IDeclarativeWorkflow<PolicyValidationRequest>
{
public string WorkflowName => "PolicyValidation";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Policy Validation";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public WorkflowSpec<PolicyValidationRequest> Spec { get; } = WorkflowSpec
.For<PolicyValidationRequest>()
.InitializeState(WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId"))))
.StartWith(BuildFlow)
.Build();
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
private static void BuildFlow(WorkflowFlowBuilder<PolicyValidationRequest> flow)
{
flow
// --- Example 1: Simple call with shorthand error handling ---
.Call(
"Validate Policy", // step name
Address.LegacyRabbit("pas_policy_validate"), // transport address
WorkflowExpr.Object( // payload (expression-based)
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
WorkflowHandledBranchAction.Complete, // on failure: complete workflow
WorkflowHandledBranchAction.Complete) // on timeout: complete workflow
// --- Example 2: Call with typed response stored in state ---
.Call<object>(
"Load Policy Info",
Address.LegacyRabbit("pas_get_policy_product_info"),
WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete,
resultKey: "policyInfo") // store response as "policyInfo"
// Use the result to set state values
.SetIfHasValue("productCode",
WorkflowExpr.Func("upper", WorkflowExpr.Path("result.policyInfo.productCode")))
.SetIfHasValue("lob",
WorkflowExpr.Path("result.policyInfo.lob"))
// --- Example 3: Call with custom failure/timeout branches ---
.Call(
"Calculate Premium",
Address.LegacyRabbit("pas_premium_calculate_for_object",
SerdicaLegacyRabbitMode.MicroserviceConsumer),
WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
whenFailure: fail => fail
.Set("calculationFailed", WorkflowExpr.Bool(true))
.Complete(),
whenTimeout: timeout => timeout
.Set("calculationTimedOut", WorkflowExpr.Bool(true))
.Complete(),
timeoutSeconds: 120) // per-step timeout: 2 minutes
// --- Example 4: HTTP transport ---
// .Call("Notify External",
// Address.Http("authority", "/api/v1/notifications", "POST"),
// WorkflowExpr.Object(
// WorkflowExpr.Prop("message", WorkflowExpr.String("Policy validated"))),
// WorkflowHandledBranchAction.Complete,
// WorkflowHandledBranchAction.Complete)
.Complete();
}
}

View File

@@ -0,0 +1,89 @@
{
"schemaVersion": "serdica.workflow.definition/v1",
"workflowName": "PolicyValidation",
"workflowVersion": "1.0.0",
"displayName": "Policy Validation",
"workflowRoles": ["DBA"],
"start": {
"initializeStateExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } }
]
},
"sequence": {
"steps": [
{
"$type": "call-transport",
"stepName": "Validate Policy",
"invocation": {
"address": { "$type": "legacy-rabbit", "command": "pas_policy_validate", "mode": "Envelope" },
"payloadExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
]
}
},
"whenFailure": { "steps": [{ "$type": "complete" }] },
"whenTimeout": { "steps": [{ "$type": "complete" }] }
},
{
"$type": "call-transport",
"stepName": "Load Policy Info",
"resultKey": "policyInfo",
"invocation": {
"address": { "$type": "legacy-rabbit", "command": "pas_get_policy_product_info", "mode": "Envelope" },
"payloadExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
]
}
},
"whenFailure": { "steps": [{ "$type": "complete" }] },
"whenTimeout": { "steps": [{ "$type": "complete" }] }
},
{
"$type": "set-state",
"stateKey": "productCode",
"valueExpression": {
"$type": "function",
"functionName": "upper",
"arguments": [{ "$type": "path", "path": "result.policyInfo.productCode" }]
},
"onlyIfPresent": true
},
{
"$type": "call-transport",
"stepName": "Calculate Premium",
"timeoutSeconds": 120,
"invocation": {
"address": { "$type": "legacy-rabbit", "command": "pas_premium_calculate_for_object", "mode": "MicroserviceConsumer" },
"payloadExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
]
}
},
"whenFailure": {
"steps": [
{ "$type": "set-state", "stateKey": "calculationFailed", "valueExpression": { "$type": "boolean", "value": true } },
{ "$type": "complete" }
]
},
"whenTimeout": {
"steps": [
{ "$type": "set-state", "stateKey": "calculationTimedOut", "valueExpression": { "$type": "boolean", "value": true } },
{ "$type": "complete" }
]
}
},
{ "$type": "complete" }
]
}
},
"tasks": []
}

View File

@@ -0,0 +1,28 @@
# Tutorial 3: Decisions
Branch workflow logic based on conditions — state values, payload answers, or complex expressions.
## Concepts Introduced
- `.WhenExpression()` — branch on any boolean expression
- `.WhenStateFlag()` — shorthand for checking a boolean state value
- `.WhenPayloadEquals()` — shorthand for checking a task completion payload value
- Nested decisions — decisions inside decisions for complex routing
## Decision Types
| Method | Use When |
|--------|----------|
| `WhenExpression` | Complex conditions (comparisons, boolean logic, function calls) |
| `WhenStateFlag` | Checking a boolean state key against true/false |
| `WhenPayloadEquals` | Checking a task completion answer (inside OnComplete) |
## Variants
- [C# Fluent DSL](csharp/)
- [Canonical JSON](json/)
## Next
[Tutorial 4: Human Tasks](../04-human-tasks/) — approve/reject patterns with OnComplete flows.

View File

@@ -0,0 +1,72 @@
using System.Collections.Generic;
using WorkflowEngine.Abstractions;
using WorkflowEngine.Contracts;
namespace WorkflowEngine.Tutorials;
public sealed class PolicyRoutingRequest
{
public long PolicyId { get; set; }
public string AnnexType { get; set; } = string.Empty;
public bool PolicyExistsOnIPAL { get; set; }
}
public sealed class PolicyRoutingWorkflow : IDeclarativeWorkflow<PolicyRoutingRequest>
{
public string WorkflowName => "PolicyRouting";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Policy Routing Example";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public WorkflowSpec<PolicyRoutingRequest> Spec { get; } = WorkflowSpec
.For<PolicyRoutingRequest>()
.InitializeState(WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")),
WorkflowExpr.Prop("annexType", WorkflowExpr.Path("start.annexType")),
WorkflowExpr.Prop("policyExistsOnIPAL",
WorkflowExpr.Func("coalesce",
WorkflowExpr.Path("start.policyExistsOnIPAL"),
WorkflowExpr.Bool(true)))))
.StartWith(BuildFlow)
.Build();
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
private static void BuildFlow(WorkflowFlowBuilder<PolicyRoutingRequest> flow)
{
// --- Example 1: State flag decision (boolean shorthand) ---
flow.WhenStateFlag(
"policyExistsOnIPAL", // state key to check
true, // expected value
"Policy exists on IPAL?", // decision name (appears in diagram)
whenTrue: ipal => ipal
// --- Example 2: Expression decision ---
.WhenExpression(
"Annex Type?",
WorkflowExpr.Eq(
WorkflowExpr.Func("upper", WorkflowExpr.Path("state.annexType")),
WorkflowExpr.String("BENEF")),
benefit => benefit
.Set("route", WorkflowExpr.String("BENEFIT_PROCESSING"))
.Complete(),
// --- Example 3: Nested decision ---
other => other.WhenExpression(
"Is Equipment?",
WorkflowExpr.Eq(
WorkflowExpr.Func("upper", WorkflowExpr.Path("state.annexType")),
WorkflowExpr.String("ADDEQ")),
equipment => equipment
.Set("route", WorkflowExpr.String("EQUIPMENT_PROCESSING"))
.Complete(),
cover => cover
.Set("route", WorkflowExpr.String("COVER_CHANGE"))
.Complete())),
whenElse: notIpal => notIpal
.Set("route", WorkflowExpr.String("INSIS_PROCESSING"))
.Complete());
}
}

View File

@@ -0,0 +1,83 @@
{
"schemaVersion": "serdica.workflow.definition/v1",
"workflowName": "PolicyRouting",
"workflowVersion": "1.0.0",
"displayName": "Policy Routing Example",
"workflowRoles": ["DBA"],
"start": {
"initializeStateExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } },
{ "name": "annexType", "expression": { "$type": "path", "path": "start.annexType" } },
{ "name": "policyExistsOnIPAL", "expression": {
"$type": "function", "functionName": "coalesce",
"arguments": [
{ "$type": "path", "path": "start.policyExistsOnIPAL" },
{ "$type": "boolean", "value": true }
]
}}
]
},
"sequence": {
"steps": [
{
"$type": "decision",
"decisionName": "Policy exists on IPAL?",
"conditionExpression": { "$type": "path", "path": "state.policyExistsOnIPAL" },
"whenTrue": {
"steps": [
{
"$type": "decision",
"decisionName": "Annex Type?",
"conditionExpression": {
"$type": "binary", "operator": "eq",
"left": { "$type": "function", "functionName": "upper", "arguments": [{ "$type": "path", "path": "state.annexType" }] },
"right": { "$type": "string", "value": "BENEF" }
},
"whenTrue": {
"steps": [
{ "$type": "set-state", "stateKey": "route", "valueExpression": { "$type": "string", "value": "BENEFIT_PROCESSING" } },
{ "$type": "complete" }
]
},
"whenElse": {
"steps": [
{
"$type": "decision",
"decisionName": "Is Equipment?",
"conditionExpression": {
"$type": "binary", "operator": "eq",
"left": { "$type": "function", "functionName": "upper", "arguments": [{ "$type": "path", "path": "state.annexType" }] },
"right": { "$type": "string", "value": "ADDEQ" }
},
"whenTrue": {
"steps": [
{ "$type": "set-state", "stateKey": "route", "valueExpression": { "$type": "string", "value": "EQUIPMENT_PROCESSING" } },
{ "$type": "complete" }
]
},
"whenElse": {
"steps": [
{ "$type": "set-state", "stateKey": "route", "valueExpression": { "$type": "string", "value": "COVER_CHANGE" } },
{ "$type": "complete" }
]
}
}
]
}
}
]
},
"whenElse": {
"steps": [
{ "$type": "set-state", "stateKey": "route", "valueExpression": { "$type": "string", "value": "INSIS_PROCESSING" } },
{ "$type": "complete" }
]
}
}
]
}
},
"tasks": []
}

View File

@@ -0,0 +1,34 @@
# Tutorial 4: Human Tasks with OnComplete Flows
The approve/reject pattern — the most common human task flow in insurance workflows.
## Concepts Introduced
- `WorkflowHumanTask.For<T>()` — define a task with name, type, route, and roles
- `.WithPayload()` — data sent to the UI when the task is displayed
- `.WithTimeout(seconds)` — optional deadline for the task
- `.WithRoles()` — restrict which roles can interact with this task
- `.OnComplete(flow => ...)` — sequence executed after user completes the task
- `.ActivateTask()` — pause workflow and wait for user action
- `.AddTask()` — register a task in the workflow spec (separate from activation)
- Re-activation — send the user back to the same task on validation failure
## Approve/Reject Pattern
1. Workflow starts, runs some service tasks
2. `.ActivateTask("Approve")` — workflow pauses
3. User sees the task in their inbox, assigns it, submits an answer
4. `.OnComplete` checks `payload.answer`:
- `"approve"` — run confirmation operations, convert to policy
- `"reject"` — cancel the application
5. If operations fail, re-activate the same task for correction
## Variants
- [C# Fluent DSL](csharp/)
- [Canonical JSON](json/)
## Next
[Tutorial 5: Sub-Workflows](../05-sub-workflows/) — inline vs fire-and-forget child workflows.

View File

@@ -0,0 +1,101 @@
using System.Collections.Generic;
using System.Text.Json;
using WorkflowEngine.Abstractions;
using WorkflowEngine.Contracts;
namespace WorkflowEngine.Tutorials;
public sealed class ApprovalRequest
{
public long PolicyId { get; set; }
public long AnnexId { get; set; }
}
public sealed class ApprovalWorkflow : IDeclarativeWorkflow<ApprovalRequest>
{
public string WorkflowName => "ApprovalExample";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Approval Example";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA", "UR_UNDERWRITER"];
public WorkflowSpec<ApprovalRequest> Spec { get; } = WorkflowSpec
.For<ApprovalRequest>()
.InitializeState(WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")),
WorkflowExpr.Prop("annexId", WorkflowExpr.Path("start.annexId"))))
// Register the task definition (separate from activation).
.AddTask(approveTask)
// Start flow: validate, then activate the approval task.
.StartWith(flow => flow
.Call("Validate",
Address.LegacyRabbit("pas_policy_validate"),
WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete)
.ActivateTask("Approve Policy")) // pauses here
.Build();
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
// Define the human task with roles, payload, optional deadline, and OnComplete flow.
private static readonly WorkflowHumanTaskDefinition<ApprovalRequest> approveTask =
WorkflowHumanTask.For<ApprovalRequest>(
"Approve Policy", // task name
"PolicyApproval", // task type (UI component)
"business/policies", // route
taskRoles: ["UR_UNDERWRITER"]) // only underwriters
.WithPayload(WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId")),
WorkflowExpr.Prop("annexId", WorkflowExpr.Path("state.annexId"))))
.WithTimeout(86400) // 24-hour deadline (optional)
.OnComplete(BuildApprovalFlow);
private static void BuildApprovalFlow(WorkflowFlowBuilder<ApprovalRequest> flow)
{
flow
// Store the user's answer in state for auditability.
.Set("answer", WorkflowExpr.Path("payload.answer"))
// Branch on the answer.
.WhenPayloadEquals("answer", "reject", "Rejected?",
rejected => rejected
.Call("Cancel Application",
Address.LegacyRabbit("pas_annexprocessing_cancelaplorqt"),
WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete)
.Complete(),
approved => approved
.Call<object>("Perform Operations",
Address.LegacyRabbit("pas_operations_perform",
SerdicaLegacyRabbitMode.MicroserviceConsumer),
WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId")),
WorkflowExpr.Prop("stages", WorkflowExpr.Array(
WorkflowExpr.String("UNDERWRITING"),
WorkflowExpr.String("CONFIRMATION")))),
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete,
resultKey: "operations")
.Set("passed", WorkflowExpr.Path("result.operations.passed"))
.WhenStateFlag("passed", true, "Operations Passed?",
passed => passed
.Call("Convert To Policy",
Address.LegacyRabbit("pas_polreg_convertapltopol"),
WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete)
.Complete(),
// Operations failed: re-open the same task for the user to fix and retry.
failed => failed.ActivateTask("Approve Policy")));
}
}

View File

@@ -0,0 +1,144 @@
{
"schemaVersion": "serdica.workflow.definition/v1",
"workflowName": "ApprovalExample",
"workflowVersion": "1.0.0",
"displayName": "Approval Example",
"workflowRoles": ["DBA", "UR_UNDERWRITER"],
"start": {
"initializeStateExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } },
{ "name": "annexId", "expression": { "$type": "path", "path": "start.annexId" } }
]
},
"sequence": {
"steps": [
{
"$type": "call-transport",
"stepName": "Validate",
"invocation": {
"address": { "$type": "legacy-rabbit", "command": "pas_policy_validate", "mode": "Envelope" },
"payloadExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
]
}
},
"whenFailure": { "steps": [{ "$type": "complete" }] },
"whenTimeout": { "steps": [{ "$type": "complete" }] }
},
{
"$type": "activate-task",
"taskName": "Approve Policy",
"timeoutSeconds": 86400
}
]
}
},
"tasks": [
{
"taskName": "Approve Policy",
"taskType": "PolicyApproval",
"routeExpression": { "$type": "string", "value": "business/policies" },
"taskRoles": ["UR_UNDERWRITER"],
"payloadExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } },
{ "name": "annexId", "expression": { "$type": "path", "path": "state.annexId" } }
]
},
"onCompleteSequence": {
"steps": [
{ "$type": "set-state", "stateKey": "answer", "valueExpression": { "$type": "path", "path": "payload.answer" } },
{
"$type": "decision",
"decisionName": "Rejected?",
"conditionExpression": {
"$type": "binary", "operator": "eq",
"left": { "$type": "path", "path": "payload.answer" },
"right": { "$type": "string", "value": "reject" }
},
"whenTrue": {
"steps": [
{
"$type": "call-transport",
"stepName": "Cancel Application",
"invocation": {
"address": { "$type": "legacy-rabbit", "command": "pas_annexprocessing_cancelaplorqt", "mode": "Envelope" },
"payloadExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
]
}
},
"whenFailure": { "steps": [{ "$type": "complete" }] },
"whenTimeout": { "steps": [{ "$type": "complete" }] }
},
{ "$type": "complete" }
]
},
"whenElse": {
"steps": [
{
"$type": "call-transport",
"stepName": "Perform Operations",
"resultKey": "operations",
"invocation": {
"address": { "$type": "legacy-rabbit", "command": "pas_operations_perform", "mode": "MicroserviceConsumer" },
"payloadExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } },
{ "name": "stages", "expression": { "$type": "array", "items": [
{ "$type": "string", "value": "UNDERWRITING" },
{ "$type": "string", "value": "CONFIRMATION" }
]}}
]
}
},
"whenFailure": { "steps": [{ "$type": "complete" }] },
"whenTimeout": { "steps": [{ "$type": "complete" }] }
},
{ "$type": "set-state", "stateKey": "passed", "valueExpression": { "$type": "path", "path": "result.operations.passed" } },
{
"$type": "decision",
"decisionName": "Operations Passed?",
"conditionExpression": { "$type": "path", "path": "state.passed" },
"whenTrue": {
"steps": [
{
"$type": "call-transport",
"stepName": "Convert To Policy",
"invocation": {
"address": { "$type": "legacy-rabbit", "command": "pas_polreg_convertapltopol", "mode": "Envelope" },
"payloadExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
]
}
},
"whenFailure": { "steps": [{ "$type": "complete" }] },
"whenTimeout": { "steps": [{ "$type": "complete" }] }
},
{ "$type": "complete" }
]
},
"whenElse": {
"steps": [
{ "$type": "activate-task", "taskName": "Approve Policy" }
]
}
}
]
}
}
]
}
}
]
}

View File

@@ -0,0 +1,22 @@
# Tutorial 5: Sub-Workflows & Continuations
Compose workflows by invoking child workflows — either inline (SubWorkflow) or fire-and-forget (ContinueWith).
## SubWorkflow vs ContinueWith
| Feature | `.SubWorkflow()` | `.ContinueWith()` |
|---------|-----------------|-------------------|
| Parent waits | Yes — resumes after child completes | No — parent completes immediately |
| State flows back | Yes — child state merges into parent | No — child is independent |
| Same instance | Yes — tasks appear under parent instance | No — new workflow instance |
| Use when | Steps must complete before parent continues | Fire-and-forget, scheduled work |
## Variants
- [C# Fluent DSL](csharp/)
- [Canonical JSON](json/)
## Next
[Tutorial 6: Advanced Patterns](../06-advanced-patterns/) — Fork, Repeat, Timer, External Signal.

View File

@@ -0,0 +1,61 @@
using System.Collections.Generic;
using WorkflowEngine.Abstractions;
using WorkflowEngine.Contracts;
namespace WorkflowEngine.Tutorials;
public sealed class ParentWorkflow : IDeclarativeWorkflow<PolicyChangeWorkflowRequest>
{
public string WorkflowName => "ParentWorkflow";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Parent Workflow Example";
public IReadOnlyCollection<string> WorkflowRoles => [];
public WorkflowSpec<PolicyChangeWorkflowRequest> Spec { get; } = WorkflowSpec
.For<PolicyChangeWorkflowRequest>()
.InitializeState(WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId"))))
.StartWith(BuildFlow)
.Build();
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
private static void BuildFlow(WorkflowFlowBuilder<PolicyChangeWorkflowRequest> flow)
{
flow
.Call("Open For Change",
Address.LegacyRabbit("pas_annexprocessing_alterpolicy"),
WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete)
// --- SubWorkflow: inline execution, parent waits ---
// The child workflow runs within this execution.
// Its tasks appear under the parent instance.
// State from the child merges back into the parent after completion.
.SubWorkflow(
"Review Policy Changes",
new WorkflowWorkflowInvocationDeclaration
{
WorkflowName = "ReviewPolicyOpenForChange",
PayloadExpression = WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId")),
WorkflowExpr.Prop("productCode", WorkflowExpr.Path("state.productCode"))),
})
// Execution resumes here after child completes.
// --- ContinueWith: fire-and-forget ---
// The parent workflow completes immediately.
// A new independent workflow instance is created via the signal bus.
.ContinueWith(
"Start Transfer Process",
new WorkflowWorkflowInvocationDeclaration
{
WorkflowName = "TransferPolicy",
PayloadExpression = WorkflowExpr.Path("state"),
});
// Parent is now complete. TransferPolicy runs independently.
}
}

View File

@@ -0,0 +1,57 @@
{
"schemaVersion": "serdica.workflow.definition/v1",
"workflowName": "ParentWorkflow",
"workflowVersion": "1.0.0",
"displayName": "Parent Workflow Example",
"workflowRoles": [],
"start": {
"initializeStateExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } }
]
},
"sequence": {
"steps": [
{
"$type": "call-transport",
"stepName": "Open For Change",
"invocation": {
"address": { "$type": "legacy-rabbit", "command": "pas_annexprocessing_alterpolicy", "mode": "Envelope" },
"payloadExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
]
}
},
"whenFailure": { "steps": [{ "$type": "complete" }] },
"whenTimeout": { "steps": [{ "$type": "complete" }] }
},
{
"$type": "sub-workflow",
"stepName": "Review Policy Changes",
"invocation": {
"workflowName": "ReviewPolicyOpenForChange",
"payloadExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } },
{ "name": "productCode", "expression": { "$type": "path", "path": "state.productCode" } }
]
}
}
},
{
"$type": "continue-with-workflow",
"stepName": "Start Transfer Process",
"invocation": {
"workflowName": "TransferPolicy",
"payloadExpression": { "$type": "path", "path": "state" }
}
}
]
}
},
"tasks": []
}

View File

@@ -0,0 +1,22 @@
# Tutorial 6: Advanced Patterns
Fork (parallel branches), Repeat (retry loops), Timer (delays), and External Signal (wait for events).
## Patterns
| Pattern | Use When |
|---------|----------|
| **Fork** | Multiple independent operations that should run concurrently |
| **Repeat** | Retry a service call with backoff, poll until condition met |
| **Timer** | Delay between steps (backoff, scheduled processing) |
| **External Signal** | Wait for an external event (document upload, approval from another system) |
## Variants
- [C# Fluent DSL](csharp/)
- [Canonical JSON](json/)
## Next
[Tutorial 7: Shared Helpers](../07-shared-helpers/) — organizing reusable workflow components.

View File

@@ -0,0 +1,92 @@
using System.Collections.Generic;
using WorkflowEngine.Abstractions;
using WorkflowEngine.Contracts;
namespace WorkflowEngine.Tutorials;
public sealed class AdvancedPatternsWorkflow : IDeclarativeWorkflow<PolicyChangeWorkflowRequest>
{
public string WorkflowName => "AdvancedPatterns";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Advanced Patterns Example";
public IReadOnlyCollection<string> WorkflowRoles => [];
public WorkflowSpec<PolicyChangeWorkflowRequest> Spec { get; } = WorkflowSpec
.For<PolicyChangeWorkflowRequest>()
.InitializeState(WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")),
WorkflowExpr.Prop("retryAttempt", WorkflowExpr.Number(0)),
WorkflowExpr.Prop("integrationFailed", WorkflowExpr.Bool(false))))
.StartWith(BuildFlow)
.Build();
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
private static void BuildFlow(WorkflowFlowBuilder<PolicyChangeWorkflowRequest> flow)
{
flow
// ═══════════════════════════════════════════════
// FORK: parallel branches
// ═══════════════════════════════════════════════
// Both branches run concurrently. Workflow resumes after all complete.
.Fork("Process Documents and Notify",
documents => documents
.Call("Generate PDF",
Address.LegacyRabbit("pas_pdf_generate"),
WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete),
notification => notification
.Call("Send Email",
Address.LegacyRabbit("notifications_send_email",
SerdicaLegacyRabbitMode.MicroserviceConsumer),
WorkflowExpr.Object(
WorkflowExpr.Prop("to", WorkflowExpr.String("agent@company.com")),
WorkflowExpr.Prop("subject", WorkflowExpr.String("Policy processed"))),
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete))
// ═══════════════════════════════════════════════
// REPEAT: retry loop with backoff
// ═══════════════════════════════════════════════
// Retries up to 3 times while integrationFailed is true.
.Repeat(
"Retry Integration",
WorkflowExpr.Number(3), // max iterations
"retryAttempt", // counter state key
WorkflowExpr.Or( // continue while:
WorkflowExpr.Eq( // first attempt (counter == 0)
WorkflowExpr.Path("state.retryAttempt"),
WorkflowExpr.Number(0)),
WorkflowExpr.Path("state.integrationFailed")), // or previous attempt failed
body => body
.Set("integrationFailed", WorkflowExpr.Bool(false))
.Call("Transfer Policy",
Address.Http("integration", "/api/transfer", "POST"),
WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
whenFailure: fail => fail
.Set("integrationFailed", WorkflowExpr.Bool(true))
// TIMER: wait before retrying
.Wait("Backoff", WorkflowExpr.String("00:05:00")),
whenTimeout: timeout => timeout
.Set("integrationFailed", WorkflowExpr.Bool(true))))
// ═══════════════════════════════════════════════
// EXTERNAL SIGNAL: wait for event
// ═══════════════════════════════════════════════
// Workflow pauses until an external system raises the named signal.
.WaitForSignal(
"Wait for Document Upload",
signalName: "documents-ready",
resultKey: "uploadedDocs")
// Use the signal payload in subsequent steps.
.Set("documentCount",
WorkflowExpr.Func("length",
WorkflowExpr.Path("result.uploadedDocs.fileIds")))
.Complete();
}
}

View File

@@ -0,0 +1,127 @@
{
"schemaVersion": "serdica.workflow.definition/v1",
"workflowName": "AdvancedPatterns",
"workflowVersion": "1.0.0",
"displayName": "Advanced Patterns Example",
"workflowRoles": [],
"start": {
"initializeStateExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } },
{ "name": "retryAttempt", "expression": { "$type": "number", "value": 0 } },
{ "name": "integrationFailed", "expression": { "$type": "boolean", "value": false } }
]
},
"sequence": {
"steps": [
{
"$type": "fork",
"stepName": "Process Documents and Notify",
"branches": [
{
"steps": [
{
"$type": "call-transport",
"stepName": "Generate PDF",
"invocation": {
"address": { "$type": "legacy-rabbit", "command": "pas_pdf_generate", "mode": "Envelope" },
"payloadExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
]
}
},
"whenFailure": { "steps": [{ "$type": "complete" }] },
"whenTimeout": { "steps": [{ "$type": "complete" }] }
}
]
},
{
"steps": [
{
"$type": "call-transport",
"stepName": "Send Email",
"invocation": {
"address": { "$type": "legacy-rabbit", "command": "notifications_send_email", "mode": "MicroserviceConsumer" },
"payloadExpression": {
"$type": "object",
"properties": [
{ "name": "to", "expression": { "$type": "string", "value": "agent@company.com" } },
{ "name": "subject", "expression": { "$type": "string", "value": "Policy processed" } }
]
}
},
"whenFailure": { "steps": [{ "$type": "complete" }] },
"whenTimeout": { "steps": [{ "$type": "complete" }] }
}
]
}
]
},
{
"$type": "repeat",
"stepName": "Retry Integration",
"maxIterationsExpression": { "$type": "number", "value": 3 },
"iterationStateKey": "retryAttempt",
"continueWhileExpression": {
"$type": "binary", "operator": "or",
"left": {
"$type": "binary", "operator": "eq",
"left": { "$type": "path", "path": "state.retryAttempt" },
"right": { "$type": "number", "value": 0 }
},
"right": { "$type": "path", "path": "state.integrationFailed" }
},
"body": {
"steps": [
{ "$type": "set-state", "stateKey": "integrationFailed", "valueExpression": { "$type": "boolean", "value": false } },
{
"$type": "call-transport",
"stepName": "Transfer Policy",
"invocation": {
"address": { "$type": "http", "target": "integration", "path": "/api/transfer", "method": "POST" },
"payloadExpression": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
]
}
},
"whenFailure": {
"steps": [
{ "$type": "set-state", "stateKey": "integrationFailed", "valueExpression": { "$type": "boolean", "value": true } },
{ "$type": "timer", "stepName": "Backoff", "delayExpression": { "$type": "string", "value": "00:05:00" } }
]
},
"whenTimeout": {
"steps": [
{ "$type": "set-state", "stateKey": "integrationFailed", "valueExpression": { "$type": "boolean", "value": true } }
]
}
}
]
}
},
{
"$type": "external-signal",
"stepName": "Wait for Document Upload",
"signalNameExpression": { "$type": "string", "value": "documents-ready" },
"resultKey": "uploadedDocs"
},
{
"$type": "set-state",
"stateKey": "documentCount",
"valueExpression": {
"$type": "function",
"functionName": "length",
"arguments": [{ "$type": "path", "path": "result.uploadedDocs.fileIds" }]
}
},
{ "$type": "complete" }
]
}
},
"tasks": []
}

View File

@@ -0,0 +1,24 @@
# Tutorial 7: Shared Support Helpers
When building many workflows for the same domain (e.g., 50+ policy change workflows), extract reusable components into a support helper class.
## What to Extract
| Component | Example |
|-----------|---------|
| **Address constants** | `LegacyRabbitAddress`, `HttpAddress` — centralized routing |
| **Workflow references** | `WorkflowReference` — for SubWorkflow/ContinueWith targets |
| **Payload builders** | Static methods returning `WorkflowExpressionDefinition` |
| **State initializers** | Base state + override pattern |
| **Flow extensions** | Extension methods on `WorkflowFlowBuilder<T>` for common sequences |
## C#-Only Tutorial
This tutorial has no JSON equivalent — it covers C# code organization patterns.
- [C# Example](csharp/)
## Next
[Tutorial 8: Expressions](../08-expressions/) — path navigation, functions, and operators.

View File

@@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using WorkflowEngine.Abstractions;
using WorkflowEngine.Contracts;
namespace WorkflowEngine.Tutorials;
/// <summary>
/// Shared support helper for policy change workflows.
/// Centralizes addresses, payload builders, and reusable flow patterns.
/// </summary>
internal static class PolicyWorkflowSupport
{
// ═══════════════════════════════════════════════════════════
// ADDRESS REGISTRY
// Centralize all service routing in one place.
// ═══════════════════════════════════════════════════════════
internal static readonly LegacyRabbitAddress ValidatePolicyAddress =
new("pas_policy_validate");
internal static readonly LegacyRabbitAddress AlterPolicyAddress =
new("pas_annexprocessing_alterpolicy");
internal static readonly LegacyRabbitAddress CalculatePremiumAddress =
new("pas_premium_calculate_for_object",
SerdicaLegacyRabbitMode.MicroserviceConsumer);
internal static readonly LegacyRabbitAddress GetAnnexDescAddress =
new("pas_polannexes_get");
internal static readonly LegacyRabbitAddress NotificationEmailAddress =
new("notifications_send_email",
SerdicaLegacyRabbitMode.MicroserviceConsumer);
// ═══════════════════════════════════════════════════════════
// WORKFLOW REFERENCES
// For SubWorkflow and ContinueWith invocations.
// ═══════════════════════════════════════════════════════════
internal static readonly WorkflowReference ReviewPolicyReference =
new("ReviewPolicyOpenForChange");
internal static readonly WorkflowReference TransferPolicyReference =
new("TransferPolicy");
// ═══════════════════════════════════════════════════════════
// STATE INITIALIZATION
// Base state + override pattern for workflow families.
// ═══════════════════════════════════════════════════════════
/// <summary>
/// Builds a state initialization expression with common policy fields
/// and optional per-workflow overrides.
/// </summary>
internal static WorkflowExpressionDefinition BuildInitializeState(
params WorkflowNamedExpressionDefinition[] overrides)
{
var properties = new List<WorkflowNamedExpressionDefinition>
{
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("srAnnexId", WorkflowExpr.Path("start.srAnnexId")),
WorkflowExpr.Prop("srCustId", WorkflowExpr.Path("start.srCustId")),
WorkflowExpr.Prop("annexType", WorkflowExpr.Path("start.annexType")),
WorkflowExpr.Prop("beginDate", WorkflowExpr.Path("start.beginDate")),
WorkflowExpr.Prop("endDate", WorkflowExpr.Path("start.endDate")),
};
// Apply overrides: replace existing or add new properties.
foreach (var o in overrides)
{
var existing = properties.FindIndex(
p => string.Equals(p.Name, o.Name, StringComparison.OrdinalIgnoreCase));
if (existing >= 0)
{
properties[existing] = o;
}
else
{
properties.Add(o);
}
}
return WorkflowExpr.Object(properties);
}
// ═══════════════════════════════════════════════════════════
// PAYLOAD BUILDERS
// Reusable expressions for common service call payloads.
// ═══════════════════════════════════════════════════════════
internal static WorkflowExpressionDefinition BuildAlterPolicyPayload()
{
return WorkflowExpr.Object(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId")),
WorkflowExpr.Prop("beginDate", WorkflowExpr.Path("state.beginDate")),
WorkflowExpr.Prop("endDate", WorkflowExpr.Path("state.endDate")),
WorkflowExpr.Prop("annexType", WorkflowExpr.Path("state.annexType")));
}
internal static WorkflowExpressionDefinition BuildAnnexTypeEquals(string type)
{
return WorkflowExpr.Eq(
WorkflowExpr.Func("upper", WorkflowExpr.Path("state.annexType")),
WorkflowExpr.String(type));
}
internal static WorkflowExpressionDefinition BuildPolicyIdPayload()
{
return WorkflowExpr.Object(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId")));
}
// ═══════════════════════════════════════════════════════════
// WORKFLOW INVOCATION BUILDERS
// ═══════════════════════════════════════════════════════════
internal static WorkflowWorkflowInvocationDeclaration BuildReviewInvocation()
{
return new WorkflowWorkflowInvocationDeclaration
{
WorkflowName = ReviewPolicyReference.WorkflowName,
PayloadExpression = WorkflowExpr.Path("state"),
};
}
}
/// <summary>
/// Extension methods for common flow patterns.
/// Used across multiple workflows for DRY step sequences.
/// </summary>
internal static class PolicyWorkflowFlowExtensions
{
/// <summary>
/// Applies product info from a service call result into workflow state.
/// </summary>
internal static WorkflowFlowBuilder<T> ApplyProductInfo<T>(
this WorkflowFlowBuilder<T> flow,
string resultKey = "productInfo")
where T : class
{
return flow
.SetIfHasValue("productCode",
WorkflowExpr.Func("upper",
WorkflowExpr.Path($"result.{resultKey}.productCode")))
.SetIfHasValue("lob",
WorkflowExpr.Func("upper",
WorkflowExpr.Path($"result.{resultKey}.lob")))
.SetIfHasValue("contractType",
WorkflowExpr.Path($"result.{resultKey}.contractType"));
}
/// <summary>
/// Standard "load product info and apply" pattern.
/// </summary>
internal static WorkflowFlowBuilder<T> LoadAndApplyProductInfo<T>(
this WorkflowFlowBuilder<T> flow)
where T : class
{
return flow
.Call<object>("Load Product Info",
Address.LegacyRabbit("pas_get_policy_product_info"),
PolicyWorkflowSupport.BuildPolicyIdPayload(),
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete,
resultKey: "productInfo")
.ApplyProductInfo();
}
}

View File

@@ -0,0 +1,36 @@
# Tutorial 8: Expressions
The expression system enables declarative logic that compiles to portable canonical JSON. All expressions are evaluable at runtime without recompilation.
## Path Navigation
| Prefix | Source | Example |
|--------|--------|---------|
| `start.*` | Start request fields | `start.policyId` |
| `state.*` | Mutable workflow state | `state.customerName` |
| `payload.*` | Task completion payload | `payload.answer` |
| `result.*` | Step result (by resultKey) | `result.productInfo.lob` |
## Built-in Functions
| Function | Description | Example |
|----------|-------------|---------|
| `coalesce` | First non-null | `coalesce(state.id, start.id, 0)` |
| `concat` | String join | `concat("Policy #", state.policyNo)` |
| `add` | Sum | `add(state.attempt, 1)` |
| `if` | Conditional | `if(state.isVip, "VIP", "Standard")` |
| `isNullOrWhiteSpace` | Null/empty check | `isNullOrWhiteSpace(state.name)` |
| `length` | String/array length | `length(state.items)` |
| `upper` | Uppercase | `upper(state.annexType)` |
| `first` | First array element | `first(state.objects)` |
| `mergeObjects` | Deep merge | `mergeObjects(state, payload)` |
## Variants
- [C# Expression Builder](csharp/)
- [JSON Expression Format](json/)
## Next
[Tutorial 9: Testing](../09-testing/) — unit test setup with recording transports.

View File

@@ -0,0 +1,131 @@
using WorkflowEngine.Abstractions;
namespace WorkflowEngine.Tutorials;
/// <summary>
/// Expression builder examples showing all expression types.
/// These examples are not a runnable workflow — they demonstrate the WorkflowExpr API.
/// </summary>
internal static class ExpressionExamples
{
// ═══════════════════════════════════════════════
// LITERALS
// ═══════════════════════════════════════════════
static readonly WorkflowExpressionDefinition nullExpr = WorkflowExpr.Null();
static readonly WorkflowExpressionDefinition stringExpr = WorkflowExpr.String("hello");
static readonly WorkflowExpressionDefinition numberExpr = WorkflowExpr.Number(42);
static readonly WorkflowExpressionDefinition boolExpr = WorkflowExpr.Bool(true);
// ═══════════════════════════════════════════════
// PATH NAVIGATION
// ═══════════════════════════════════════════════
// Read from start request
static readonly WorkflowExpressionDefinition fromStart = WorkflowExpr.Path("start.policyId");
// Read from workflow state
static readonly WorkflowExpressionDefinition fromState = WorkflowExpr.Path("state.customerName");
// Read from task completion payload
static readonly WorkflowExpressionDefinition fromPayload = WorkflowExpr.Path("payload.answer");
// Read from step result (requires resultKey on the Call step)
static readonly WorkflowExpressionDefinition fromResult = WorkflowExpr.Path("result.productInfo.lob");
// Nested path navigation
static readonly WorkflowExpressionDefinition nested = WorkflowExpr.Path("state.entityData.address.city");
// ═══════════════════════════════════════════════
// OBJECT & ARRAY CONSTRUCTION
// ═══════════════════════════════════════════════
static readonly WorkflowExpressionDefinition obj = WorkflowExpr.Object(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId")),
WorkflowExpr.Prop("status", WorkflowExpr.String("ACTIVE")),
WorkflowExpr.Prop("tags", WorkflowExpr.Array(
WorkflowExpr.String("motor"),
WorkflowExpr.String("casco"))));
// ═══════════════════════════════════════════════
// COMPARISONS
// ═══════════════════════════════════════════════
static readonly WorkflowExpressionDefinition eq = WorkflowExpr.Eq(
WorkflowExpr.Path("state.status"), WorkflowExpr.String("APPROVED"));
static readonly WorkflowExpressionDefinition ne = WorkflowExpr.Ne(
WorkflowExpr.Path("state.status"), WorkflowExpr.String("REJECTED"));
static readonly WorkflowExpressionDefinition gt = WorkflowExpr.Gt(
WorkflowExpr.Path("state.premium"), WorkflowExpr.Number(1000));
// ═══════════════════════════════════════════════
// BOOLEAN LOGIC
// ═══════════════════════════════════════════════
static readonly WorkflowExpressionDefinition not = WorkflowExpr.Not(
WorkflowExpr.Path("state.isRejected"));
static readonly WorkflowExpressionDefinition and = WorkflowExpr.And(
WorkflowExpr.Path("state.isValid"),
WorkflowExpr.Not(WorkflowExpr.Path("state.isRejected")));
static readonly WorkflowExpressionDefinition or = WorkflowExpr.Or(
WorkflowExpr.Eq(WorkflowExpr.Path("state.status"), WorkflowExpr.String("APPROVED")),
WorkflowExpr.Eq(WorkflowExpr.Path("state.status"), WorkflowExpr.String("OVERRIDE")));
// ═══════════════════════════════════════════════
// FUNCTION CALLS
// ═══════════════════════════════════════════════
// Coalesce: first non-null value
static readonly WorkflowExpressionDefinition coalesce = WorkflowExpr.Func("coalesce",
WorkflowExpr.Path("state.customerId"),
WorkflowExpr.Path("start.customerId"),
WorkflowExpr.Number(0));
// String concatenation
static readonly WorkflowExpressionDefinition concat = WorkflowExpr.Func("concat",
WorkflowExpr.String("Policy #"),
WorkflowExpr.Path("state.policyNo"));
// Arithmetic
static readonly WorkflowExpressionDefinition increment = WorkflowExpr.Func("add",
WorkflowExpr.Func("coalesce",
WorkflowExpr.Path("state.attempt"), WorkflowExpr.Number(0)),
WorkflowExpr.Number(1));
// Conditional value
static readonly WorkflowExpressionDefinition conditional = WorkflowExpr.Func("if",
WorkflowExpr.Path("state.isVip"),
WorkflowExpr.String("VIP"),
WorkflowExpr.String("Standard"));
// Uppercase
static readonly WorkflowExpressionDefinition upper = WorkflowExpr.Func("upper",
WorkflowExpr.Path("state.annexType"));
// Null check
static readonly WorkflowExpressionDefinition nullCheck = WorkflowExpr.Func("isNullOrWhiteSpace",
WorkflowExpr.Path("state.integrationId"));
// Array length
static readonly WorkflowExpressionDefinition length = WorkflowExpr.Func("length",
WorkflowExpr.Path("state.documents"));
// ═══════════════════════════════════════════════
// COMBINING EXPRESSIONS (real-world patterns)
// ═══════════════════════════════════════════════
// "Use integration customer ID if present, otherwise use lookup ID"
static readonly WorkflowExpressionDefinition resolveCustomerId = WorkflowExpr.Func("if",
WorkflowExpr.Not(
WorkflowExpr.Func("isNullOrWhiteSpace",
WorkflowExpr.Path("state.integrationCustomerId"))),
WorkflowExpr.Path("state.integrationCustomerId"),
WorkflowExpr.Path("state.lookupCustomerId"));
// "Should we retry? (first attempt or previous failed, and not timed out)"
static readonly WorkflowExpressionDefinition shouldRetry = WorkflowExpr.Or(
WorkflowExpr.Eq(WorkflowExpr.Path("state.retryAttempt"), WorkflowExpr.Number(0)),
WorkflowExpr.And(
WorkflowExpr.Not(WorkflowExpr.Path("state.timedOut")),
WorkflowExpr.Path("state.integrationFailed")));
}

View File

@@ -0,0 +1,166 @@
{
"_comment": "Expression examples in canonical JSON format. Each key shows a different expression pattern.",
"literals": {
"null": { "$type": "null" },
"string": { "$type": "string", "value": "hello" },
"number": { "$type": "number", "value": 42 },
"boolean": { "$type": "boolean", "value": true }
},
"paths": {
"fromStartRequest": { "$type": "path", "path": "start.policyId" },
"fromState": { "$type": "path", "path": "state.customerName" },
"fromPayload": { "$type": "path", "path": "payload.answer" },
"fromResult": { "$type": "path", "path": "result.productInfo.lob" },
"nestedPath": { "$type": "path", "path": "state.entityData.address.city" }
},
"objectConstruction": {
"$type": "object",
"properties": [
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } },
{ "name": "status", "expression": { "$type": "string", "value": "ACTIVE" } },
{ "name": "tags", "expression": {
"$type": "array",
"items": [
{ "$type": "string", "value": "motor" },
{ "$type": "string", "value": "casco" }
]
}}
]
},
"comparisons": {
"equals": {
"$type": "binary", "operator": "eq",
"left": { "$type": "path", "path": "state.status" },
"right": { "$type": "string", "value": "APPROVED" }
},
"notEquals": {
"$type": "binary", "operator": "ne",
"left": { "$type": "path", "path": "state.status" },
"right": { "$type": "string", "value": "REJECTED" }
},
"greaterThan": {
"$type": "binary", "operator": "gt",
"left": { "$type": "path", "path": "state.premium" },
"right": { "$type": "number", "value": 1000 }
}
},
"booleanLogic": {
"not": {
"$type": "unary", "operator": "not",
"operand": { "$type": "path", "path": "state.isRejected" }
},
"and": {
"$type": "binary", "operator": "and",
"left": { "$type": "path", "path": "state.isValid" },
"right": {
"$type": "unary", "operator": "not",
"operand": { "$type": "path", "path": "state.isRejected" }
}
},
"or": {
"$type": "binary", "operator": "or",
"left": {
"$type": "binary", "operator": "eq",
"left": { "$type": "path", "path": "state.status" },
"right": { "$type": "string", "value": "APPROVED" }
},
"right": {
"$type": "binary", "operator": "eq",
"left": { "$type": "path", "path": "state.status" },
"right": { "$type": "string", "value": "OVERRIDE" }
}
}
},
"functions": {
"coalesce": {
"$type": "function", "functionName": "coalesce",
"arguments": [
{ "$type": "path", "path": "state.customerId" },
{ "$type": "path", "path": "start.customerId" },
{ "$type": "number", "value": 0 }
]
},
"concat": {
"$type": "function", "functionName": "concat",
"arguments": [
{ "$type": "string", "value": "Policy #" },
{ "$type": "path", "path": "state.policyNo" }
]
},
"increment": {
"$type": "function", "functionName": "add",
"arguments": [
{
"$type": "function", "functionName": "coalesce",
"arguments": [
{ "$type": "path", "path": "state.attempt" },
{ "$type": "number", "value": 0 }
]
},
{ "$type": "number", "value": 1 }
]
},
"conditional": {
"$type": "function", "functionName": "if",
"arguments": [
{ "$type": "path", "path": "state.isVip" },
{ "$type": "string", "value": "VIP" },
{ "$type": "string", "value": "Standard" }
]
},
"uppercase": {
"$type": "function", "functionName": "upper",
"arguments": [{ "$type": "path", "path": "state.annexType" }]
},
"nullCheck": {
"$type": "function", "functionName": "isNullOrWhiteSpace",
"arguments": [{ "$type": "path", "path": "state.integrationId" }]
},
"arrayLength": {
"$type": "function", "functionName": "length",
"arguments": [{ "$type": "path", "path": "state.documents" }]
}
},
"realWorldPatterns": {
"resolveCustomerId_comment": "Use integration customer ID if present, otherwise use lookup ID",
"resolveCustomerId": {
"$type": "function", "functionName": "if",
"arguments": [
{
"$type": "unary", "operator": "not",
"operand": {
"$type": "function", "functionName": "isNullOrWhiteSpace",
"arguments": [{ "$type": "path", "path": "state.integrationCustomerId" }]
}
},
{ "$type": "path", "path": "state.integrationCustomerId" },
{ "$type": "path", "path": "state.lookupCustomerId" }
]
},
"shouldRetry_comment": "First attempt or previous failed and not timed out",
"shouldRetry": {
"$type": "binary", "operator": "or",
"left": {
"$type": "binary", "operator": "eq",
"left": { "$type": "path", "path": "state.retryAttempt" },
"right": { "$type": "number", "value": 0 }
},
"right": {
"$type": "binary", "operator": "and",
"left": {
"$type": "unary", "operator": "not",
"operand": { "$type": "path", "path": "state.timedOut" }
},
"right": { "$type": "path", "path": "state.integrationFailed" }
}
}
}
}

View File

@@ -0,0 +1,29 @@
# Tutorial 9: Testing Your Workflow
Write unit tests for workflows using `RecordingSerdicaLegacyRabbitTransport` and `TechnicalStyleWorkflowTestHelpers`.
## Test Setup Pattern
1. Create a recording transport with pre-configured responses
2. Build a test service provider via `TechnicalStyleWorkflowTestHelpers.CreateServiceProvider`
3. Resolve `WorkflowRuntimeService` from DI
4. Call `StartWorkflowAsync` with test payload
5. Assert: tasks created, transport calls made, state values correct
6. Optionally complete tasks and verify downstream behavior
## What to Test
| Scenario | Approach |
|----------|----------|
| Workflow starts correctly | Assert single open task after start |
| Service calls made in order | `transport.Invocations.Select(x => x.Command).Should().Equal(...)` |
| Rejection flow | Complete task with `"answer": "reject"`, verify cancel call |
| Approval flow | Complete with `"answer": "approve"`, verify conversion calls |
| Operations failure re-opens task | Check same task re-appears after operations return `passed: false` |
| Sub-workflow creates child tasks | Query tasks by child workflow name |
| Business reference set | `startResponse.BusinessReference.Key.Should().Be(...)` |
## C#-Only Tutorial
- [C# Test Examples](csharp/)

View File

@@ -0,0 +1,196 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using WorkflowEngine.Abstractions;
using WorkflowEngine.Contracts;
using WorkflowEngine.Services;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
namespace WorkflowEngine.Tutorials.Tests;
[TestFixture]
public class WorkflowTestExamples
{
// ═══════════════════════════════════════════════
// BASIC: Start workflow and verify task created
// ═══════════════════════════════════════════════
[Test]
public async Task Workflow_WhenStarted_ShouldCreateOpenTask()
{
// 1. Configure fake transport responses
var transport = new RecordingSerdicaLegacyRabbitTransport()
.Respond("pas_policy_validate", new { valid = true })
.Respond("pas_get_policy_product_info", new
{
productCode = "4704",
lob = "MOT",
contractType = "STANDARD",
});
// 2. Build test service provider
using var provider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider(
transport,
WorkflowRuntimeProviderNames.SerdicaEngine);
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
// 3. Start the workflow
var start = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "ApproveApplication",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 12345L,
["srAnnexId"] = 67890L,
["srCustId"] = 11111L,
},
});
// 4. Assert task was created
var tasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = start.WorkflowInstanceId,
Status = "Open",
});
tasks.Tasks.Should().ContainSingle();
tasks.Tasks.Single().TaskName.Should().Be("Approve Application");
}
// ═══════════════════════════════════════════════
// VERIFY: Service calls made in order
// ═══════════════════════════════════════════════
[Test]
public async Task Workflow_WhenStarted_ShouldCallServicesInOrder()
{
var transport = new RecordingSerdicaLegacyRabbitTransport()
.Respond("pas_annexprocessing_alterpolicy", new
{
srPolicyId = 1L,
srAnnexId = 2L,
previouslyOpened = false,
})
.Respond("pas_polclmparticipants_create", new { ok = true })
.Respond("pas_premium_calculate_for_object", new { ok = true },
SerdicaLegacyRabbitMode.MicroserviceConsumer)
.Respond("pas_polannexes_get", new
{
shortDescription = "Test annex",
policyNo = "POL-001",
});
using var provider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider(
transport,
WorkflowRuntimeProviderNames.SerdicaEngine);
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "AssistantAddAnnex",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 1L,
["srAnnexId"] = 2L,
["policyExistsOnIPAL"] = true,
["annexPreviouslyOpened"] = false,
["annexType"] = "BENEF",
["entityData"] = new { srCustId = 3L },
},
});
// Verify exact call sequence
transport.Invocations.Select(x => x.Command)
.Should().Equal(
"pas_annexprocessing_alterpolicy",
"pas_polclmparticipants_create",
"pas_premium_calculate_for_object",
"pas_polannexes_get");
}
// ═══════════════════════════════════════════════
// TASK COMPLETION: Approve/reject flows
// ═══════════════════════════════════════════════
[Test]
public async Task Workflow_WhenTaskCompleted_ShouldExecuteOnCompleteFlow()
{
var transport = new RecordingSerdicaLegacyRabbitTransport()
.Respond("pas_operations_perform", new { passed = true, nextStep = "CONTINUE" },
SerdicaLegacyRabbitMode.MicroserviceConsumer)
.Respond("pas_polreg_convertapltopol", new { ok = true })
.Respond("pas_annexprocessing_generatepolicyno", new { ok = true })
.Respond("pas_get_policy_product_info", new
{
productCode = "4704",
lob = "MOT",
contractType = "STANDARD",
});
using var provider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider(
transport,
WorkflowRuntimeProviderNames.SerdicaEngine);
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
// Start workflow
var start = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "ApproveApplication",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 1L,
["srAnnexId"] = 2L,
["srCustId"] = 3L,
},
});
// Get the open task
var task = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = start.WorkflowInstanceId,
Status = "Open",
})).Tasks.Single();
// Complete with "approve"
await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest
{
WorkflowTaskId = task.WorkflowTaskId,
ActorId = "test-user",
ActorRoles = ["DBA"],
Payload = new Dictionary<string, object?> { ["answer"] = "approve" },
});
// Verify operations and conversion were called
transport.Invocations.Should().Contain(x => x.Command == "pas_operations_perform");
transport.Invocations.Should().Contain(x => x.Command == "pas_polreg_convertapltopol");
}
// ═══════════════════════════════════════════════
// RECORDING TRANSPORT: multiple responses
// ═══════════════════════════════════════════════
[Test]
public void RecordingTransport_CanConfigureMultipleResponses()
{
var transport = new RecordingSerdicaLegacyRabbitTransport()
// Default mode (Envelope)
.Respond("command_a", new { result = "first" })
// Specific mode
.Respond("command_b", new { result = "second" },
SerdicaLegacyRabbitMode.MicroserviceConsumer)
// Same command, different responses (returned in order)
.Respond("command_c", new { attempt = 1 })
.Respond("command_c", new { attempt = 2 });
// After workflow execution, inspect:
// transport.Invocations — list of all calls made
// transport.Invocations[0].Command — command name
// transport.Invocations[0].Payload — request payload
transport.Should().NotBeNull();
}
}

View File

@@ -0,0 +1,32 @@
# Workflow Declaration Tutorials
Step-by-step tutorials for building workflows with the Serdica Workflow Engine. Each tutorial is available in both **C# fluent DSL** and **canonical JSON** variants.
## Reference Documentation
- [Engine Reference Manual](../ENGINE.md) - Architecture, configuration, service surface, timeout model, signal system
- [Fluent DSL Syntax Guide](../workflow-fluent-syntax-guide.md) - Complete DSL method reference
## Tutorials
| # | Tutorial | C# | JSON | Topics |
|---|---------|-----|------|--------|
| 01 | [Hello World](01-hello-world/) | [C#](01-hello-world/csharp/) | [JSON](01-hello-world/json/) | Minimal workflow, single task, state init |
| 02 | [Service Tasks](02-service-tasks/) | [C#](02-service-tasks/csharp/) | [JSON](02-service-tasks/json/) | Transport calls, addresses, failure/timeout handling |
| 03 | [Decisions](03-decisions/) | [C#](03-decisions/csharp/) | [JSON](03-decisions/json/) | WhenExpression, WhenStateFlag, nested branching |
| 04 | [Human Tasks](04-human-tasks/) | [C#](04-human-tasks/csharp/) | [JSON](04-human-tasks/json/) | Approve/reject, OnComplete, re-activation, deadlines |
| 05 | [Sub-Workflows](05-sub-workflows/) | [C#](05-sub-workflows/csharp/) | [JSON](05-sub-workflows/json/) | SubWorkflow vs ContinueWith, state flow |
| 06 | [Advanced Patterns](06-advanced-patterns/) | [C#](06-advanced-patterns/csharp/) | [JSON](06-advanced-patterns/json/) | Fork, Repeat, Timer, External Signal |
| 07 | [Shared Helpers](07-shared-helpers/) | [C#](07-shared-helpers/csharp/) | - | Address registries, payload builders, extensions |
| 08 | [Expressions](08-expressions/) | [C#](08-expressions/csharp/) | [JSON](08-expressions/json/) | Path navigation, functions, operators |
| 09 | [Testing](09-testing/) | [C#](09-testing/csharp/) | - | Recording transports, task completion, assertions |
## How to Read
Each tutorial folder contains:
- **`README.md`** - Explanation, concepts, and what to expect
- **`csharp/`** - C# fluent DSL examples
- **`json/`** - Equivalent canonical JSON definitions (where applicable)
Start with Tutorial 01 and progress sequentially. Tutorials 07 (Shared Helpers) and 09 (Testing) are C#-only since they cover code organization and test infrastructure.