Skip to content

How to customise stream names (multi-tenancy)

Override the stream naming strategy to prefix stream IDs with a tenant identifier or any other domain-specific scheme.

Default behaviour

By default, a command handler uses the command's AggregateID() as the stream name. For a command with AggregateID() = "order-123", events are stored in stream "order-123".

Per-handler override

Use WithStreamNamer to change the naming logic for a specific handler:

handler := eventsourcing.NewCommandHandler(
    store,
    initialState,
    evolve,
    decide,
    eventsourcing.WithStreamNamer(func(ctx context.Context, cmd eventsourcing.Command) string {
        tenant := ctx.Value("tenant_id").(string)
        return fmt.Sprintf("%s-%s", tenant, cmd.AggregateID())
    }),
)

Streams are now stored as "acme-order-123", "globex-order-123", etc.

Global override

Override DefaultStreamNamer at startup to apply the naming convention to all handlers:

func init() {
    eventsourcing.DefaultStreamNamer = func(ctx context.Context, cmd eventsourcing.Command) string {
        if tenant, ok := ctx.Value("tenant_id").(string); ok && tenant != "" {
            return fmt.Sprintf("%s-%s", tenant, cmd.AggregateID())
        }
        return cmd.AggregateID()
    }
}

Be careful: changing DefaultStreamNamer after data has been written will break existing streams. Migrate data or set the namer before any events are stored.

Type-prefixed streams

Another common pattern is to prefix with the aggregate type to avoid collisions across domains:

eventsourcing.WithStreamNamer(func(ctx context.Context, cmd eventsourcing.Command) string {
    return fmt.Sprintf("order-%s", cmd.AggregateID())
})

Notes

  • The stream namer receives the full context so it can access auth claims, request metadata, or other values.
  • The same stream name must be used consistently across all handlers that operate on the same aggregate. Mismatched names result in empty streams and incorrect state reconstruction.