How to Implement a Hook/Event System in Go

Implement a Go hook system by defining a function type, storing callbacks in a slice, and iterating to execute them on events.

How to Implement a Hook/Event System in Go

You are building a CLI tool that processes data files. The core logic reads input, transforms it, and writes output. A user asks for a feature to compress the result before saving. Another user wants a Slack notification when processing finishes. You do not want to hardcode every possible extension. You need a way to let external code plug into specific points in your program without modifying the core logic. That is a hook system.

A hook is a designated spot in your code where execution pauses, calls out to registered functions, and then resumes. Think of it like a relay race baton pass. The main runner carries the baton to a checkpoint. At that checkpoint, several coaches can shout instructions or hand off a tool. The runner takes the input, the coaches do their thing, and the runner continues. In Go, you implement this by defining a function signature, collecting functions that match that signature in a slice, and iterating over the slice to call each one.

The core mechanism

Go treats functions as first-class values. You can assign a function to a variable, pass it as an argument, return it from another function, and store it in data structures. This capability makes hook systems natural to express. You define a type that represents the function signature, create a collection to hold instances of that type, and provide methods to add to the collection and invoke the contents.

The simplest form uses a slice. A slice is a reference to an underlying array with a length and capacity. You append functions to the slice as they register. When the event fires, you iterate over the slice and call each function. The order of execution matches the order of registration.

Minimal implementation

Here is the skeleton: define the type, store the slice, iterate to call.

package main

import "fmt"

// Hook defines the signature for all registered callbacks.
// Every hook receives the event name as a string.
type Hook func(string)

// hooks stores the list of registered callbacks.
var hooks []Hook

// Register adds a new hook to the list.
func Register(h Hook) {
	hooks = append(hooks, h)
}

// Fire invokes every registered hook with the given event.
func Fire(event string) {
	for _, h := range hooks {
		h(event)
	}
}

func main() {
	// Register two simple hooks that print the event.
	Register(func(s string) { fmt.Println("Hook 1:", s) })
	Register(func(s string) { fmt.Println("Hook 2:", s) })

	// Trigger the hooks.
	Fire("user-logged-in")
}

The type Hook func(string) line creates a named function type. This is distinct from an interface. An interface defines a set of methods. A function type defines a single signature. You can assign any function matching the signature to a variable of type Hook. The compiler enforces type safety. If you try to pass a function with the wrong signature, the compiler rejects the program with cannot use func(int) as Hook value in argument.

The hooks variable starts as a nil slice. The first call to Register allocates the underlying array. append handles growth automatically. If the slice needs more capacity, append allocates a larger array, copies the elements, and updates the slice header. Fire loops over the slice. Each element is a function value, so h(event) calls it.

Hooks are just functions in a list. Keep the signature simple.

Loop variables and closures

Registering hooks inside a loop requires attention to variable capture. A closure captures variables from its surrounding scope. In Go versions before 1.22, loop variables were reused across iterations. If you registered a hook inside a loop that referenced the loop variable, every hook would see the final value of the variable.

// BAD pattern in Go < 1.22.
// All hooks capture the same variable i.
// After the loop, i equals n.
// Every hook prints n.
for i := 0; i < 3; i++ {
	Register(func(s string) { fmt.Println("Index:", i) })
}

Go 1.22 changed loop semantics. Each iteration now gets a new variable instance. The code above works correctly in Go 1.22 and later. If you compile older code that captures a loop variable, the compiler rejects it with loop variable i captured by func literal. This error forces you to acknowledge the change. You can fix legacy code by creating a local copy of the variable inside the loop body.

// Safe pattern for all Go versions.
// i is a new variable for each iteration.
for i := 0; i < 3; i++ {
	idx := i
	Register(func(s string) { fmt.Println("Index:", idx) })
}

Capture variables explicitly. Trust the compiler to catch stale references.

Realistic manager with concurrency

Real applications often need multiple event types and concurrent access. A single slice works for one event. A map groups hooks by event name. Concurrency requires synchronization. A sync.Mutex protects the map from data races.

Here is the manager structure and registration logic.

package main

import "sync"

// HookManager stores callbacks grouped by event name.
// The mutex ensures safe concurrent access.
type HookManager struct {
	mu    sync.Mutex
	hooks map[string][]func()
}

// NewHookManager initializes the map.
func NewHookManager() *HookManager {
	return &HookManager{
		hooks: make(map[string][]func()),
	}
}

// Register appends a callback to the event's list.
// Locking prevents data races during registration.
func (m *HookManager) Register(event string, fn func()) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.hooks[event] = append(m.hooks[event], fn)
}

The receiver name is m, following the convention of short names matching the type. NewHookManager allocates the map. The zero value of a map is nil. Writing to a nil map panics with fatal error: assignment to entry in nil map. Initializing the map in the constructor avoids this. Register locks the mutex before modifying the map. defer m.mu.Unlock() ensures the mutex unlocks even if a panic occurs.

