Skip to content

Reference

CommandHandler

Reference for go-sequent command handlers: the Handler interface, Router registration, repository-only access, multi-aggregate commands such as transfers, and duplicate-type panics.

CommandHandler

Package: command

A handler turns a command into calls on aggregates. It is the glue between CommandService and your domain.

The interface

type Handler interface {
    Handles() []string
    Handle(ctx context.Context, cmd Command) error
}

Handles() lists the command types the handler accepts. Handle receives the command after middleware, filters and validation have passed.

A handler for several commands

type Handler struct {
    Repo *aggregate.Repository
}

func (h *Handler) Handles() []string {
    return []string{CmdOpenAccount, CmdDeposit, CmdWithdraw, CmdTransfer}
}

func (h *Handler) Handle(ctx context.Context, cmd command.Command) error {
    switch c := cmd.(type) {
    case *OpenAccount:
        acc := NewAccount(c.AccountID)
        if tid := c.Meta().TenantID; tid != uuid.Nil {
            acc.SetPartitionKey(tenancy.PartitionKey(tid))
        }
        if err := acc.OpenAccount(c.Owner, c.Currency, c.Opening); err != nil {
            return err
        }
        return h.Repo.Add(ctx, acc)
    case *Deposit:
        agg, err := h.Repo.Load(ctx, c.AccountID, AggregateAccount)
        if err != nil {
            return err
        }
        return agg.(*Account).Deposit(c.Amount)
    default:
        return fmt.Errorf("bank: unhandled command %T", cmd)
    }
}

Use one handler per aggregate type, or one per command. Both are fine as long as every command type has exactly one handler.

Rules

  • Use Repo.Add and Repo.Load only. Do not call EventStore.Append from a handler. The repository appends at commit.
  • New aggregates go through Add. Existing ones through Load. Both require a UnitOfWork on the context, which Execute provides.
  • Do not commit. CommandService calls Repository.Commit after the handler returns.
  • Return an error to abort. Any error rolls back the whole Execute transaction.
  • Keep handlers thin. Business rules belong in aggregate methods, where testkit can reach them without a database.

Multi-aggregate commands

A command can touch more than one aggregate. Load all of them in the same Handle call, and they share one UnitOfWork and one transaction:

case *Transfer:
    from, err := h.Repo.Load(ctx, c.FromAccountID, AggregateAccount)
    if err != nil {
        return err
    }
    to, err := h.Repo.Load(ctx, c.ToAccountID, AggregateAccount)
    if err != nil {
        return err
    }
    fromAcc, toAcc := from.(*Account), to.(*Account)
    if fromAcc.Currency != toAcc.Currency {
        return fmt.Errorf("currency mismatch between accounts")
    }
    if err := fromAcc.DebitTransfer(c.Amount, c.ToAccountID.String(), c.TransferID); err != nil {
        return err
    }
    return toAcc.CreditTransfer(c.Amount, c.FromAccountID.String(), c.TransferID)

Commit publishes events in load order. Each aggregate gets its own ExpectedVersion, so a conflict on either stream fails the whole command. See Repository.

Registration

app.RegisterHandler(&Handler{Repo: app.Repo})

This calls Router.Register, which maps each type in Handles() to the handler. Router.Register panics when two handlers claim the same command type, so mistakes show up at startup. Dispatching a type with no handler returns an error: command: no handler for "...".

Handlers and workflows

A handler should be a pure function of the command and the aggregates it loads. Side effects that must not be rolled back, such as sending an email, belong in a Workflow AfterCommit hook or in the outbox. If a handler has to call an external system, keep it to short calls that fetch an external id, and keep balance facts in SyncInTxn projectors.

Authorization

Aggregate-level checks, such as account ownership, belong in aggregate methods and use only aggregate state plus the claims carried by the command. Command-level permission checks belong in an Authorizer filter that runs before the handler.

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