If you know how Temporal's splitmerge-future Go sample works and want to see what changes in Resonate, this is the shortest possible diff. The fan-out / fan-in pattern has one structural rule that both systems enforce the same way: start all units of work before awaiting any of them, or the work runs in series. The Temporal names change; the discipline does not.
Temporal (samples-go/splitmerge-future)
Two explicit loops — one to dispatch, one to collect — with workflow.ActivityOptions applied before the first dispatch:
func SampleSplitMergeFutureWorkflow(ctx workflow.Context, processorCount int) (ChunkResult, error) {
ao := workflow.ActivityOptions{
StartToCloseTimeout: 10 * time.Second,
}
ctx = workflow.WithActivityOptions(ctx, ao)
var results []workflow.Future
for i := 0; i < processorCount; i++ {
// ExecuteActivity returns a Future immediately — does not block.
future := workflow.ExecuteActivity(ctx, ChunkProcessingActivity, i+1)
results = append(results, future)
}
var totalItemCount, totalSum int
for i := 0; i < processorCount; i++ {
var result ChunkResult
// Blocks until this activity's result is available.
err := results[i].Get(ctx, &result)
if err != nil {
return ChunkResult{}, err
}
totalItemCount += result.NumberOfItemsInChunk
totalSum += result.SumInChunk
}
return ChunkResult{totalItemCount, totalSum}, nil
}Resonate (example-fan-out-fan-in-go)
The same two-loop shape, minus the activity options and the workflow/activity split:
// fanout dispatches one ctx.RPC call per channel without awaiting in between,
// then awaits all of them. The dispatches run concurrently on the server side;
// the Awaits block until every child promise settles.
func fanout(ctx *resonate.Context, args FanoutArgs) (FanoutResult, error) {
futures := make([]*resonate.Future, 0, len(args.Channels))
for _, ch := range args.Channels {
f, err := ctx.RPC("send", SendArgs{Channel: ch, Message: args.Message})
if err != nil {
return FanoutResult{}, err
}
futures = append(futures, f)
}
out := FanoutResult{Delivered: make([]Delivery, 0, len(futures))}
for i, f := range futures {
var d Delivery
if err := f.Await(&d); err != nil {
out.Delivered = append(out.Delivered, Delivery{
Channel: args.Channels[i],
OK: false,
Reason: err.Error(),
})
continue
}
out.Delivered = append(out.Delivered, d)
}
return out, nil
}
// from example-fan-out-fan-in-go/main.go:39-63The child function is a plain Go function; there is no separate activity type:
func send(_ *resonate.Context, args SendArgs) (Delivery, error) {
fmt.Printf(" [send] %-8s <- %q\n", args.Channel, args.Message)
return Delivery{Channel: args.Channel, OK: true}, nil
}
// from example-fan-out-fan-in-go/main.go:68-71Both are registered and run from one main:
r, err := resonate.New(resonate.Config{URL: "http://localhost:8001"})
// ...
fanoutFn, err := resonate.Register(r, "fanout", fanout)
// ...
if _, err := resonate.Register(r, "send", send); err != nil { ... }
h, err := fanoutFn.Run(ctx, *id, args)
// ...
out, err := h.Result(ctx)
// from example-fan-out-fan-in-go/main.go:84-106What maps to what
| Temporal | Resonate |
|---|---|
workflow.ExecuteActivity(ctx, Fn, args) | ctx.RPC("name", args) |
[]workflow.Future (loop 1 collection) | []*resonate.Future |
future.Get(ctx, &result) | f.Await(&result) |
workflow.ActivityOptions{StartToCloseTimeout: …} | (none required; optional resonate.RPCOpts{Timeout: d} as third arg) |
workflow.WithActivityOptions(ctx, ao) | (none) |
workflow.Context (orchestrator) + context.Context (activity) | one *resonate.Context in both |
RegisterWorkflow + RegisterActivity (two types) | resonate.Register(r, "name", fn) for each function |
| Task queue routing | Group on httpnet.HTTPOptions (defaults to "default") |
| Workflow ID + Run ID | one promise ID string passed to fanoutFn.Run |
Porting it
-
Remove
workflow.ActivityOptionsandworkflow.WithActivityOptions. Resonate does not require activity options before dispatch. Delete that block. If you want a per-call deadline, passresonate.RPCOpts{Timeout: 10 * time.Second}as an optional third argument toctx.RPC— but you are not required to. -
Replace
workflow.ExecuteActivity(ctx, Fn, i+1)withctx.RPC("name", args). The first argument is the string name passed toresonate.Registerwhen registering the child. Return type is(*resonate.Future, error)— check the error before appending. A dispatch error means the child promise could not be created (server unreachable, duplicate ID conflict), not that the child function failed. -
Replace
results[i].Get(ctx, &result)withf.Await(&result). The signature drops thectxparameter; the SDK manages context internally. AnAwaiterror means the child promise settled with a failure. -
Replace
workflow.Contextwith*resonate.Contextin the orchestrator signature. The child function uses the same type:func send(_ *resonate.Context, args SendArgs) (Delivery, error). -
Register both functions. Call
resonate.Register(r, "fanout", fanout)andresonate.Register(r, "send", send). A single binary can register and run both — no worker/starter split is required. -
Start the workflow with
fanoutFn.Run(ctx, id, args). The ID string becomes the root promise ID.fanoutFn.Runreturns a*TypedHandle[FanoutResult], soh.Result(ctx)returns(FanoutResult, error)directly. Pass the same ID on a subsequent run and the SDK returns a handle to the already-resolved promise instead of re-executing — the-idflag inmain.godemonstrates this live.
What's actually different
The two-loop discipline is identical. Both systems require all dispatches to happen before any await. In Temporal: build []workflow.Future in loop 1, call .Get in loop 2. In Resonate: build []*resonate.Future in loop 1, call .Await in loop 2. Collapse them into one loop — ctx.RPC(…); f.Await(&d) inline — and you serialize the children. The same footgun exists in both systems.
No workflow/activity split. Temporal differentiates the orchestrator (a workflow, subject to determinism rules) from the units of work (activities, free to do I/O) via workflow.Context vs context.Context and separate RegisterWorkflow / RegisterActivity calls. Resonate uses one *resonate.Context in both places and one resonate.Register call per function. The execution distinction is still present — the function that calls ctx.RPC is the orchestrator and must follow determinism rules; the dispatched function is free to do I/O — but it does not surface as a separate type or registration path.
No activity options required. Temporal returns an error if StartToCloseTimeout (or ScheduleToCloseTimeout) is missing. Resonate applies a 24-hour default when no timeout is specified; durability comes from the promise store and the worker heartbeat rather than a mandatory per-call deadline.
Per-child promise visibility. Each ctx.RPC call creates a named child promise visible on the dashboard at http://localhost:8001. A failed delivery can be inspected and re-triggered independently without replaying the full parent history.
Notes & coverage
- Completion-order processing. This example, like
splitmerge-future, awaits results in dispatch order. Temporal's companion samplesplitmerge-selectorusesworkflow.NewSelectorto process results as each activity completes. The Resonate SDK does not currently expose a selector or race primitive; processing in arrival order requires goroutines and channels outside the built-in surface. - Async RPC and other dispatch shapes. If you need fire-and-forget dispatch or await-chain variants, the sibling "Async RPC" brief covers those shapes.
- SDK stability.
resonate-sdk-gois pre-release and has no semver tag. Thectx.RPC/*resonate.Future/f.AwaitAPI reflects the commit pinned ingo.mod. Expect possible changes beforev0.1.0.
Sources
- Example repo: github.com/resonatehq-examples/example-fan-out-fan-in-go
- Temporal sample: github.com/temporalio/samples-go/splitmerge-future
- Concept-level guide, all SDKs: docs.resonatehq.io/evaluate/coming-from/temporal
