A small, generic, concurrency-safe event hook system for Go.
hook lets you define typed events, register handlers for them (with optional
priorities and stable IDs), and trigger the handlers as a middleware-style chain
where each handler decides whether the rest of the chain runs by calling
e.Next().
The code is extracted from the excellent
tools/hook
package of PocketBase by
@ganigeorgiev, published as a standalone
module with no third-party dependencies.
- Go 1.27 or newer (the module uses the standard library
uuidpackage for handler ID generation and has no external dependencies).
go get github.com/gowool/hookimport "github.com/gowool/hook"package main
import (
"fmt"
"github.com/gowool/hook"
)
// 1. Define an event. It must embed hook.Event.
type UserCreatedEvent struct {
hook.Event
UserID string
Email string
}
func main() {
// 2. Create a hook typed on a pointer to your event.
var onUserCreated hook.Hook[*UserCreatedEvent]
// 3. Register handlers. Each handler must call e.Next() to let the
// remaining handlers run.
onUserCreated.BindFunc(func(e *UserCreatedEvent) error {
fmt.Println("sending welcome email to", e.Email)
return e.Next()
})
onUserCreated.BindFunc(func(e *UserCreatedEvent) error {
fmt.Println("recording audit log for", e.UserID)
return e.Next()
})
// 4. Trigger the hook.
err := onUserCreated.Trigger(&UserCreatedEvent{
UserID: "u_123",
Email: "alice@example.com",
})
if err != nil {
fmt.Println("hook failed:", err)
}
}Output:
sending welcome email to alice@example.com
recording audit log for u_123
An event is any struct that embeds hook.Event. Embedding gives the struct the
Next() method and the unexported plumbing that satisfies the hook.Resolver
interface, which is the type constraint used by Hook[T].
type RequestEvent struct {
hook.Event
Path string
Status int
}Because Next() has a pointer receiver, hooks are always typed on a pointer to
the event: hook.Hook[*RequestEvent]. This also means every handler in the
chain shares the same event instance and can read or mutate its fields.
Handlers run in a chain, similar to HTTP middleware. Each handler receives the
event and must call e.Next() to continue the chain. This gives every
handler full control over what happens before, after, or instead of the rest of
the chain:
h.BindFunc(func(e *RequestEvent) error {
// before the remaining handlers
start := time.Now()
err := e.Next() // run the rest of the chain
// after the remaining handlers
fmt.Println("took", time.Since(start))
return err
})Returning without calling e.Next() short-circuits the chain: the handlers
that come after the current one are simply not executed.
h.BindFunc(func(e *RequestEvent) error {
if e.Path == "/health" {
return nil // stop here, skip everything after this handler
}
return e.Next()
})An error returned by a handler is propagated up through the callers of
e.Next() and finally returned by Trigger. Note that an error by itself does
not stop the chain; only skipping e.Next() does. A handler is free to
call e.Next() first and then return its own error, or to inspect and wrap the
error returned by e.Next().
There are two ways to register a handler.
BindFunc is the short form. It registers the function with priority 0 and
an autogenerated ID, and returns that ID:
id := h.BindFunc(func(e *RequestEvent) error {
return e.Next()
})Bind takes a *hook.Handler and lets you set an explicit ID and/or priority:
id := h.Bind(&hook.Handler[*RequestEvent]{
ID: "auth",
Priority: -100,
Func: func(e *RequestEvent) error {
return e.Next()
},
})hook.Handler has three fields:
| Field | Description |
|---|---|
Func |
The handler function. Must call e.Next() to continue the chain. |
ID |
Unique identifier. Autogenerated (UUID) if empty. Re-binding with an existing ID replaces the old handler. |
Priority |
Execution order. Lower values run first. Handlers with equal priority keep their registration order. Default 0. |
Handlers are sorted by ascending Priority every time one is bound, using a
stable sort. A handler with priority -10 runs before priority 0, which runs
before priority 10. Handlers with the same priority run in the order they
were registered.
h.BindFunc(func(e *RequestEvent) error { fmt.Print("B"); return e.Next() })
h.Bind(&hook.Handler[*RequestEvent]{
Priority: -1,
Func: func(e *RequestEvent) error { fmt.Print("A"); return e.Next() },
})
h.Bind(&hook.Handler[*RequestEvent]{
Priority: 1,
Func: func(e *RequestEvent) error { fmt.Print("C"); return e.Next() },
})
_ = h.Trigger(&RequestEvent{}) // prints "ABC"Binding a handler whose ID already exists replaces the previous handler in
place. This is useful for letting users override a default behaviour:
h.Bind(&hook.Handler[*RequestEvent]{
ID: "logger",
Func: defaultLogger,
})
// later, somewhere else:
h.Bind(&hook.Handler[*RequestEvent]{
ID: "logger",
Func: customLogger, // replaces defaultLogger
})Unbind removes one or more handlers by ID. Unknown IDs are ignored.
UnbindAll clears every handler.
id1 := h.BindFunc(...)
id2 := h.BindFunc(...)
h.Unbind(id1, id2)
h.Unbind("does-not-exist") // no-op
h.UnbindAll()Length returns the number of currently registered handlers.
Trigger runs the chain with the given event and returns the error that
bubbles out of it, if any.
err := h.Trigger(&RequestEvent{Path: "/users"})You can also pass one-off handlers that are appended to the end of the chain for this single call only. They are not stored on the hook. This is handy for running the "actual" operation after all the registered hooks have had a chance to intercept it:
err := h.Trigger(&RequestEvent{Path: "/users"}, func(e *RequestEvent) error {
// runs last, after every registered handler called e.Next()
e.Status = 200
return e.Next()
})A typical pattern is to build a "before"/"after" flow around a core action:
func (s *Service) CreateUser(u *User) error {
return s.OnUserCreate.Trigger(&UserCreateEvent{User: u}, func(e *UserCreateEvent) error {
// the core operation, executed only if no handler short-circuited the chain
if err := s.db.Insert(e.User); err != nil {
return err
}
return e.Next()
})
}Registered handlers then see the event before the insert (code placed before
e.Next()) and after it (code placed after e.Next()), and can abort the
whole operation by returning an error or not calling e.Next().
The same event value can be safely passed to Trigger more than once; the
internal chain state is reset on every call.
TaggedHook is a proxy over a regular Hook whose handlers only run when the
event carries a matching tag. This is useful when a single hook is shared by
many logical groups (collections, tables, topics, routes, …) and you want to
register handlers for a subset of them without creating a separate hook per
group.
To use it, your event must also implement hook.Tagger, i.e. provide a
Tags() []string method in addition to embedding hook.Event:
type RecordEvent struct {
hook.Event
Collection string
Record map[string]any
}
func (e *RecordEvent) Tags() []string {
return []string{e.Collection}
}Then create tagged views over one shared base hook:
var onRecordCreate hook.Hook[*RecordEvent]
// Runs for every collection.
onRecordCreate.BindFunc(func(e *RecordEvent) error {
fmt.Println("any record created in", e.Collection)
return e.Next()
})
// Runs only when e.Tags() contains "users" or "admins".
usersHook := hook.NewTaggedHook(&onRecordCreate, "users", "admins")
usersHook.BindFunc(func(e *RecordEvent) error {
fmt.Println("user-like record created")
return e.Next()
})
// Runs only for "posts".
postsHook := hook.NewTaggedHook(&onRecordCreate, "posts")
postsHook.BindFunc(func(e *RecordEvent) error {
fmt.Println("post created")
return e.Next()
})
_ = onRecordCreate.Trigger(&RecordEvent{Collection: "users"})
// any record created in users
// user-like record created
_ = onRecordCreate.Trigger(&RecordEvent{Collection: "posts"})
// any record created in posts
// post createdNotes on tagged hooks:
- Handlers are always stored on, and triggered through, the base hook.
Priorities, IDs,
Unbind,UnbindAllandLengthall refer to the shared base handler list. - A
TaggedHookcreated with no tags matches every event, exactly like binding on the base hook directly. - When the event's tags do not match, the wrapped handler is skipped
transparently: it calls
e.Next()on your behalf so the rest of the chain still runs. CanTriggerOn(tags)is exposed if you need to check matching manually.
All Hook methods are safe for concurrent use. Binding, unbinding and
triggering can happen from multiple goroutines. Trigger takes a snapshot of
the handler list under a read lock, so handlers added or removed while a
trigger is in flight do not affect that in-flight chain.
The event instance itself is shared by all handlers in a single Trigger
call and is not synchronised; do not trigger the same event value
concurrently from multiple goroutines.
// event.go
type Resolver interface { Next() error /* + unexported methods */ }
type Event struct{ /* embed this in your events */ }
func (e *Event) Next() error
// hook.go
type Handler[T Resolver] struct {
Func func(T) error
ID string
Priority int
}
type Hook[T Resolver] struct{ /* ... */ }
func (h *Hook[T]) Bind(handler *Handler[T]) string
func (h *Hook[T]) BindFunc(fn func(e T) error) string
func (h *Hook[T]) Unbind(idsToRemove ...string)
func (h *Hook[T]) UnbindAll()
func (h *Hook[T]) Length() int
func (h *Hook[T]) Trigger(event T, oneOffHandlerFuncs ...func(T) error) error
// tagged.go
type Tagger interface { Resolver; Tags() []string }
type TaggedHook[T Tagger] struct{ /* ... */ }
func NewTaggedHook[T Tagger](hook *Hook[T], tags ...string) *TaggedHook[T]
func (h *TaggedHook[T]) CanTriggerOn(tagsToCheck []string) bool
func (h *TaggedHook[T]) Bind(handler *Handler[T]) string
func (h *TaggedHook[T]) BindFunc(fn func(e T) error) string
// Unbind, UnbindAll, Length and Trigger are promoted from the base Hook.go test ./...The implementation is copied from
pocketbase/pocketbase tools/hook,
Copyright (c) 2022 - present, Gani Georgiev, and is distributed under the
MIT License. The original license notice
is preserved at the top of every source file.