đď¸ Go beyond Goroutines: introducing the Reactive Programming paradigm â¨â¨â¨
Since 2009, the Go language has been a first-class citizen for concurrent applications. Today, it ascends to a new level with "ro", the reactive programming package.
Goâs strengths in event-based applications
Go shines in event-based programming because of a few key primitives:
Goroutines: lightweight threads that allow you to run thousands of concurrent tasks effortlessly.
Channels: a safe way to communicate between goroutines.
Select statements: allowing you to wait on multiple channels at once.
Context: controlling cancellation and timeouts in concurrent flows.
Together, these tools make Go perfect for microservices, real-time systems, and high-throughput backends.
Sounds perfect, right? Well⌠not quite.
Building Pipelines with Goroutines and Channels
Letâs consider a simple data processing pipeline:
func main() {
rand.Seed(time.Now().UnixNano())
source := make(chan int)
processed := make(chan int)
done := make(chan struct{})
// Producer
go func() {
for i := 0; i < 10; i++ {
val := rand.Intn(100)
fmt.Printf(âproducer: %d\nâ, val)
source <- val
}
close(source)
}()
// Workers
var wg sync.WaitGroup
for w := 0; w < 10; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for v := range source {
res := v * 2
fmt.Printf(âworker %d: %d -> %d\nâ, id, v, res)
processed <- res
}
}(w)
}
// Close processed channel after all workers finish
go func() {
wg.Wait()
close(processed)
}()
// Consumer
go func() {
for v := range processed {
fmt.Printf(âcollector: received %d\nâ, v)
}
done <- struct{}{}
}()
<-done
}Works fine for this tiny example, but what if your pipeline grows to 10, 20, or 50 stages? Suddenly, youâre juggling channel closures, select statements, and goroutine lifetimes. Itâs verbose, error-prone, and painful to maintain.
Enter DSLs for Event Pipelines
This is where DSLs (Domain-Specific Languages) come in. A DSL allows us to describe pipelines declaratively instead of imperatively. Instead of manually connecting goroutines and channels, you can express your workflow in a clean, readable chain of operations.
Reactive Programming to the Rescue
Reactive Programming is all about streams of data and operators to transform, filter, and combine them. With reactive paradigms, pipelines become simple, readable, and composable, even for complex event-driven systems.
Hereâs a tiny reactive pipeline example with samber/ro:
observable := ro.Pipe[int, int](
ro.Just(1, 2, 3, 4),
ro.Map(func(x int) int { return x * 2 }),
ro.Filter(func(x int) bool { return x > 4 }),
)Notice how clean this is compared to goroutines + channels. No closures to close, no manual coordination, no messy selects. In this example, ro.Just(âŚ) creates a predictable stream, but any source could be used.
Run this samber/ro sample in Go Playground now!.
Learning from RxJS
I tested multiple reactive libraries before designing my Go library. RxJS stood out as the most mature and developer-centric:
They werenât afraid of breaking changes, which resulted in an outstanding API.
Operators are intuitive, composable, and provide many variants.
Backpressure, hot/cold observables, and Subjects make life easy.
It became clear: Go needed something inspired by RxJS but idiomatic to Go.
What about RxGo?
RxGo exists, but it has limitations:
No generics â verbose, unsafe, unreadable, and hard to maintain pipelines.
Specifications differ from standard ReactiveX conventions, making it challenging to adopt, for developers familiar with the paradigm.
Missing Subjects â impossible to create hot observables without workarounds.
Built on top of Go channels â broken backpressure.
Whatâs wrong with Go channels in RxGo? Letâs see a simple example:
rxgo.Range(0, 3).
Map(func(_ context.Context, item interface{}) (interface{}, error) {
fmt.Println("Map-A:", item)
time.Sleep(10 * time.Millisecond) // simulate slow processing
return item, nil
}).
Map(func(_ context.Context, item interface{}) (interface{}, error) {
fmt.Println("Map-B:", item)
time.Sleep(10 * time.Millisecond) // simulate slow processing
return item, nil
}).
ToSlice(0)
// Output:
// Map-A: 0
// Map-A: 1 // <- â bad
// Map-B: 0
// Map-B: 1
// Map-A: 2
// Map-B: 2In RxGo, messages may print out of order due to the channel. When you write on a channel (ch <- msg), the producer blocks until the consumer reads from it (msg := <- ch). As soon as the consumer reads the next message, the producer is unblocked, and upstream processing continues, even if the consumer is still processing data.
In samber/ro, execution is predictable, ordered, and easy to reason about:
ro.Pipe(
ro.Range(0, 3),
ro.Map(func(n int) int {
fmt.Println("Map-A:", item)
time.Sleep(10 * time.Millisecond)
return item
}),
ro.Map(func(n int) int {
fmt.Println("Map-B:", item)
time.Sleep(10 * time.Millisecond)
return item
}),
).Subscribe( ... )
// Output:
// Map-A: 0
// Map-B: 0 // <- â
good
// Map-A: 1
// Map-B: 1
// Map-A: 2
// Map-B: 2Oh, and RxGo has not been maintained for 3 yearsâŚ
Key Takeaways
Go remains a fantastic language for concurrent programming. But when it comes to complex, event-driven pipelines, the traditional goroutines + channels approach starts to show its limits.
With Reactive Programming, we gain:
Readability: declarative pipelines instead of verbose coordination.
Composability: chain operations like building blocks.
Backpressure & flow control: predictable execution even under load.
Want to try? Start here: github.com/samber/ro.
Thanks!
If you enjoy my work, consider sponsoring me on GitHub. Your support helps me keep blogging and coding open-source projects đ github.com/sponsors/samber

