Command Bus
> Type-safe command dispatch with middleware, self-handling commands, and async queue delivery.
The bus package implements a typed command bus. Commands are plain
structs; handlers are generic functions; middleware composes through
the pipeline package. Commands can be dispatched synchronously or
pushed to the queue.
Import path: github.com/velocitykode/velocity/bus
Commands and handlers
A command is any struct. A handler is a function with a single typed argument:
type CreateInvoice struct {
UserID int
Amount int
}
func handleCreateInvoice(cmd CreateInvoice) error {
// ...
return nil
}Register the handler against the command type:
b := bus.New()
bus.Register(b, handleCreateInvoice)Register is a package-level function (not a method) because Go
generics don’t allow type parameters on methods.
Register panics with *contract.RegistrationError if the handler is
nil, a handler for the same command type is already registered, or the
command type is not JSON-serializable. Serializability is probed at
registration time (a zero value is round-tripped through
encoding/json) so that async dispatch can never fail later with an
obscure marshal error.
Dispatching
err := b.Dispatch(CreateInvoice{UserID: 1, Amount: 5000})Dispatch looks up the registered handler, runs any middleware, and
invokes the handler. If no handler is registered it returns an error.
Self-handling commands
A command can carry its own handler by implementing SelfHandling:
type SendReminder struct { UserID int }
func (c SendReminder) Handle() error {
// send the reminder
return nil
}
// No Register() call needed - the bus falls back to the Handle method.
b.Dispatch(SendReminder{UserID: 42})Middleware
Middleware is a pipeline.Stage[Command] - use bus.Middleware to
build one from a function:
logging := bus.Middleware(func(cmd bus.Command, next func(bus.Command) error) error {
start := time.Now()
err := next(cmd)
log.Info("command", "type", fmt.Sprintf("%T", cmd), "dur", time.Since(start), "err", err)
return err
})
b.Through(logging, anotherMiddleware)Stages run in order for each dispatch, wrapping the handler in a pipeline.
bus.LoggingMiddleware(logger) is a ready-to-use logger stage.
Async dispatch
Push commands to the queue to run later in a worker:
b.SetQueue(v.Queue) // anything implementing QueuePusher
b.SetQueueName("notifications") // optional - otherwise default queue
b.DispatchAsync(CreateInvoice{UserID: 1, Amount: 5000})QueuePusher is the single-method slice of the queue driver that async
dispatch needs:
type QueuePusher interface {
PushCtx(ctx context.Context, job contract.QueueJob, queue ...string) error
}Every shipped driver (memory, database, Redis) and the queuetest
fakes satisfy it directly.
The command is wrapped as a queue job; when the worker picks it up it
calls Dispatch internally so the same handler + middleware chain
runs.
The command must be registered with bus.Register on this bus before
it can be dispatched asynchronously - the registration installs the
factory the worker uses to rehydrate the command from its serialized
payload. DispatchAsync refuses to enqueue (returning an error) when no
factory is registered for the command type, so a missing Register call
is caught at the producer instead of silently routing the job to
failed_jobs. Self-handling commands with no Register call therefore
cannot be dispatched asynchronously.
DispatchAsync enqueues with a background context. Prefer
DispatchAsyncCtx so that a client disconnect, deadline, or graceful
shutdown aborts the enqueue before it reaches the backing store:
b.DispatchAsyncCtx(ctx, CreateInvoice{UserID: 1, Amount: 5000})Cross-process workers
Every bus has a stable id (b.ID()) that is written onto every queued
job. The consumer side uses it to route hydration back through the
originating bus, which prevents cross-bus contamination when several
buses are in play (multi-tenant apps, plugin systems, per-test buses).
bus.New() assigns a random UUID, which is sufficient when the worker
runs in the same process as the producer (the memory driver, or a
same-process consumer of a durable driver). When the worker runs in a
separate process from the producer, pin a deterministic id with
bus.NewWithID on both sides so producer and consumer agree:
b := bus.NewWithID("orders-bus")NewWithID panics with *contract.RegistrationError if the id is empty
or another bus is already registered under the same id. Call b.Close()
to remove the bus from the registry when you are done with it; this is
optional, as the registry entry otherwise lives for the process
lifetime.
Events
Every dispatch emits lifecycle events:
*bus.CommandDispatching- before the handler runs*bus.CommandCompleted- on success*bus.CommandFailed- on handler error*bus.CommandQueued- onDispatchAsync
Wire the dispatcher:
b.SetEventDispatcher(v.Events.Dispatch)Testing
FakeBus records dispatched commands without running handlers - handy
for isolating handler tests:
fake := bus.NewFakeBus()
svc := NewOrderService(fake)
svc.Checkout(ctx, order)
if err := fake.AssertDispatched(SendReceipt{}); err != nil {
t.Error(err)
}Assertions available on *FakeBus:
AssertDispatched(cmd)- at least one command of that type was dispatchedAssertDispatchedTimes(cmd, n)- exactly n timesAssertNotDispatched(cmd)- zero timesAssertNothingDispatched()- no commands at allAssertAsyncDispatched(cmd)- dispatched viaDispatchAsyncGetDispatched()/ClearDispatched()- inspect or reset the log