Skip to content
gohacks.dev
Concurrency

Event Buses in Go and Why Most of Them Are Too Clever

A small typed event bus in Go, what it hides from the reader, why global buses and reflection ruin traceability, and plainer alternatives that usually win.

7 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

Every few months a codebase grows an event bus. The reasoning is familiar: the order service needs to tell the email service, the inventory service and the analytics service that an order was placed, and calling all three directly means the order service imports them all. So someone writes bus.Publish(OrderPlaced{...}), each service subscribes in its init, and the dependencies disappear from the import graph. The diagram looks cleaner. The program is harder to read.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article builds a small typed bus to show how little code it takes, then looks at what it costs, and ends with the alternatives I try first. It sits in the concurrency patterns cluster, next to pub-sub with channels, which builds a more complete broker. Everything ran on Go 1.25.6.

A typed bus in thirty lines

Before generics, Go event buses were built on interface{} and reflection, which gave up type safety for flexibility. Since Go 1.18 you can keep the types:

bus.goGo
package main

import "sync"

// Bus delivers events of type T to every subscriber, synchronously.
type Bus[T any] struct {
	mu   sync.RWMutex
	subs []func(T)
}

// Subscribe registers fn to be called for each published event.
func (b *Bus[T]) Subscribe(fn func(T)) {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.subs = append(b.subs, fn)
}

// Publish calls every subscriber in registration order.
func (b *Bus[T]) Publish(event T) {
	b.mu.RLock()
	subs := make([]func(T), len(b.subs))
	copy(subs, b.subs)
	b.mu.RUnlock()

	for _, fn := range subs {
		fn(event)
	}
}

It copies the subscriber slice under the read lock and calls the handlers outside it, so a handler that subscribes or publishes in turn does not deadlock on the bus's own lock. It is synchronous: Publish returns after every handler has run, in order. That choice matters, and we come back to it below. A demo with three subscribers:

main.goGo
package main

import "fmt"

type OrderPlaced struct {
	ID    int
	Total float64
}

func main() {
	var orders Bus[OrderPlaced]

	orders.Subscribe(func(e OrderPlaced) { fmt.Printf("email: receipt for order %d\n", e.ID) })
	orders.Subscribe(func(e OrderPlaced) { fmt.Printf("stock: reserve items for order %d\n", e.ID) })
	orders.Subscribe(func(e OrderPlaced) { fmt.Printf("stats: add %.2f to revenue\n", e.Total) })

	fmt.Println("placing order 42")
	orders.Publish(OrderPlaced{ID: 42, Total: 99.5})
	fmt.Println("order 42 placed")
}
Text
placing order 42
email: receipt for order 42
stock: reserve items for order 42
stats: add 99.50 to revenue
order 42 placed

The three handlers ran in registration order before Publish returned, so the closing line prints last. The compiler checks that every subscriber takes an OrderPlaced, which is the thing the interface{} buses of the past could not do. If this is all you need, the code above is fine. The problems start with what it does to the reader.

What the bus hides

Read orders.Publish(OrderPlaced{...}) in the order service. What happens next? You cannot tell from this line, or from this file, or from this package. The answer lives in whatever other packages called Subscribe, and finding them means searching the codebase for the event type and hoping every subscriber is a closure registered in plain sight. Go to Definition on Publish leads to the bus. The stack trace of a panic in a subscriber shows Publish and the closure, and not the line in the order service that triggered it. A debugger stepping through Publish jumps into code that the publisher knows nothing about.

That is what decoupling means, and it is the intended effect. The cost is the one you always pay when you remove a direct reference: the relationship is real, and it is now invisible. In a small system that is a convenience. In a large one, it is the reason a change to the order service breaks the email service at runtime instead of at compile time.

Several specific problems come with it.

Ordering becomes accidental. The handlers above ran email, then stock, then stats, because of the order of Subscribe calls. If that order depends on init functions across packages, it depends on the order the linker happens to initialize them, which is deterministic but not something anyone intended. Code that quietly relies on "stock reserves before email sends" works until a package is renamed.

Failure handling has no good answer. If the stock handler fails, what should Publish do? Return an error that the publisher cannot do anything about? Skip the remaining handlers? Carry on and lose the failure? With a direct call, the order service decides, in code that can be read and tested. With a bus, the policy is embedded in the bus and applies to everyone.

Synchronous means slow handlers slow the publisher. The version above calls handlers inline, so a slow email send delays the order response. Making it asynchronous (a goroutine per handler, or a channel per subscriber) fixes that and brings its own questions: what if a subscriber is slow, does the queue grow without bound? Is delivery order preserved? What happens to events in flight at shutdown? Those are the questions pub-sub with channels answers one at a time, and each answer is more code.

The global bus is worse

The version I regret most in other people's code, and my own, is var DefaultBus = &Bus{} at package level, with subscriptions in init. It is shared mutable state with no owner. Tests leak subscriptions into each other: a handler registered in one test fires during the next, and the failure is in a test that didn't touch the event. You cannot run two instances in one process. Startup order becomes a hidden dependency. And nothing in a function's signature says it can trigger any of this.

If you keep a bus, make it a value that is created in main and passed to the things that need it, like any other dependency. That keeps tests isolated (each test makes its own), and it makes the coupling to the bus visible in constructors.

Plain alternatives, in the order I try them

A direct call. The order service calls email.SendReceipt(order). It imports the email package. That is a dependency, and it is a true one: the order service does care that a receipt goes out. The compiler and the editor can find every use. Most of the time this is the right answer, and the fear of "too many imports" is a worse problem than the import.

An interface owned by the caller. If the order service should not import the email package, define a small interface in the order service: type ReceiptSender interface{ SendReceipt(Order) error }, and have main pass in an implementation. The dependency now points the right way, the call is still direct, and tests pass a fake. Accept interfaces, return structs is this rule applied.

A function field. For a single listener, a OnOrderPlaced func(Order) field set at construction is enough, and it has none of the machinery.

A channel. If the reaction is slow or must happen in another goroutine, publish onto a buffered channel and let one consumer own it. It is a queue with a single reader, and that is all many "event" needs are.

A slice of listeners. If there really are several, a []func(Order) on the service, with Add and a loop in the method that raises the event, is a bus with the abstraction taken away. It is easy to read and trivial to test.

When a bus does earn its place

Genuine fan-out to listeners the publisher cannot know about: a plugin system, audit hooks, a UI that updates when a model changes, an observability layer. In these cases the lack of traceability is the feature, because the publisher must not depend on what subscribes. Even then, I would keep the bus typed, pass it explicitly, give the subscribers a way to unsubscribe, and decide up front whether handlers run synchronously, with what timeout, and what happens when one panics (recovering in goroutines).

The small test for whether you need one: could you replace the bus with a []func(T) and two lines of code, and would anyone notice? If not, you have an event bus problem that is really a slice of functions. If the answer is that you need delivery guarantees, retries, persistence or cross-process reach, that is a message queue, and a different article. The Go standard library itself offers no bus, and the closest thing to a recommendation lives in the Go proverbs collection, which asks for clear over clever; see Go Proverbs and the language's own habit of using small interfaces.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.