Skip to content

Reference

Workflow

Reference for go-sequent workflows and AfterCommit hooks: reacting to events inside the transaction, running side effects only after commit, and exclusion from projection rebuild.

Workflow

Package: workflow

A workflow reacts to events to orchestrate follow-up work. It can enqueue commands, record state, and register side effects that run only after the transaction commits.

The interface

A workflow is a publish.Handler:

type Workflow interface {
    publish.Handler // Handles() []string; Handle(ctx, event.Event) error
}

Register it with the façade:

app.RegisterWorkflow(&NotifyWorkflow{})

Workflows run on SyncInTxn after the projectors for the same event. They are excluded from projection rebuild: the replay pipeline uses PublishProjectorsOnly, so a rebuild never re-sends a notification or re-issues a command. See Publisher.

AfterCommit

Work that cannot be rolled back, such as an email, a webhook or a call to a payment provider, must not run inside the transaction. If it ran and the transaction later rolled back, you would have told the outside world about something that did not happen.

Register it as an AfterCommit hook instead:

workflow.RegisterAfterCommit(ctx, func(ctx context.Context) error {
    // side effect
    return nil
})

Hooks are collected on the context for each Execute. They run in registration order, only after the transaction commits successfully. If the Execute fails for any reason, none run.

A failing hook returns its error from Execute, but the committed events stay committed. Hook failure does not undo a ledger write. Treat hooks as best-effort, and use the outbox if you need retries.

Example from the bank sample

type NotifyWorkflow struct {
    mu       sync.Mutex
    Opened   []string
    Notified []string
}

func (w *NotifyWorkflow) Handles() []string { return []string{EventAccountOpened} }

func (w *NotifyWorkflow) Handle(ctx context.Context, e event.Event) error {
    ev := e.(*AccountOpened)
    w.mu.Lock()
    w.Opened = append(w.Opened, ev.Owner)
    owner := ev.Owner
    w.mu.Unlock()

    workflow.RegisterAfterCommit(ctx, func(ctx context.Context) error {
        w.mu.Lock()
        w.Notified = append(w.Notified, owner)
        w.mu.Unlock()
        return nil
    })
    return nil
}

When AccountOpened is published, the workflow records the owner immediately and schedules the notification for after commit. The test can then assert that Notified is only filled when the command succeeded.

Enqueueing commands

A workflow can start more work in the same transaction:

command.Enqueue(ctx, &SomeFollowUp{ /* ... */ })

The command runs after the commands already queued, in the same UnitOfWork and transaction. Set Meta.Source to workflow and the causation fields so the audit trail shows why the command ran.

Workflows or outbox

Situation Choose
Quick local side effect after commit, loss acceptable AfterCommit hook
Must be delivered, retried and observed Outbox, drained by a poller
Must happen atomically with the ledger A projector or a follow-up command in the same transaction

If a workflow must call an external service such as the Circle API, do it from an AfterCommit hook or from outbox delivery, so external latency and retries stay out of the ledger transaction.

Rules

  • A workflow runs inside the write transaction. Return an error only when you want to roll back the ledger write.
  • Workflow handlers are not replayed on rebuild, so do not keep read models in them. Use a projector.
  • Make workflow handlers idempotent where you can. They can run again if a command is retried after a conflict.

Docs track the go-sequent library. When APIs drift, the source wins.