Why Channels for Payment Events
A typical payment platform processes events that need to flow through multiple stages: validation, enrichment, routing, execution, and notification. Each stage has different latency characteristics — validation is sub-millisecond, but calling an acquirer API takes 200-800ms.
Go channels let you decouple these stages so a slow acquirer response doesn't block the validation of the next transaction. But the standard "just send it on a channel" approach creates problems at scale: unbounded memory growth, lost events during deploys, and silent backpressure that manifests as increased latency.
Scale context: The patterns in this article come from a system processing 3,000-5,000 payment events per second across multiple acquirer integrations. At this volume, a 50ms stall in one stage can queue 200+ events in under a second.
The Pipeline Pattern
The core idea is simple: each processing stage reads from an input channel and writes to an output channel. The stages are connected in sequence, and each runs in its own goroutine.
type PaymentEvent struct {
ID string
Amount int64
Currency string
MerchantID string
CardToken string
Status string
}
func validate(in <-chan PaymentEvent) <-chan PaymentEvent {
out := make(chan PaymentEvent, 256)
go func() {
defer close(out)
for evt := range in {
if evt.Amount <= 0 || evt.Currency == "" {
continue
}
evt.Status = "validated"
out <- evt
}
}()
return out
}
func enrich(in <-chan PaymentEvent) <-chan PaymentEvent {
out := make(chan PaymentEvent, 256)
go func() {
defer close(out)
for evt := range in {
// Look up merchant config, fee tier, routing rules
evt.Status = "enriched"
out <- evt
}
}()
return out
}
Composing the pipeline is then straightforward:
events := ingest()
validated := validate(events)
enriched := enrich(validated)
routed := route(enriched)
Each stage owns its output channel and closes it when the input is exhausted. This propagates shutdown cleanly through the entire pipeline.
Fan-Out for Multi-Provider Routing
Payment orchestration often routes transactions to different acquirers based on card network, currency, or merchant preference. Fan-out distributes events from one channel to multiple downstream workers.
func fanOut(in <-chan PaymentEvent, workers int) []<-chan PaymentEvent {
outs := make([]<-chan PaymentEvent, workers)
for i := 0; i < workers; i++ {
ch := make(chan PaymentEvent, 128)
outs[i] = ch
go func(c chan PaymentEvent) {
defer close(c)
for evt := range in {
c <- evt
}
}(ch)
}
return outs
}
This is a competing-consumer fan-out — each event goes to exactly one worker. For payment routing, you typically want deterministic routing instead, where the same merchant always goes to the same worker to preserve ordering:
func routeByMerchant(in <-chan PaymentEvent, workers int) []chan PaymentEvent {
outs := make([]chan PaymentEvent, workers)
for i := range outs {
outs[i] = make(chan PaymentEvent, 128)
}
go func() {
defer func() {
for _, ch := range outs {
close(ch)
}
}()
for evt := range in {
idx := hashMerchant(evt.MerchantID) % uint32(workers)
outs[idx] <- evt
}
}()
return outs
}
Fan-In for Settlement Aggregation
After multiple workers process events in parallel, you need to merge results back into a single stream — for example, to aggregate settlement batches or write to a single audit log.
func fanIn(channels ...<-chan PaymentEvent) <-chan PaymentEvent {
var wg sync.WaitGroup
merged := make(chan PaymentEvent, 256)
for _, ch := range channels {
wg.Add(1)
go func(c <-chan PaymentEvent) {
defer wg.Done()
for evt := range c {
merged <- evt
}
}(ch)
}
go func() {
wg.Wait()
close(merged)
}()
return merged
}
The sync.WaitGroup ensures the merged channel only closes after all input channels are drained. Without this, you'd either leak goroutines or close the channel prematurely.
Backpressure — The Part Everyone Skips
Buffered channels give you some breathing room, but they're not a backpressure strategy. When a downstream acquirer slows down, your buffered channels fill up, and suddenly your entire pipeline stalls.
The pattern that works in production: use select with a timeout to detect backpressure, then shed load explicitly rather than letting it cascade.
func sendWithBackpressure(ch chan<- PaymentEvent, evt PaymentEvent) error {
select {
case ch <- evt:
return nil
case <-time.After(50 * time.Millisecond):
backpressureCounter.Inc()
return fmt.Errorf("channel full, event %s shed", evt.ID)
}
}
Real-world gotcha: Don't use time.After in hot loops — it allocates a new timer every call. Use time.NewTimer and reset it, or use a pre-allocated ticker. At 5,000 events/sec, the GC pressure from time.After alone can add 2-3ms of p99 latency.
A better approach for high-throughput paths:
func sendWithTimer(ch chan<- PaymentEvent, evt PaymentEvent, t *time.Timer) error {
t.Reset(50 * time.Millisecond)
select {
case ch <- evt:
if !t.Stop() {
<-t.C
}
return nil
case <-t.C:
return fmt.Errorf("backpressure: event %s shed", evt.ID)
}
}
Graceful Shutdown with In-Flight Payments
You can't just kill a payment pipeline. In-flight transactions that are mid-authorization will leave money in limbo — held on the cardholder's account but never settled or voided. The shutdown sequence matters.
func shutdown(cancel context.CancelFunc, pipeline <-chan PaymentEvent) {
cancel() // Signal no new events
timeout := time.After(30 * time.Second)
for {
select {
case evt, ok := <-pipeline:
if !ok {
log.Info("pipeline drained cleanly")
return
}
log.Info("draining event", "id", evt.ID)
case <-timeout:
log.Warn("shutdown timeout, events may be orphaned")
return
}
}
}
The 30-second timeout is a hard boundary. Any events still in-flight after that get logged for manual reconciliation. In practice, we've found that 99.9% of events drain within 5 seconds — the timeout is there for the rare case where an acquirer connection hangs.
Production Lessons
- Buffer sizes matter more than you think. Too small and you get unnecessary backpressure. Too large and you hide latency problems. We settled on 256 per stage after benchmarking — enough to absorb a 50ms GC pause at 5,000 events/sec.
- Monitor channel depth. Expose the
len(ch)of each pipeline stage as a Prometheus gauge. A steadily growing channel depth is the earliest signal of a downstream problem. - Don't use unbuffered channels between stages. They create tight coupling — if the downstream stage pauses for even a microsecond, the upstream stage blocks. In payment systems, this cascading stall can cause authorization timeouts.
- Use
context.Contextalongside channels. Channels handle data flow; context handles cancellation. Trying to use channel closure for both leads to panics on double-close or sends on closed channels. - Test with realistic load profiles. Payment traffic is bursty — lunch hour spikes, flash sales, month-end settlements. Your channel pipeline needs to handle 3-5x average throughput without shedding load. Use Go's built-in benchmarks with
b.RunParallelto simulate concurrent producers.
References
- Go Blog — Go Concurrency Patterns: Pipelines and Cancellation
- Effective Go — Channels
- Go Standard Library — sync Package
- Concurrency in Go — Katherine Cox-Buday (O'Reilly)
Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. Code examples are simplified for clarity — always add proper error handling and testing for production use.