Designing a Scalable Poller–Dispatcher–Worker Architecture in Go

Introduction

Background job processing is a foundational building block in modern backend systems. Whether handling email delivery, data synchronization, cache warming, or event-driven workflows, a well-designed job execution pipeline must be scalable, testable, and operationally safe.

This article presents a production-ready design for a Poller–Dispatcher–Worker–Job architecture in Go, optimized for:

  • Long-running services
  • Deterministic behavior
  • Clean lifecycle management
  • High testability

Go-Worker

Go-worker is an open source project and has a simple impediment of a Poller–Dispatcher–Worker–Job architecture in Go.

Core Design Goals

The system is designed around the following principles:

  1. Separation of responsibilities
  2. Explicit control flow
  3. Safe concurrency
  4. Graceful startup and shutdown
  5. Test-first architecture

High-Level Architecture

Designing a Scalable Poller–Dispatcher–Worker Architecture in Go

Component Responsibilities

1. Job: The Atomic Unit of Work

A Job represents a single business action.

go
type Job interface {
    ID() string Service() string Execute(ctx context.Context) error
}

Key characteristics:

  • Stateless or minimally stateful
  • Self-contained
  • Retry-safe
  • Executable independently

2. Worker: Concurrent Job Executor

Workers are long-lived goroutines that process jobs from a channel.

go
type Worker interface {
    Start(ctx context.Context) Stop()
}
go
type SimpleWorker struct {
    id int jobQueue <-chan job.Job cancel context.CancelFunc
}
func NewSimpleWorker(id int, jobQueue <-chan job.Job,) *SimpleWorker {
    return &SimpleWorker{
        id: id, jobQueue: jobQueue,
    }
}
func (w *SimpleWorker) Start(ctx context.Context) {
    ctx, cancel := context.WithCancel(ctx) w.cancel = cancel go func() {
        log.Printf("[worker-%d] started\n", w.id) for {
            //blocker select {
                case <-ctx.Done():
                log.Printf("[worker-%d] stopped\n", w.id)
                return case j := <-w.jobQueue:
                w.handleJob(ctx, j)
            }
        }
    }
    ()
}
func (w *SimpleWorker) Stop() {
    if w.cancel != nil {
        w.cancel()
    }
}
func (w *SimpleWorker) handleJob(ctx context.Context, j job.Job) {
    log.Printf("[worker-%d] executing job id=%s service=%s\n", w.id, j.ID(), j.Service(),) if err :=
    j.Execute(ctx);
    err != nil {
        log.Printf("[worker-%d] job failed id=%s error=%v\n", w.id, j.ID(), err,)
    }
}

Workers:

  • Block on job queues
  • Execute jobs synchronously
  • Report success or failure
  • Never make scheduling decisions

Design rule:

Workers execute work — they never decide what work to run.

3. Dispatcher: Routing and Backpressure Control

The dispatcher is responsible for:

  • Managing job queues
  • Enforcing concurrency limits
  • Routing jobs to the correct worker pool
go
type Dispatcher interface {
    Start() Stop() Dispatch(j job.Job) error
}
var ErrServiceNotRegistered = errors.New("service not registered"
)
type Service struct {
    ctx
    context.Context cancel context.CancelFunc mu sync.RWMutex queues map[string]chan job.Job workers
    map[string][]worker.Worker
}
func New() *Service {
    ctx, cancel := context.WithCancel(context.Background()) return &Service{
        ctx: ctx, cancel: cancel, queues: make(map[string]chan job.Job), workers:
        make(map[string][]worker.Worker),
    }
}
func (d *Service) Register(service string, workerCount int, queueSize int,) {
    d.mu.Lock() defer d.mu.Unlock() if _, exists := d.queues[service];
    exists {
        fmt.Println("service already registered") return
    }
    queue := make(chan job.Job, queueSize) d.queues[service] = queue var workers []worker.Worker for
    i := 0;
    i < workerCount;
    i++ {
        w := worker.NewSimpleWorker(i+1, queue) workers = append(workers, w)
    }
    d.workers[service] = workers
}
func (d *Service) Start() {
    d.mu.RLock() defer d.mu.RUnlock() for _, ws := range d.workers {
        for _, w := range ws {
            w.Start(d.ctx)
        }
    }
}
func (d *Service) Dispatch(j job.Job) error {
    // Prevent dispatch after stop select {
        case <-d.ctx.Done(): return context.Canceled default:
    }
    d.mu.RLock() queue, ok := d.queues[j.Service()] d.mu.RUnlock() if !ok {
        return ErrServiceNotRegistered
    }
    // Safe send select {
        case queue <- j: return nil case <-d.ctx.Done(): return context.Canceled
    }
}
func (d *Service) Stop() {
    d.cancel() d.mu.RLock() defer d.mu.RUnlock() for _, q := range d.queues {
        close(q)
    }
}
func RegisterLifecycle(lc fx.Lifecycle, d *Service) {
    lc.Append(fx.Hook{
        OnStart: func(ctx context.Context) error {
            fmt.Println("starting dispatcher...") d.Start() return nil
        }, OnStop: func(ctx context.Context) error {
            fmt.Println("stopping dispatcher...") d.Stop() return nil
        },
    })
}
func RegisterServices(d *Service) {
    // Example services d.Register("email", 5, 100)
}

