Table of Contents

Elsa integration

elsa-broker is a durable queueing front end for Elsa 3 workflows. The broker owns ingress (mTLS), authorization, durable delivery, and the audit trail; the processing logic for each request type is an Elsa workflow — versioned in git, authored visually in Elsa Studio, and diagrammed with the sibling elsa-to-mermaid.

There are no hand-written C# handlers. A request type is "implemented" by dropping a workflow JSON in the shared folder.

The shared workflows folder + convention

The workflows/ folder (under src/services/ElsaBroker/) is the single source of truth, loaded by two consumers:

  • The Elsa server mounts it at /app/Workflows and publishes every JSON file as a workflow.
  • The broker (ElsaBroker.Processor) scans the same folder on startup and auto-registers requestType → workflowDefinitionId.

Each request-handling workflow declares its broker request type with a custom property:

{
  "definitionId": "invoice-process",
  "customProperties": { "requestType": "InvoiceProcess" },
  "root": { "...": "activity graph" }
}

WorkflowFolderScanner reads definitionId + customProperties.requestType; a file without a requestType (e.g. the dispatcher) is ignored. The result populates WorkflowRegistry, and the Processor registers one ElsaDispatchHandler per discovered request type. Add a request type = add a file; no redeploy of handler code.

Deployment shape (remote Elsa server)

The workflow runs in a separate Elsa server, not in-process — so it can be operated, versioned, and scaled independently, and authored in Studio. The broker talks to it over two hops:

client ──mTLS :5001──▶ Queue API ──outbox──▶ SQL transport ──▶ Processor
                            ▲                                      │ ElsaDispatchHandler (Deferred)
                            │ callback                             │ POST {definitionId, correlationId,
                  :5080 shared-secret                              │       message, callbackUrl, secret}
                            │                                      ▼
                       Queue (callback) ◀── SendHttpRequest ── Elsa "broker-dispatch" workflow
                            │                                      │ DispatchWorkflow(definitionId,
                            ▼                                      ▼   WaitForCompletion) → target workflow
                     RequestRecord → Completed/Faulted

Async callback model

Workflows can be long-running (timers, human approval), so the broker does not block a consumer waiting for a result:

  1. The Processor sets RequestRecordProcessing and calls ElsaDispatchHandler.
  2. The handler POSTs { definitionId, correlationId, message, callbackUrl, callbackSecret } to the Elsa broker-dispatch workflow and returns RequestResult(Deferred: true). The consumer leaves the record in Processing (it does not finalize).
  3. broker-dispatch acks 202, runs the target workflow by definitionId (DispatchWorkflow with WaitForCompletion), then SendHttpRequests the outcome to callbackUrl.
  4. The broker's callback endpoint (POST /internal/requests/{id}/result on the internal listener :5080) validates the X-Callback-Secret and sets the record → Completed/Faulted.
  5. The client's poll on :5001 eventually returns the terminal status.

The callback uses a shared secret, not mTLS: the Elsa container can't easily present a client certificate, and the callback is a server-to-server hop on the internal network. The mTLS ingress on :5001 is untouched. See ADR-0006.

The dispatcher workflow

workflows/broker-dispatch.json is the single entry workflow the broker calls. Its contract (request body, callback shape) is documented in the workflows folder README. It is best authored in Elsa Studio — hand-written Elsa JSON (input binding + expressions) is fragile — then exported back into the folder. Because the broker only needs definitionId + customProperties.requestType to register, editing the graph in Studio never breaks registration.

The feedback loop

The Docker stack runs Elsa Server + Studio, so the authoring loop is:

  1. docker compose up -d (see Getting started).
  2. Open Studio at http://localhost:13000 (admin / password).
  3. Build/refine broker-dispatch and your request-type workflows visually.
  4. Export the JSON back into workflows/. The Elsa server reloads it; the broker re-registers on next start.

Example child workflow

A small invoice workflow — branch on an auto-approve limit, else route to a human Await approval step before completing. The async approval step is exactly the durable behavior workflows give you for free. The diagram is generated from the JSON by elsa-to-mermaid (docs/scripts/render-diagram.sh):

flowchart TD

    HttpEndpoint1(["Receive Invoice Request"])
    If1{"Amount within auto-approve limit?"}
    subgraph Sequence1["Auto-post"]
        SendHttpRequest1["Post to ledger"]
        WriteHttpResponse1["Return Completed"]
        SendHttpRequest1 --> WriteHttpResponse1
    end
    subgraph Sequence2["Manual review"]
        SendEmail1["Email AP team"]
        Signal1[/"Await approval"/]
        WriteHttpResponse2["Return Completed"]
        SendEmail1 --> Signal1
        Signal1 --> WriteHttpResponse2
    end

    HttpEndpoint1 --> If1
    If1 -->|True| Sequence1
    If1 -->|False| Sequence2
{
  "id": "invoice-process-1",
  "definitionId": "InvoiceProcess",
  "name": "Invoice Process",
  "version": 1,
  "isPublished": true,
  "isLatest": true,
  "root": {
    "id": "Flowchart1",
    "type": "Elsa.Flowchart",
    "activities": [
      {
        "id": "HttpEndpoint1",
        "type": "Elsa.Http.HttpEndpoint",
        "displayName": "Receive Invoice Request",
        "properties": {}
      },
      {
        "id": "If1",
        "type": "Elsa.If",
        "displayName": "Amount within auto-approve limit?",
        "properties": {}
      },
      {
        "id": "Sequence1",
        "type": "Elsa.Sequence",
        "displayName": "Auto-post",
        "activities": [
          {
            "id": "SendHttpRequest1",
            "type": "Elsa.Http.SendHttpRequest",
            "displayName": "Post to ledger",
            "properties": {}
          },
          {
            "id": "WriteHttpResponse1",
            "type": "Elsa.Http.WriteHttpResponse",
            "displayName": "Return Completed",
            "properties": {}
          }
        ]
      },
      {
        "id": "Sequence2",
        "type": "Elsa.Sequence",
        "displayName": "Manual review",
        "activities": [
          {
            "id": "SendEmail1",
            "type": "Elsa.Email.SendEmail",
            "displayName": "Email AP team",
            "properties": {}
          },
          {
            "id": "Signal1",
            "type": "Elsa.Primitives.SignalReceived",
            "displayName": "Await approval",
            "properties": {}
          },
          {
            "id": "WriteHttpResponse2",
            "type": "Elsa.Http.WriteHttpResponse",
            "displayName": "Return Completed",
            "properties": {}
          }
        ]
      }
    ],
    "connections": [
      { "source": "HttpEndpoint1", "target": "If1",       "sourcePort": "Done",  "targetPort": "In" },
      { "source": "If1",           "target": "Sequence1",  "sourcePort": "True",  "targetPort": "In" },
      { "source": "If1",           "target": "Sequence2",  "sourcePort": "False", "targetPort": "In" }
    ]
  }
}

Why the correlation id matters

The broker passes request.CorrelationId as the workflow's correlation id, keeping the idempotency model intact: a redelivered message resolves to the same workflow instance instead of starting a duplicate — the same id the audit RequestRecord is keyed on.