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.
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.
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 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!.
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.
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…
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.
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