View on GitHub
Clean Architecture Toolkit for .NET
Clean Architecture Toolkit is a clean, modular architecture foundation for .NET applications.
It embraces SOLID principles and focuses on maintainability, testability, and scalability through clear boundaries and pluggable design.

|   HOME   |   MODULE OVERVIEW   |   DEMO   |

Plans for Cat.Sagas

This plan looks at long running workflows.
A workflow sends a message, waits for the answer and goes on when the answer arrives, maybe hours later.

It answers one question: what do we add to Cat.FluentWorkflows, and what goes in a new module around it?

Contents

The idea

The place order workflow of the demo takes a payment. The idea:

  1. The workflow sends a TakePayment message over the Service Bus.
  2. The workflow stops and its state goes to the database. Nothing stays in memory.
  3. The payment provider answers with a PaymentTaken or PaymentDeclined message.
  4. The message finds the stored workflow, loads it and runs it on from the step after the wait.

The workflow then works as an orchestrator, also called a saga.

sequenceDiagram
    participant Orders
    participant Store as Workflow store
    participant Bus as Service Bus
    participant Payment as Payment provider

    Orders->>Orders: Run steps until the payment step
    Orders->>Bus: TakePayment (correlation id)
    Orders->>Store: Save state and position, then stop
    Bus->>Payment: TakePayment
    Payment->>Bus: PaymentTaken (correlation id)
    Bus->>Orders: PaymentTaken
    Orders->>Store: Load by correlation id
    Orders->>Orders: Run on after the payment step

Findings

I read the engine in modules/Cat.FluentWorkflows.

How it runs today

What this means

What is missing

Gap Where it has to live
A way for a step to stop the workflow and say “resume later” Core engine
An address for every step, stable between rebuilds Core engine
A checkpoint that holds the position, including the open If, While, Foreach, Switch and sub-workflow frames Core engine
A way to start a workflow at a checkpoint Core engine
A workflow instance id and a correlation key New module
Saving and loading the state and the checkpoint New module
Turning the state into JSON and back New module
A step that sends a message and waits for the answer New module
Matching an incoming message to a stored instance New module
Timeouts, such as cancel after 24 hours without payment New module, with Scheduling
Safe handling of two messages for the same instance New module
Sending a message and saving the state as one unit New module, with Messaging and the transactions of Cat.EFCore
Handling a workflow that changed while instances were stored Both

Options

Option A: split into short workflows, no engine change

Each wait becomes the end of a workflow. The answer starts a second workflow. The saga state is a status on an entity, for example Order.OrderStatus = AwaitingPayment. This is how many message based state machines work.

Option B: put everything in Cat.FluentWorkflows

Add suspend, storage, serializers and messaging to the existing module.

The engine learns to suspend and resume, in memory and with no storage. A new module adds the storage, the correlation and the messaging.

Option D: replay from the start

Record the result of every step and run the whole workflow again on every resume, skipping steps that already have a result, as Durable Task does.

Recommendation

Option C, with Option A as the first step.

Name

Cat.Sagas. Alternative: Cat.WorkflowPersistence. The saga name says what it is for, but the module also covers any long running workflow. The owner decides.

What goes where

In Cat.FluentWorkflows

All of it is generic and has no storage or messaging.

  1. CatStepExecutionResult.Suspend, added at the end of the enum so nothing breaks. Every container treats it like BreakCascade: stop at once and pass it up. The workflow processor reports it as a suspended workflow, with no error.
  2. Step addresses. The builder gives every step a position, for example 3, 3/then/1 or 4/item/2. A step can also get an explicit name, which is safer when a workflow changes.
  3. A workflow checkpoint. A small serializable object that holds:
    • the address of the step that suspended,
    • one frame per open container: the chosen branch of an If, the case of a Switch, the iteration of a While, the index of a Foreach and the sub-workflow,
    • the foreach loop state.
  4. Resume entry. ResumeAsync on ICatWorkflowProcessor. It takes the state and a checkpoint, rebuilds the workflow, skips to the address and goes on.
  5. A resumable step body. A second interface for steps that run in two phases: start (and suspend), and resume with the data of the answer. Output copies the answer into the state after the resume.
  6. Container support. If, Switch, While, Foreach and the sub-workflow step learn to write their frame when a child suspends, and to restore it on resume.
  7. A run context. Gives a step the instance id, so the new module can correlate. The engine only carries the id and does nothing with it.
  8. Rules in the readme. Build must be deterministic, and the state must be serializable if you want to save it.

In the new module

Following the usual module layout, with an abstractions project and implementations:

The instance record holds: instance id, workflow type name, workflow version, status, state as JSON, checkpoint as JSON, correlation key, the message type it waits for, the timeout moment and a row version for concurrency.

What the module depends on

Module Why
Cat.FluentWorkflows The engine to suspend and resume.
Cat.Messaging (future) The wait step sends the request message through it.
Cat.EventProcessor (future) Incoming answers come in through it.
Cat.Scheduling (future) Looks for instances whose wait timed out.
Cat.EFCore (new transaction project) Saves the state and the outgoing message together.
Cat.EFCore The store.

A note on EventAggregators

The wait step must not go through Cat.EventAggregators. That module is in-process and holds its subscribers with weak references. It cannot carry a message to another service, and it cannot be trusted to still be alive when an answer comes hours later.

The wait step depends on the messaging abstractions. The event aggregator stays what the demo plan says: it announces facts inside one service, and a subscriber bridges them to the Service Bus.

Hard problems

These must be solved in the design, not afterwards.

Phases

Phase Work Needs
0 Orders uses short workflows and the order status as saga state. The payment provider stays an interface with a fake. Nothing
1 Engine: Suspend, step addresses, checkpoint, ResumeAsync and container support. In memory, fully tested. Update the readme and the namespace pages. Nothing
2 Cat.Sagas.Abstractions and Cat.Sagas with an in-memory store, to prove the model with the wait step. Phase 1
3 Cat.Sagas.EFCore with the instance entity and concurrency. Phase 2, Cat.EFCore
4 Connect to Cat.Messaging and Cat.EventProcessor. Add the outbox and the inbox ids. Those modules
5 Timeouts with Cat.Scheduling. That module
6 Demo: the place order workflow waits for the payment answer over the Service Bus. All above

Phases 1 to 3 do not depend on the other future modules, so they can start before Messaging exists.

Decisions and open questions

Decided

Topic Decision
Approach Option C is recommended, with Option A as the first step. The owner has not confirmed it yet.
Orders now Orders keeps its state in the order status. The payment is an interface with a fake.
Messaging The wait step goes through the messaging abstractions, not through Cat.EventAggregators.
Order of work The engine change comes first and needs no other future module. The module itself follows Cat.Messaging and Cat.EventProcessor.

Open

Questions before building

  1. Do you confirm Option C, with Option A first? Or do you want another option?
  2. Name: Cat.Sagas or Cat.WorkflowPersistence?
  3. Do you accept an engine change in Cat.FluentWorkflows that touches every container step?
  4. Step addresses: index paths, explicit names or both?
  5. May we fail on a workflow version mismatch, or must two versions run side by side?
  6. Which serializer: System.Text.Json only, or pluggable?
  7. Do we need to wait for more than one answer at the same time in the first version?
  8. Who owns the outbox: this module, Cat.EFCore or Cat.Messaging?
  9. Compensation: explicit steps in the failure branch for now, and a dedicated feature later?
  10. Is Orders with Option A now fine, or must it wait for the saga work?
  11. Do timeouts wait for Cat.Scheduling, or does the first version go without them?