Orchestration workflows
Orchestration connects domain decisions. An event from one actor can cause a command to another actor whose state owns the next decision. A registration request can add a registration ID to a workshop roster; a cancellation can remove it.
On this page
Keep that relationship explicit: which event is the trigger, which actor is the destination, and which command describes the next action? It is easier to understand and recover than embedding unrelated writes inside the source actor's replay handler.
A typed event-to-command listener
The listener uses OrchestrationStreamSubscription to observe the registration actor's events. Its On methods address the roster actor using the workshop ID in the payload.
[OrchestrationStreamSubscription(typeof(RegistrationActor))]
public sealed class WorkshopEnrolmentOrchestration : OrchestrationActor
{
public Task On(EventEnvelope<RegistrationRequested> envelope) =>
System.ActorOf<IWorkshopRosterActor>(envelope.Event.WorkshopId.ToString("D"))
.Tell(new AddAttendee(envelope.Event.Headers, envelope.Event.WorkshopId,
envelope.Event.RegistrationId, envelope.Event.AttendeeId));
public Task On(EventEnvelope<RegistrationCancelled> envelope) =>
System.ActorOf<IWorkshopRosterActor>(envelope.Event.WorkshopId.ToString("D"))
.Tell(new RemoveAttendee(envelope.Event.Headers, envelope.Event.WorkshopId,
envelope.Event.RegistrationId));
}
The event's headers are passed to InternalCommand. Ordinary Troolio dispatch derives the next command's message and causation context. The business registration ID remains in the payload so the destination can recognize repeated work independently of diagnostics.
Return or await the command's task. Starting a task and returning before it completes makes failure and completion harder to associate with the trigger. A detached task also needs a durable job record if it must survive process loss. An in-memory task is not such a record.
Give the target a safe duplicate policy
A repeated AddAttendee should not inflate the count. The roster records registration IDs and checks whether an ID already maps to the same attendee. A conflicting reuse of that ID is an error.
Cancellation needs more than removing a dictionary entry. A cancellation may be delivered before an older add, or the add may be retried later. The sample records removed registration IDs as tombstones, so cancelled registrations remain cancelled.
public IEnumerable<Event> Handle(RemoveAttendee command)
{
if (!Guid.TryParse(Id, out Guid workshopId) || workshopId != command.WorkshopId)
throw new ArgumentException("Workshop key mismatch.");
return removed.Contains(command.RegistrationId)
? [] : [new AttendeeRemoved(command.Headers, command.RegistrationId)];
}
public void On(AttendeeAdded e) => attendees = attendees.Add(e.RegistrationId, e.AttendeeId);
public void On(AttendeeRemoved e)
{
attendees = attendees.Remove(e.RegistrationId);
removed = removed.Add(e.RegistrationId);
}
Tombstones themselves have a retention policy. If they are discarded while old work can still arrive, the application needs another durable identity or lifecycle fence. A large roster may also need a different boundary rather than one actor that retains unlimited registration history.
Decide which workflow protocol is required
| Need | Design choice |
|---|---|
| React to one actor event with another domain command | An ordinary orchestration listener plus the target's validation and retry policy. |
| Commit a reviewed closed set of participant events and handoffs together | Selected event-only planning, route authority, and a capable atomic store. |
| Wait for an external provider or human decision | Durable application workflow state and effect/job workers. |
| Build a list or report for readers | A projection and read model. |
Ordinary orchestration uses Troolio's command transaction context, but a listener by itself does not establish a provider's atomic multi-stream guarantee. The selected route requires reviewed handlers, closed planning, and store capabilities. The full operation must be checked at that boundary.
Long-running workflows
A provider call can take seconds or minutes and may have an uncertain outcome. Record the work to be done, dispatch it through an idempotent provider, and then record the known result as another domain command. Avoid keeping one actor request open indefinitely while waiting for a remote system or an approval click.
A useful state model is requested → processing → succeeded or requested → processing → failed. Preserve a workflow or attempt ID in each result so a late response cannot complete a newer attempt. A cancelled workflow should define what happens to an already-running provider request; cancellation does not roll back a remote action automatically.
If an external step already happened, a compensating action is another explicit business operation, such as cancelling a provider booking. It has its own outcome and retry policy. Do not describe it as undoing an event history.
Observe the whole chain
Log the triggering stream/version, correlation, causation, business workflow ID, destination actor, and attempt outcome. Keep private payloads out of routine logs. Use separate statuses for rejected commands, transient failure, and uncertain provider results.
Continue with correlation and causation, notifications and background work, and idempotency and ordering. The complete original listener is in the workshop example.