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/Workflowsand publishes every JSON file as a workflow. - The broker (
ElsaBroker.Processor) scans the same folder on startup and auto-registersrequestType → 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:
- The Processor sets
RequestRecord→Processingand callsElsaDispatchHandler. - The handler
POSTs{ definitionId, correlationId, message, callbackUrl, callbackSecret }to the Elsa broker-dispatch workflow and returnsRequestResult(Deferred: true). The consumer leaves the record inProcessing(it does not finalize). broker-dispatchacks202, runs the target workflow bydefinitionId(DispatchWorkflowwithWaitForCompletion), thenSendHttpRequests the outcome tocallbackUrl.- The broker's callback endpoint (
POST /internal/requests/{id}/resulton the internal listener:5080) validates theX-Callback-Secretand sets the record →Completed/Faulted. - The client's poll on
:5001eventually 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:
docker compose up -d(see Getting started).- Open Studio at http://localhost:13000 (
admin/password). - Build/refine
broker-dispatchand your request-type workflows visually. - 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.