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:
- Separation of responsibilities
- Explicit control flow
- Safe concurrency
- Graceful startup and shutdown
- Test-first architecture
High-Level Architecture

Component Responsibilities
1. Job: The Atomic Unit of Work
A Job represents a single business action.
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.
type Worker interface {
Start(ctx context.Context) Stop()
}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
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.
/*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
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:
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
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
p.OnTick = func(ctx context.Context) { tickCount.Add(1)}Dispatcher Test Example
err := dispatcher.Dispatch(job)require.NoError(t, err)Worker Test Example
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

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