Here is the firing logic and usage.

// Fire iterates over hooks for the event and calls them.
// Locking protects the map while reading.
func (m *HookManager) Fire(event string) {
	m.mu.Lock()
	defer m.mu.Unlock()
	for _, fn := range m.hooks[event] {
		fn()
	}
}

func main() {
	mgr := NewHookManager()

	// Register multiple hooks for the same event.
	mgr.Register("start", func() { fmt.Println("Init DB") })
	mgr.Register("start", func() { fmt.Println("Init Cache") })

	mgr.Fire("start")
}

Fire locks the mutex during iteration. If another goroutine calls Register while Fire is running, the mutex blocks the registration until Fire completes. This prevents fatal error: concurrent map iteration and map write. The iteration is safe because the slice is read while locked. The hooks themselves run while the mutex is held. If a hook takes a long time, it blocks other goroutines from registering or firing. Consider unlocking before calling hooks if registration is frequent, but be aware that the slice might change during iteration.

Lock the map. Unlock the map. Don't let the runtime catch you.

Concurrency optimizations

A sync.Mutex allows only one goroutine to hold the lock at a time. If your application fires hooks much more often than it registers them, a sync.RWMutex improves performance. RWMutex allows multiple readers or a single writer.

// Fire uses RLock for concurrent reads.
// Multiple goroutines can fire hooks simultaneously.
func (m *HookManager) Fire(event string) {
	m.mu.RLock()
	defer m.mu.RUnlock()
	for _, fn := range m.hooks[event] {
		fn()
	}
}

RLock allows concurrent execution of Fire. Register still uses Lock to block readers during writes. This optimization helps when reads dominate. Profile before optimizing. A simple mutex is often fast enough and easier to reason about.

Handling errors and panics

Hooks can fail. A panic in one hook stops the chain. The panic propagates to the caller of Fire. If you want resilience, recover from panics inside Fire.

// Fire calls hooks with panic recovery.
// It logs panics but continues to the next hook.
func (m *HookManager) Fire(event string) {
	m.mu.RLock()
	defer m.mu.RUnlock()
	for _, fn := range m.hooks[event] {
		func() {
			defer func() {
				if r := recover(); r != nil {
					fmt.Printf("Hook panicked: %v\n", r)
				}
			}()
			fn()
		}()
	}
}

The anonymous function wraps the call. recover catches the panic inside the deferred function. The panic is logged, and the loop continues. This isolates failures. A panic in a hook kills the chain. Recover or isolate.

Hooks might also return errors. Change the signature to func() error. Aggregate errors using errors.Join from the standard library.

// Fire collects errors from all hooks.
func (m *HookManager) Fire(event string) error {
	m.mu.RLock()
	defer m.mu.RUnlock()
	var errs []error
	for _, fn := range m.hooks[event] {
		if err := fn(); err != nil {
			errs = append(errs, err)
		}
	}
	return errors.Join(errs...)
}

errors.Join combines multiple errors into one. The caller can check the result. If you need to stop on the first error, return immediately. The design choice depends on whether hooks are independent or sequential.

Conventions and style

Go has strong conventions that improve readability. Follow them in hook systems.

Public names start with a capital letter. Private names start lowercase. Register and Fire are exported. hooks is unexported. This encapsulation prevents external code from modifying the slice directly.

Use gofmt to format code. Do not argue about indentation. Run gofmt on save. Most editors integrate this automatically. Consistent formatting reduces cognitive load.

Error handling is verbose by design. if err != nil { return err } makes the unhappy path visible. Do not hide errors. If a hook returns an error, handle it explicitly.

Context is plumbing. If hooks perform I/O or network calls, they should accept a context.Context as the first parameter. Name it ctx. Respect cancellation and deadlines.

// HookWithContext adds context support.
type HookWithContext func(ctx context.Context) error

Functions that take a context should check ctx.Done() or pass the context to downstream calls. This allows the caller to cancel long-running hooks.

Accept interfaces, return structs. If you need to extend hook behavior, define an interface. Return concrete structs from constructors. This keeps dependencies flexible.

Do not pass *string. Strings are cheap to pass by value. Pass string directly.

Use _ to discard values intentionally. result, _ := someFunc() says you considered the second return value and chose to drop it. Use this sparingly with errors. Dropping errors without comment is a bug waiting to happen.

Decision matrix

Choose the implementation based on your requirements.

Use a simple slice of functions when you have a single event type and registration happens only at startup.

Use a map-based manager with a mutex when you need multiple event names and concurrent registration.

Use an interface-based hook when hooks need to share state or implement a lifecycle with methods like Init and Close.

Use a channel-based pipeline when hooks need to transform data sequentially rather than react independently.

Use context.Context in your hook signature when hooks need to respect cancellation or deadlines.

Use sync.RWMutex when reads vastly outnumber writes and profiling shows contention.

Pick the structure that matches your concurrency needs. Complexity grows fast.

Where to go next