How to Implement the Observer Pattern in Go

Implement the Observer pattern in Go by defining Subject and Observer interfaces with Attach, Detach, Notify, and Update methods to manage state changes.

The problem with tight coupling

You are building a notification system for a task manager. When a user completes a task, three things need to happen: the analytics service records the event, the badge counter updates, and the team chat gets a message. You could chain these calls directly inside your task completion function. That works for a moment. Then you add email notifications. Then you add a webhook for an external CRM. Your task completion function swells with dependencies. Changing the analytics provider requires touching the task logic. The code becomes brittle and hard to test.

You want a way to separate the event source from the event consumers. The task manager should only know that a task finished. It should not care who is listening. It should broadcast the fact, and interested parties should react on their own.

This is the Observer pattern. One object, the Subject, maintains a list of dependents, the Observers. When the Subject changes state, it automatically notifies all Observers by calling their update methods. In Go, this pattern maps cleanly to interfaces and slices. It also highlights a key Go principle: implicit interface satisfaction. You do not declare that a type implements an interface. You just write the methods, and the compiler does the rest.

How the pattern works in Go

Think of the Observer pattern like a newsletter subscription. You provide your email address to the publisher. The publisher adds you to a mailing list. When a new issue drops, the publisher sends an email to everyone on the list. You can unsubscribe at any time. The publisher does not know who you are or what you do with the email. It just knows you are on the list.

In Go code, the Subject is a struct that holds a slice of Observer interfaces. The Observer is an interface with a single method, usually Update. The Subject provides methods to Attach and Detach observers, and a Notify method that iterates over the slice and calls Update on each one.

Go's type system makes this flexible. Any struct that implements the Update method satisfies the Observer interface. You do not need to inherit from a base class or use an implements keyword. The compiler checks the method signature at compile time. If the signature matches, the type is valid. This keeps the code decoupled and easy to extend.

Minimal implementation

Here is the simplest form of the pattern. We define the interfaces first, then the concrete types.

package main

import "fmt"

// Observer defines the contract for receiving updates.
// Any type with this method satisfies the interface implicitly.
type Observer interface {
	Update(subject Subject)
}

// Subject defines the contract for managing observers.
// It handles attachment, detachment, and notification.
type Subject interface {
	Attach(o Observer)
	Detach(o Observer)
	Notify()
}

The Subject interface declares the control methods. The Observer interface declares the callback. Notice the receiver names are not part of the interface definition. The compiler only cares about the method name and signature.

Next, we implement the Subject. We use a struct to hold the state and the list of observers.

// ConcreteSubject holds the state and the list of observers.
// The slice grows dynamically as observers attach.
type ConcreteSubject struct {
	observers []Observer
	state     int
}

// Attach adds an observer to the notification list.
// It appends to the slice, which may reallocate the underlying array.
func (s *ConcreteSubject) Attach(o Observer) {
	s.observers = append(s.observers, o)
}

// Detach removes an observer from the list.
// It scans the slice and rebuilds it without the target observer.
func (s *ConcreteSubject) Detach(o Observer) {
	for i, obs := range s.observers {
		if obs == o {
			// Remove the element by slicing around it and appending.
			s.observers = append(s.observers[:i], s.observers[i+1:]...)
			break
		}
	}
}

// Notify iterates over observers and calls their Update method.
// This runs synchronously, blocking until all observers finish.
func (s *ConcreteSubject) Notify() {
	for _, obs := range s.observers {
		obs.Update(s)
	}
}

The receiver name s is a convention. Use a short name that matches the type, like s for Subject or b for Buffer. Avoid this or self, which are common in other languages but not idiomatic in Go. The Detach method uses a linear scan. For small lists, this is fast enough. If you have thousands of observers, a map might be better, but slices are simpler and cache-friendly for typical use cases.

Now we implement an Observer. This struct holds its own state and reacts to updates.

// ConcreteObserver reacts to state changes from the subject.
// It stores the name for logging and the last known state.
type ConcreteObserver struct {
	name  string
	state int
}

// Update implements the Observer interface.
// It asserts the subject type to access specific state data.
func (o *ConcreteObserver) Update(subject Subject) {
	// Type assertion checks if the subject is the concrete type.
	// This is safe because we control the subject implementation here.
	if s, ok := subject.(*ConcreteSubject); ok {
		o.state = s.state
		fmt.Printf("Observer %s updated: state is now %d\n", o.name, o.state)
	}
}

The Update method receives the Subject interface. To access the state, we perform a type assertion. This is a common pattern when the Subject interface is generic, but the observer needs specific data. In more refined designs, you might pass the data directly in the Update method to avoid assertions.

Finally, we wire it together in main.

func main() {
	// Create the subject and two observers.
	subject := &ConcreteSubject{}
	o1 := &ConcreteObserver{name: "O1"}
	o2 := &ConcreteObserver{name: "O2"}

	// Attach observers to the subject.
	subject.Attach(o1)
	subject.Attach(o2)

	// Change state triggers notification to both observers.
	subject.state = 10
	subject.Notify()

	// Detach one observer and notify again.
	subject.Detach(o1)
	subject.state = 20
	subject.Notify()
}

Run this code and you see the output. Both observers update on the first change. After detaching o1, only o2 updates on the second change. The pattern works. The subject drives the flow. The observers react.

Walking through the execution

When main starts, subject is a pointer to a ConcreteSubject with an empty slice. o1 and o2 are pointers to ConcreteObserver structs.

Calling subject.Attach(o1) appends o1 to the observers slice. The slice now has one element. Attach(o2) adds the second. The underlying array holds two interface values. Each interface value is a pair: a pointer to the type descriptor and a pointer to the data.

