๐งฉ Embedding Guide
This guide walks you through adding go-saga-orchestration as an in-process library to your Go service โ from a thirty-second hello world all the way to production wiring.
๐ Quickstartโ
The fastest path is saga.InMemory(): an in-process engine backed by a thread-safe in-memory store. No database, no message broker, no external processes required.
import (
"context"
"fmt"
"github.com/Bugs5382/go-saga-orchestration/saga"
"github.com/Bugs5382/go-saga-orchestration/domain"
)
sc := saga.InMemory()
sc.Register(domain.WorkflowDefinition{
ID: "hello", Version: 1, Start: "greet", Published: true,
Steps: []domain.Step{
{ID: "greet", Type: "noop", Next: "done"},
{ID: "done", Type: domain.StepTypeEnd},
},
})
runID, err := sc.Start(context.Background(), "hello", map[string]any{"name": "world"})
if err != nil {
panic(err)
}
run, _ := sc.Get(context.Background(), runID)
fmt.Println(run.State) // succeeded
sc.Start creates the run and advances it synchronously to the first pause or terminal state, so for an all-synchronous workflow the run is already complete by the time Start returns.
๐ก See
examples/basicfor a runnable standalone example.
๐งฉ Custom verbsโ
Register your own step type with a closure. The return value is a map[string]any that gets merged into run.Variables.
import "github.com/Bugs5382/go-saga-orchestration/engine/verbs"
sc.RegisterVerb(
"charge_card", // step type name used in workflow JSON/Go
"common", // license group โ "common" means no gate
verbs.HandlerFunc(func(ctx context.Context, run domain.SagaRun, step domain.Step) (map[string]any, error) {
total, _ := run.Variables["total"].(float64)
if total <= 0 {
return nil, fmt.Errorf("charge_card: invalid total")
}
// ... call your payment service ...
return map[string]any{"charge_id": "ch_abc123", "charged": total}, nil
}),
)
The keys returned (charge_id, charged) land directly in Variables and are visible to every subsequent step. Use set_var or transform steps after the custom verb to rename or reshape them if needed.
๐ Custom actions (worker round-trip)โ
For steps that need to run in a separate process (e.g. a microservice that owns its own business logic), use type: "action" in your workflow definition:
{
"id": "charge",
"type": "action",
"action": "payments.charge_card",
"inputs": {"total": 4200},
"next": "confirm"
}
The engine dispatches payments.charge_card over the configured publisher (RabbitMQ in service mode, in-process in embedded mode). A worker process built with the Go worker SDK connects over the gRPC ExecuteStep stream, registers a handler for payments.charge_card, and returns a result map that is merged into Variables.
โ ๏ธ Pure
saga.InMemory()with no worker goroutine will leave anactionstep paused indefinitely โ the action verb pauses the saga and waits for a worker reply. You need either a worker process (service mode) or a registeredRegisterVerbhandler with the same step type to handle it in-process.
See docs/grpc.md for the worker protocol and clients/go/worker for the SDK.
๐ชข Data flow between stepsโ
Each step operates on exactly one verb, and all data flows through run.Variables. CEL verbs (e.g. transform, filter, switch) read from Variables โ not from step.Inputs directly.
Pattern: use set_var to seed a variable from a literal or a previous step's output, then reference it in downstream CEL expressions.
start โ set_var (out_var: "items", value: [...]) โ filter โ transform โ end
Two worked scenarios in examples/workflows/:
scenario_action_to_setvar.jsonโ anactionstep returns a result, then aset_varstep reads the worker's output key and assigns it to a clean variable name for downstream steps.scenario_parallel_setvars.jsonโ parallel branches each write to distinct variables, which are available after the join.
๐ก Prefer descriptive
out_varnames. Thehttp_requestandwebhook_emitverbs default tohttp_result/webhook_resultโ overrideout_varto avoid collisions when you call multiple endpoints in the same workflow.
๐ช Entry points / call treeโ
Every WorkflowDefinition has a default entry point (Start field). You can define named entry points with the Entrypoints map:
domain.WorkflowDefinition{
ID: "order",
Version: 1,
Start: "charge",
Entrypoints: map[string]string{
"refund": "start_refund",
"cancel": "start_cancel",
},
Steps: []domain.Step{ /* ... */ },
}
Start a run at a named entry point with StartAt:
runID, err := sc.StartAt(ctx, "order", "refund", map[string]any{"order_id": "ord_99"})
sub_saga and spawn_saga steps also accept an entrypoint input so a parent workflow can invoke a specific slice of a child workflow without a separate definition.
REST triggers (service mode) accept an entrypoint field in the trigger configuration โ see docs/api.md.
๐ญ Production wiringโ
Replace InMemory() with saga.New(opts) and provide your own store and infrastructure:
import (
"github.com/Bugs5382/go-saga-orchestration/saga"
"github.com/Bugs5382/go-saga-orchestration/store/postgres"
)
pgStore, err := postgres.New(ctx, databaseDSN)
if err != nil {
log.Fatal(err)
}
sc, err := saga.New(saga.Options{
Store: pgStore, // durable Postgres store (see store/postgres)
Licensing: myLicenseResolver, // licensing.Resolver โ controls feature groups
Secrets: mySecretsResolver, // secrets.Resolver โ for http_request/webhook_emit
Publisher: rabbitPublisher, // engine.Publisher โ RabbitMQ-backed
Logger: &logger, // *zerolog.Logger
Context: appCtx, // base context for background advances
})
Key option notes:
Storeis the only required field. All others have in-process defaults.Licensing: omit (or passnil) forStubAllowAll(all groups permitted). Provide your ownlicensing.Resolverto gate feature groups in production.Secrets: omit for an in-memory store seeded from a map. Provide a Vault-backed (or similar) resolver for production.Publisher: omit for in-process fan-out. Provide a RabbitMQ publisher to enable multi-process workers and theactionround-trip.- See
store/postgresfor the Postgres store implementation and SQL migrations.
โ Cancelling a runโ
To abort an in-flight run from outside it โ for example, an approval policy that re-submits or withdraws while a run is paused at a manual_approval โ call Cancel:
err := sc.Cancel(ctx, runID, "approval withdrawn")
This transitions the run to terminal cancelled, closes its open user tasks (so none linger pending in an approver's inbox), and clears any awaited signal/event or pending wakeup so a stray advance can't resurrect it. reason is recorded on the run's last_error. Cancel is idempotent โ a no-op once the run is already terminal.
A run that ends in failed likewise records the failing step's error on last_error, so a terminal run is self-describing without diffing its event log:
run, _ := sc.Get(ctx, runID)
if run.State == domain.RunStateFailed {
log.Warn("run failed", "run", runID, "err", *run.LastError)
}
โป๏ธ Lifecycleโ
When your application shuts down, call Shutdown to drain in-flight background advances:
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := sc.Shutdown(shutdownCtx); err != nil {
log.Warn("saga shutdown timed out", "err", err)
}
Shutdown cancels the internal background context (pausing new work between steps) and waits for all in-flight goroutines (from parallel, foreach, and spawn_saga child advances) to drain. If they don't finish before shutdownCtx expires, ctx.Err() is returned.
โ ๏ธ After
Shutdownreturns, theSagainstance should not be reused.
๐ฐ๏ธ Service modeโ
For multi-process deployments, the repo ships two reference binaries:
cmd/apiโ REST API on:8080. Handles workflow publishing, run lifecycle, signals, user tasks, and triggers. Seedocs/api.md.cmd/engineโ Saga coordinator + gRPC worker server on:9090. Reads from thesaga.advanceRabbitMQ queue and drives runs. Hosts theExecuteStepgRPC stream that workers connect to. Seedocs/grpc.md.
Both binaries require Postgres (DATABASE_DSN) and RabbitMQ (RABBITMQ_URL). The overall architecture is documented in docs/architecture.md.
go run ./cmd/api # REST API on :8080
go run ./cmd/engine # coordinator + gRPC on :9090