The problem with raw channels
You are building a notification service. An HTTP handler receives a request and needs to hand off a message to a background worker that sends emails. You create a channel, pass it to the worker, and send a string from the handler. It works. Then you add a second worker for SMS. You add a priority field to the message. Suddenly the handler knows about the channel type, the buffer size, and the message structure. The worker knows about the handler's expectations. You have coupled two independent parts of your system with a raw wire.
Changing the message format now requires touching both the handler and the worker. Adding logging or metrics means modifying the send site. The channel has leaked implementation details across a boundary that should be clean. You need a middleman that enforces a contract and hides the mechanics.
What the bridge pattern does
The Bridge Channel Pattern wraps a channel inside a struct. The struct exposes methods like Send and Receive instead of exposing the channel directly. Callers interact with the methods. They don't see the channel operators. This decouples the producer from the consumer. You can change the buffer size, add validation, swap the underlying transport, or handle errors without breaking the callers.
Think of a raw channel like handing someone a direct wire to your brain. They can read your thoughts and you can feel their signals. A bridge is like a letterbox. You drop a letter in. You walk away. The recipient checks the box. The box manages the flow, holds the letters, and protects both sides from each other's timing.
The bridge also lets you add lifecycle management. A raw channel has no Close method that callers can invoke safely without panicking. A bridge struct can track state, signal shutdown, and prevent sends after the system is done. This pattern aligns with the Go convention of accepting interfaces and returning structs. You return a struct that holds the channel, and callers use the methods defined on that struct.
Minimal implementation
Here is the skeleton: a struct holding a channel, with methods that hide the channel operators. The buffer size is chosen inside the constructor, so callers don't need to know about buffering.
package main
import "fmt"
// Bridge wraps a channel to decouple senders from receivers.
type Bridge struct {
// Buffered to allow the sender to return without blocking immediately.
data chan string
}
// NewBridge creates a bridge with a buffer of one message.
func NewBridge() *Bridge {
return &Bridge{
data: make(chan string, 1),
}
}
// Send places a message into the bridge.
// It blocks if the buffer is full.
func (b *Bridge) Send(msg string) {
b.data <- msg
}
// Receive retrieves the next message from the bridge.
// It blocks until a message is available.
func (b *Bridge) Receive() string {
return <-b.data
}
func main() {
bridge := NewBridge()
// Sender runs in a goroutine to avoid deadlock in this simple example.
go bridge.Send("Hello from the other side")
fmt.Println(bridge.Receive())
}
The receiver name b matches the type Bridge. This is the standard Go convention for receiver names: one or two letters that hint at the type, not this or self. The gofmt tool will format the indentation and spacing automatically. Trust gofmt. Argue logic, not formatting.
How it runs
When NewBridge runs, make allocates a channel on the heap. The channel structure includes a buffer array and metadata for synchronization. The struct Bridge holds a pointer to that channel. When you call bridge.Send, the method forwards the value into the channel. Because the buffer size is one, the send completes immediately without blocking, provided the buffer is empty.
The go keyword spawns a new goroutine. The runtime schedules the goroutine to run the Send method. The main goroutine calls Receive. The receive operation checks the channel. If a value is available, it copies the value out and returns. If the channel is empty, the runtime parks the goroutine until a value arrives. In this case, the buffer has the value, so Receive returns instantly.
The cost of the bridge is a struct allocation and a method call overhead. The struct is tiny. The method call is inlined by the compiler in most cases. The channel allocation is the dominant cost, but that cost exists whether you use a bridge or a raw channel. The bridge adds safety and flexibility for negligible runtime expense.
Real-world bridge with lifecycle
Real systems need shutdown. A bridge should support closing so receivers can stop waiting and senders can detect failure. This example adds a done signal and a Close method. The Send method uses select to check if the bridge is closed before attempting to send.
// TaskBridge handles work items with cancellation support.
type TaskBridge struct {
// Input channel for tasks.
tasks chan string
// Done signals when the bridge is closed.
done chan struct{}
}
// NewTaskBridge initializes the bridge with a buffer.
func NewTaskBridge(size int) *TaskBridge {
return &TaskBridge{
tasks: make(chan string, size),
done: make(chan struct{}),
}
}
The done channel uses chan struct{}. This is the idiomatic way to signal events in Go. A struct{} takes zero bytes, so the channel carries no payload, only the signal. The done channel is never sent to; it is only closed. Closing a channel broadcasts to all receivers.
// Send adds a task. Returns false if the bridge is closed.
func (b *TaskBridge) Send(task string) bool {
select {
case b.tasks <- task:
return true
case <-b.done:
return false
}
}
// Close stops the bridge and unblocks receivers.
func (b *TaskBridge) Close() {
close(b.done)
close(b.tasks)
}
The select statement checks both cases. If the send to tasks can proceed, it sends and returns true. If the done channel is closed, the receive case is ready, and the method returns false without sending. This prevents panics. Sending on a closed channel causes a runtime panic. The Close method closes both channels. Closing done unblocks any goroutines waiting on the signal. Closing tasks allows receivers to detect that no more work is coming.
Only the sender should close a channel. The bridge struct encapsulates that responsibility. Callers invoke Close, and the bridge handles the channel semantics. This prevents the common bug where a receiver accidentally closes a channel and panics a sender.
Pitfalls and compiler errors
Goroutine leaks happen when a goroutine waits on a channel that never gets closed. If you spawn a worker that loops on Receive and you forget to close the bridge, the worker runs forever. The program exits, but the goroutine remains until the process dies. In long-running servers, this leaks memory and file descriptors. Always have a cancellation path. Close the bridge when the system shuts down.
Sending on a closed channel panics with send on closed channel. The runtime stops the program immediately. Use select with a done signal to guard sends, or ensure only the owner closes the channel. Receiving from a closed channel does not panic. It returns the zero value and a boolean false if you use the two-value receive form. This is how receivers detect shutdown.
If you try to send a value of the wrong type, the compiler rejects the program with cannot use x (type string) as type int in send. Go is statically typed. The channel type must match the value type exactly. If you forget to import a package, you get undefined: pkg. If you import a package and don't use it, you get imported and not used. The compiler catches these errors at build time.
Buffering choices matter. An unbuffered channel blocks the sender until a receiver is ready. A buffered channel allows the sender to proceed until the buffer is full. If you buffer too much, you hide backpressure. The sender keeps pushing work while the receiver falls behind, and memory grows. If you buffer too little, the sender blocks and slows down the producer. Tune the buffer size based on the expected burst and the receiver's throughput.
Encapsulate the channel. Expose the intent.
When to use a bridge
Use a bridge struct when you need to encapsulate channel logic, add methods like Close or Done, or hide the channel type from callers. Use a bridge when the lifetime of the channel is complex and you want to manage shutdown safely. Use a bridge when you want to return a concrete type that implements an interface, allowing callers to depend on the interface rather than the channel.
Use a raw channel when the scope is small, the lifetime is obvious, and you don't need extra behavior. Use a raw channel inside a single function or a tight loop where the producer and consumer are adjacent. Use a raw channel when you are building a pipeline stage that passes data to the next stage and the channel is an implementation detail of that stage.
Use a worker pool when you have many independent tasks and need to bound concurrency. A worker pool uses a channel to distribute work to a fixed number of goroutines. Use a worker pool when the downstream service has rate limits or when you want to control resource usage.
Use a pipeline when one stage feeds the next and you need to transform data in sequence. A pipeline chains channels between stages. Each stage reads from an input channel, processes the data, and writes to an output channel. Use a pipeline when the computation is a sequence of steps and you want to process items as they arrive.
A bridge is a contract, not just a wire.