When we set subject.state = 10 and call Notify, the loop iterates over the slice. It calls o1.Update(subject). Inside Update, the type assertion succeeds. o1 reads the state and prints. Then it calls o2.Update(subject). o2 does the same.

When we call Detach(o1), the loop scans the slice. It finds o1 at index 0. It rebuilds the slice by taking the elements after index 0 and appending them to the empty prefix. The slice now contains only o2. The memory for o1 is not freed yet because main still holds a reference, but the subject no longer tracks it.

This synchronous flow is simple and predictable. The Notify call blocks until every observer finishes. If an observer takes a long time, the subject waits. This is a trade-off. Simplicity versus latency.

Realistic example: thread-safe notifications

The minimal example works for single-threaded code. Go is a concurrent language. If you attach or detach observers from one goroutine while another goroutine calls Notify, you get a race condition. The slice is not safe for concurrent access.

A realistic implementation adds a mutex to protect the observer list. It also copies the slice before notifying. This prevents panics if an observer detaches itself or another observer during the notification loop.

package main

import (
	"fmt"
	"sync"
)

// ThreadSafeSubject adds concurrency safety to the observer pattern.
// It uses a mutex to protect the observer list.
type ThreadSafeSubject struct {
	mu        sync.Mutex
	observers []Observer
	state     int
}

// Attach adds an observer while holding the lock.
// This prevents concurrent modification of the slice.
func (s *ThreadSafeSubject) Attach(o Observer) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.observers = append(s.observers, o)
}

// Detach removes an observer while holding the lock.
// It scans and rebuilds the slice safely.
func (s *ThreadSafeSubject) Detach(o Observer) {
	s.mu.Lock()
	defer s.mu.Unlock()
	for i, obs := range s.observers {
		if obs == o {
			s.observers = append(s.observers[:i], s.observers[i+1:]...)
			break
		}
	}
}

// Notify copies the observer list before iterating.
// This allows observers to detach safely during notification.
func (s *ThreadSafeSubject) Notify() {
	s.mu.Lock()
	// Copy the slice to avoid holding the lock during callbacks.
	// This also prevents panic if an observer modifies the list.
	observers := make([]Observer, len(s.observers))
	copy(observers, s.observers)
	s.mu.Unlock()

	// Iterate over the copy. The subject is free to change.
	for _, obs := range observers {
		obs.Update(s)
	}
}

The Notify method acquires the lock, copies the slice, and releases the lock. Then it iterates over the copy. This is a crucial pattern in Go. If you iterate over the original slice while holding the lock, and an observer calls Detach, the Detach call blocks waiting for the lock. This causes a deadlock. If you iterate without copying, and an observer detaches, you might panic with a slice bounds error. Copying the slice solves both problems. The copy is cheap for small lists. For large lists, the allocation cost is the price of safety.

The receiver name s is consistent. The mutex is named mu, which is standard. The defer statement ensures the lock is released even if a panic occurs. This is defensive coding.

Pitfalls and compiler errors

The Observer pattern introduces subtle bugs if you ignore concurrency or lifecycle management.

Blocking observers If an observer hangs, Notify blocks. The subject cannot proceed. This is common when observers make network calls. If you need non-blocking behavior, spawn a goroutine for each observer or use channels.

// Bad: blocks if obs.Update hangs.
obs.Update(s)

// Better: runs in a new goroutine.
go obs.Update(s)

Spawning goroutines makes the notification asynchronous. The subject returns immediately. The observers run in parallel. This improves latency but complicates error handling. You lose the guarantee that all observers finish before the next state change.

Detaching during notification If you iterate over the slice without copying, and an observer detaches another observer, the slice shrinks. The loop index might skip elements or go out of bounds. The compiler cannot catch this. It is a runtime logic error. Always copy the slice or use a reverse loop if you must modify in place.

Memory leaks If you attach an observer but never detach it, the subject holds a reference. The observer cannot be garbage collected. This leaks memory. Ensure every Attach has a corresponding Detach path, or use weak references if available. Go does not have weak references, so you must manage detachment explicitly.

Compiler errors If you implement the interface incorrectly, the compiler rejects the code.

// Wrong signature: missing receiver or wrong argument type.
func (o *ConcreteObserver) Update() {
	// ...
}

The compiler complains with cannot use o as Observer value in argument: *ConcreteObserver does not implement Observer (wrong type for method Update). The error message tells you exactly which method is wrong. Fix the signature to match the interface.

If you forget to implement a method, you get a similar error. Go's implicit interfaces are strict. The type must satisfy the contract completely.

Convention aside The community accepts verbose error handling. If your Update method returns an error, handle it. Do not ignore it with _ unless you have a reason. result, _ := ... says "I considered the return value and chose to drop it". Use it sparingly. For observers, it is common to swallow errors if the observer is best-effort, but log them. log.Printf("observer failed: %v", err).

Decision matrix

The Observer pattern is a tool, not a requirement. Go offers multiple ways to decouple code. Choose the right tool for the job.

Use the interface-based Observer pattern when you need synchronous, ordered notifications and the number of observers is small. Use a channel-based pub/sub system when you need asynchronous, decoupled flow and high throughput. Use direct function calls when you have a single consumer and want maximum performance with zero overhead. Use a mutex-protected slice when multiple goroutines modify the observer list. Use a copied slice in Notify when observers might detach themselves during notification.

Goroutines are cheap. Channels are not magic. Pick the structure that matches your concurrency needs.

Where to go next