Orchestrators

Status: DRAFT v0.1 — part of the two-tier pattern.

An orchestrator owns a business process. Where a core service is a guarded database, an orchestrator is a coordinator: it takes a request or an event, drives a sequence of operations across several core services, emits events about what happened, and makes the whole multi-service change atomic — it either fully succeeds or is fully undone.

Orchestrators are where the business logic of the system lives. Core services are deliberately dumb so that orchestrators can be the one place a process is expressed, changed, and understood.


What an orchestrator is


Triggers

An orchestrator is transport-neutral in exactly the way every Benzene service is (core-concepts.md): the process logic is written once, and one or more transport adapters feed it. Two broad shapes:

The same process can be exposed both ways. What differs is only the adapter at the edge and whether a result is returned or an event is emitted — the process in the middle is identical.

Because the outcome of an event-driven process is only visible through its emitted events, an orchestrator should emit on failure as deliberately as on success: a signup:failed event with a reason is how the rest of the system (and your operators) learn that a fire-and-forget process rolled back.


The saga pattern

The core services each write atomically to their own database. But a business process usually writes to several of them — create a tenant, create its admin user, set up billing — and there is no distributed transaction across three databases. If the second write fails, the first is already committed. The saga pattern is how an orchestrator gets atomicity across services anyway: not by holding a lock, but by pairing every forward action with a compensation that undoes it, and running the compensations in reverse if anything fails.

The invariant the orchestrator guarantees: total success, or total rollback — never a half-applied process. A signup that fails at billing must leave no orphan tenant and no orphan user behind.

Shape (informative, .NET — Benzene.Saga)

A saga is an ordered list of stages; each stage is a group of steps that run concurrently; stages run in order, threading their results through a shared context. Each step is a forward action paired with the compensation that undoes it:

var saga = new SagaBuilder()
    .Stage(stage => stage
        .Step<TenantCreated>(step => step
            .Do(_       => api.CreateTenantAsync(companyName))
            .Compensate((_, tenant) => api.DeleteTenantAsync(tenant.TenantId)))
        .Step<OktaCompanyCreated>(step => step
            .Do(_        => api.CreateOktaCompanyAsync(companyName))
            .Compensate((_, company) => api.DeleteOktaCompanyAsync(company.CompanyId))))
    .Stage(stage => stage
        .Step<UserCreated>(step => step
            // later stages read earlier results from the shared context
            .Do(ctx => api.CreateUserAsync(ctx.Get<TenantCreated>().TenantId, email))
            .Compensate((_, user) => api.DeleteUserAsync(user.UserId))))
    .Build();

var result = await saga.RunAsync();

The engine's contract:

Outcomes and durability

Running a saga yields one of three outcomes, and the difference between the last two matters:

Outcome Meaning
Succeeded Every step completed; the process is applied.
RolledBack A step failed and every compensation succeeded — the system is clean, as if nothing happened.
PartiallyRolledBack A step failed and a compensation also failed — some effect may still be applied. This needs attention: it is the one state the invariant could not fully restore.

(informative, .NET) Saga.RunAsync(SagaRunOptions) can attach a durable ISagaStateStore (so an interrupted saga can be recovered) and a SagaRetryPolicy. The retry rule is deliberately conservative: only a clean RolledBack outcome is retried — a PartiallyRolledBack one, which may have left effects behind, is never retried automatically, because retrying on top of a possibly-applied effect is how you double-charge a customer. A PartiallyRolledBack is surfaced (emit a failure event, alert) for a human or a dedicated repair process to resolve.

Designing compensations

The saga is only as atomic as its compensations. Rules that keep it honest:


Orchestrators and the mesh

An orchestrator is a Benzene service like any other and should target the Cloud Service Profile too — its handlers, health, and mesh feeds make the process layer as visible as the data layer. Because orchestrators are the services that call across the fleet, their trace-context propagation (R8) is what draws the edges in the mesh topology: "the signup orchestrator calls tenant:create, user:create, and billing:setup" is derived from the traces the orchestrator propagates, not declared anywhere. Keep propagation on and the mesh shows your business processes as real, observed call graphs.


Checklist

A service is a well-formed orchestrator when:

Next: how the calls between orchestrators and core services are addressed, routed, and realized on AWS — service communication.