The notification trap
You are building a service that processes orders. When an order is placed, three things must happen: send a confirmation email, update the inventory count, and log the event for analytics. The naive approach is to call the email service, the inventory service, and the logger directly inside the order handler. This works until the email service times out. Now the order handler hangs, the user sees a 500 error, and the inventory never updates. The components are tightly coupled. A failure in one breaks the whole chain.
You want the order handler to emit an "order created" event and move on. The email service, inventory service, and logger should react to that event independently. If the email service is slow, it shouldn't block the inventory update. This is event-driven architecture. In Go, you implement this pattern using channels and goroutines to decouple producers from consumers.
Decoupling with channels
Event-driven architecture relies on loose coupling. A producer emits an event without knowing who is listening. Consumers subscribe to events they care about. The producer and consumer share no direct dependency. They communicate through a central routing mechanism.
Think of a radio station. The station broadcasts a signal on a frequency. Listeners tune in with their radios. The station does not track every listener. If a listener turns off their radio, the station keeps broadcasting. If a new listener tunes in, they hear the signal immediately. The station and listeners are independent. The frequency is the contract.
In Go, channels act as the broadcast medium. Goroutines are the listeners and broadcasters. An event bus maps event types to channels. When a producer publishes an event, the bus sends the event to all channels subscribed to that type. Each subscriber runs in its own goroutine, receiving events asynchronously.
Minimal event bus
Here is the skeleton of an event bus. It maps event types to a slice of channels and publishes events to all subscribers.
package main
import (
"fmt"
"sync"
)
// Event represents a discrete occurrence in the system.
type Event struct {
Type string
Data any
}
// EventBus routes events to interested subscribers.
type EventBus struct {
// subscribers maps event types to a slice of channels.
// Each subscriber gets its own channel to avoid blocking others.
subscribers map[string][]chan Event
mu sync.RWMutex
}
// NewEventBus creates a ready-to-use event bus.
func NewEventBus() *EventBus {
return &EventBus{
// Initialize the map so Publish doesn't panic on nil map access.
subscribers: make(map[string][]chan Event),
}
}
// Subscribe adds a channel to the list for a specific event type.
func (bus *EventBus) Subscribe(eventType string, ch chan Event) {
bus.mu.Lock()
defer bus.mu.Unlock()
// Append the channel so multiple subscribers can listen to the same type.
bus.subscribers[eventType] = append(bus.subscribers[eventType], ch)
}
// Publish sends an event to all subscribers of that type.
func (bus *EventBus) Publish(event Event) {
bus.mu.RLock()
defer bus.mu.RUnlock()
// Iterate over channels while holding the read lock.
// Sending blocks if the channel is full, so buffer size matters.
for _, ch := range bus.subscribers[event.Type] {
ch <- event
}
}
func main() {
bus := NewEventBus()
// Buffered channel allows the publisher to return without waiting for the consumer.
orderChan := make(chan Event, 1)
bus.Subscribe("order.created", orderChan)
go func() {
for e := range orderChan {
fmt.Printf("Processing: %s\n", e.Data)
}
}()
bus.Publish(Event{Type: "order.created", Data: "Order #42"})
}
How the bus routes events
The compiler checks types at build time. Event is a struct. EventBus holds a map and a mutex. Subscribe takes a channel. Publish sends to channels. The code compiles cleanly.
At runtime, NewEventBus allocates the struct and initializes the map. Subscribe acquires a write lock, appends the channel to the slice for the event type, and releases the lock. Publish acquires a read lock, iterates over the slice of channels, and sends the event to each channel. The send operation blocks if the channel is unbuffered and no receiver is ready, or if the channel is buffered and full.
The mutex protects the map from concurrent access. Multiple goroutines can call Subscribe and Publish simultaneously. The read lock allows multiple publishers to read the subscriber list at the same time. The write lock ensures exclusive access when modifying the list.
Buffer size is a design decision. An unbuffered channel couples the publisher to the subscriber. The publisher blocks until the subscriber receives. A buffered channel decouples them. The publisher returns immediately as long as the buffer has space. If the buffer fills, the publisher blocks again. Choose the buffer size based on the expected burst rate and the subscriber's processing speed.
Channels are wires. Don't wire everything together.
Production-ready patterns
Real systems need lifecycle management and error handling. You need to stop the bus gracefully. You need to handle slow subscribers without blocking the publisher. You need to respect context cancellation.
Here is a bus that supports cancellation and non-blocking publishes. It drops events if a subscriber is too slow, protecting the publisher from backpressure.
package main
import (
"context"
"fmt"
"sync"
)
// EventBus supports cancellation via context.
type EventBus struct {
subscribers map[string][]chan Event
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
}
// NewEventBus creates a bus tied to a parent context.
func NewEventBus(ctx context.Context) *EventBus {
ctx, cancel := context.WithCancel(ctx)
return &EventBus{
subscribers: make(map[string][]chan Event),
ctx: ctx,
cancel: cancel,
}
}
// Publish sends events non-blocking.
// If a subscriber is slow, the event is dropped to protect the publisher.
func (bus *EventBus) Publish(event Event) {
bus.mu.RLock()
defer bus.mu.RUnlock()
for _, ch := range bus.subscribers[event.Type] {
select {
case ch <- event:
// Event delivered.
default:
// Channel full or no receiver. Drop event to avoid blocking.
// In production, log this or use a retry queue.
}
// Check context to stop publishing if the bus is shutting down.
if bus.ctx.Err() != nil {
return
}
}
}
// Close stops the bus and signals subscribers to exit.
func (bus *EventBus) Close() {
bus.cancel()
bus.mu.Lock()
defer bus.mu.Unlock()
// Close all channels to unblock range loops in subscribers.
for _, chs := range bus.subscribers {
for _, ch := range chs {
close(ch)
}
}
}
func main() {
ctx := context.Background()
bus := NewEventBus(ctx)
orderChan := make(chan Event, 10)
bus.Subscribe("order.created", orderChan)
go func() {
for e := range orderChan {
fmt.Printf("Processing: %s\n", e.Data)
}
fmt.Println("Subscriber exited")
}()
bus.Publish(Event{Type: "order.created", Data: "Order #1"})
bus.Close()
}
The Publish method uses a select statement with a default case. This makes the send non-blocking. If the channel is full, the default case runs immediately, and the event is dropped. This prevents the publisher from waiting for a slow subscriber. In a production system, you would log the dropped event or push it to a retry queue.
The Close method cancels the context and closes all channels. Closing a channel signals subscribers to exit their range loops. This prevents goroutine leaks. Always close channels when you are done sending. The receiver should check for closure or use range to handle it automatically.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and runtime errors
Event-driven systems introduce concurrency bugs. The most common issues are deadlocks, race conditions, and goroutine leaks.
Deadlocks happen when goroutines block waiting for each other. If you publish to an unbuffered channel and no subscriber is running, the publisher blocks. If the publisher is the only goroutine, the program hangs. The runtime panics with fatal error: all goroutines are asleep - deadlock! if every goroutine is blocked waiting for a channel operation that never completes. Start subscribers before publishing. Use buffered channels to break immediate coupling.
Race conditions happen when multiple goroutines access shared state without synchronization. If you forget the mutex in Subscribe or Publish, the map can corrupt. The runtime detects this and panics with concurrent map read and map write. Always protect the subscriber map with a mutex. Use sync.RWMutex to allow concurrent reads.
Goroutine leaks happen when a goroutine waits on a channel that never gets closed. If a subscriber runs in a loop and the channel is never closed, the goroutine runs forever. This consumes memory and file descriptors. Always close channels when publishing stops. Use context.Context to signal cancellation. The worst goroutine bug is the one that never logs.
Slow consumers can cause backpressure. If one subscriber is slow, it fills its channel buffer. If the publisher blocks on that channel, it blocks for all subscribers. This couples the publisher to the slowest consumer. Use non-blocking sends with select to drop events or use a worker pool to bound concurrency. Measure your buffer sizes. A buffer that is too small causes drops. A buffer that is too large hides latency.
The receiver name is usually one or two letters matching the type: (bus *EventBus) Publish(...), not (this *EventBus). Follow Go naming conventions to keep code readable.
When to use an event bus
Use an event bus when you have multiple independent consumers reacting to the same event. Use a direct channel when one goroutine feeds another in a pipeline. Use a worker pool when you need to bound concurrency for heavy tasks. Use a database or message queue when you need persistence across process restarts. Use sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.
In-memory event buses are fast and simple. They work well for single-process applications. They do not survive restarts. If you need durability, use an external message broker like Kafka or RabbitMQ. Go channels are for intra-process communication. External queues are for inter-process communication. Choose the tool that matches your failure domain.
Don't fight the type system. Wrap the value or change the design.