Each service has:

  • Its own buffered queue
  • A dedicated worker pool
  • Independent scaling characteristics

This design allows:

  • Different SLAs per service
  • Backpressure isolation
  • Clear failure domains

4. Poller: Time-Based Decision Engine

The poller is the control plane of the system.

go
/*Fx App ├── Dispatcher (long-lived) │
└── Worker pools ├── Poller (long-lived background process) │
└── Infinite loop │ ├── Check condition (DB / cache / API) │ └── Dispatch job(s) └── Servers*/type
Poller struct {
    Dispatcher *dispatcher.Service Interval time.Duration ctx context.Context cancel
    context.CancelFunc OnTick func(context.Context)
}
func New(dispatcher *dispatcher.Service,) *Poller {
    p := &Poller{
        Dispatcher: dispatcher, Interval: 500 * time.Microsecond,
    }
    p.OnTick = p.DefaultTick // default behavior
    return p
}
func (p *Poller) Run(ctx context.Context) {
    ticker := time.NewTicker(p.Interval) defer ticker.Stop() for {
        fmt.Println("poller For loop ...") select {
            case <-ctx.Done(): return case <-ticker.C: p.OnTick(ctx)
        }
    }
}
func (p *Poller) DefaultTick(ctx context.Context) {
    // Example condition (replace with DB/cache logic) log.Println("DefaultTick is running ...") if
    !p.conditionMet(ctx) {
        return
    }
    j := example.NewExampleJob("job-123", "email") _ = p.Dispatcher.Dispatch(j)
}
func (p *Poller) conditionMet(ctx context.Context) bool {
    // DB / Redis / API / feature flag / backlog size
    return true
}
func RegisterLifecycle(lc fx.Lifecycle, p *Poller) {
    lc.Append(fx.Hook{
        OnStart: func(ctx context.Context) error {
            fmt.Println("starting dispatcher...") p.ctx, p.cancel =
            context.WithCancel(context.Background()) go p.Run(p.ctx) return nil
        }, OnStop: func(ctx context.Context) error {
            fmt.Println("stopping poller...") if p.cancel != nil {
                p.cancel()
            }
            return nil
        },
    })
}

Responsibilities:

  • Runs on a fixed interval
  • Checks conditions (DB, Redis, API, flags)
  • Decides whether to create jobs
  • Dispatches jobs via the dispatcher

Key rule:

The poller decides when and what to run — never how.

Poller Execution Model

go
func (p *Poller) Run(ctx context.Context) {
    ticker := time.NewTicker(p.Interval) defer ticker.Stop() for {
        select {
            case <-ctx.Done(): return case <-ticker.C: p.OnTick(ctx)
        }
    }
}

Why This Matters

  • Single execution path
  • No hidden logic
  • Fully overridable for tests
  • Deterministic timing

Lifecycle Management (Uber Fx Friendly)

Each long-lived component follows the same pattern:

go
func Register(lc fx.Lifecycle, svc Service) {
    lc.Append(fx.Hook{
        OnStart: func(ctx context.Context) error {
            svc.Start() return nil
        }, OnStop: func(ctx context.Context) error {
            svc.Stop() return nil
        },
    })
}

This ensures:

  • Clean startup ordering
  • Graceful shutdown
  • No orphan goroutines
  • Controlled resource release

Backpressure and Safety

The dispatcher enforces safety through:

  • Bounded queues
  • Non-blocking dispatch
  • Service-level isolation
go
func (d *serviceDispatcher) Dispatch(job Job) error {
    select {
        case d.queues[job.Service()] <- job: return nil default: return ErrQueueFull
    }
}

This prevents:

  • Memory leaks
  • Goroutine explosion
  • Cascading failures

Testability by Design

The system is designed for deterministic testing.

Poller Test Example

go
p.OnTick = func(ctx context.Context) { tickCount.Add(1)}

Dispatcher Test Example

go
err := dispatcher.Dispatch(job)require.NoError(t, err)

Worker Test Example

go
job.Execute = func(ctx context.Context) error { executed.Store(true) return nil}

No mocks of time.
No sleeps in production logic.
No hidden side effects.

Tests

Operational Benefits

This architecture enables:

  • Horizontal scalability
  • Clear observability boundaries
  • Per-service tuning
  • Graceful degradation
  • Predictable behavior under load

Common Anti-Patterns Avoided

Designing a Scalable Poller–Dispatcher–Worker Architecture in Go

When to Use This Architecture

Ideal for:

  • Background processing systems
  • Scheduled workflows
  • Event polling services
  • ETL task orchestration
  • Microservice sidecars

Conclusion

A Poller–Dispatcher–Worker architecture succeeds or fails based on clarity of responsibility and explicit control flow.

By separating:

  • Decision making (Poller)
  • Routing and scaling (Dispatcher)
  • Execution (Worker)
  • Business logic (Job)

This design is simple enough to start small, yet robust enough to scale into production-grade workloads.

Your Business — On AutoPilot with DDImedia AI Assistant
(Join Our Waitlist)

Visit us at DataDrivenInvestor.com

Join our creator ecosystem here.

DDI Official Telegram Channel: https://t.me/+tafUp6ecEys4YjQ1

Follow us on LinkedIn, Twitter, YouTube, and Facebook.