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.AddandRepo.Loadonly. Do not callEventStore.Appendfrom a handler. The repository appends at commit. - New aggregates go through
Add. Existing ones throughLoad. Both require a UnitOfWork on the context, whichExecuteprovides. - Do not commit.
CommandServicecallsRepository.Commitafter the handler returns. - Return an error to abort. Any error rolls back the whole
Executetransaction. - Keep handlers thin. Business rules belong in aggregate methods, where
testkitcan 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.
Related
Docs track the go-sequent library. When APIs drift, the source wins.