A practical tutorial showing how to transform a monolithic data-collection flow into a modular, testable, and extensible design.
Let’s say you’re building a CLI that interviews the user, asking for name, birth date, and document number. The initial version works, but soon becomes a hard-to-maintain block of code:
- how do you add validations without increasing complexity?
- how do you implement confirmation and retry logic for each input?
1. Initial Scenario
package main
import (
"fmt"
"log"
"strings"
"time"
"github.com/AlecAivazis/survey/v2"
)
func main() {
var name, dateStr, document string
if err := survey.AskOne(&survey.Input{Message: "Name:"}, &name); err != nil {
log.Fatal(err)
}
if len(strings.TrimSpace(name)) < 2 {
log.Fatal("Name must have at least 2 characters")
}
if err := survey.AskOne(&survey.Input{Message: "Birth date (MM/DD/YYYY):"}, &dateStr); err != nil {
log.Fatal(err)
}
birthDate, err := time.Parse("01/02/2006", dateStr)
if err != nil || birthDate.After(time.Now()) {
log.Fatal("Invalid birth date")
}
if err := survey.AskOne(&survey.Input{Message: "Document:"}, &document); err != nil {
log.Fatal(err)
}
if len(document) == 0 {
log.Fatal("Document cannot be empty")
}
fmt.Printf("Collected: %s | %s | %s\n", name, birthDate.Format("2006-01-02"), document)
}
Here we see some problems:
- Difficulty adding new steps: every new field or validation requires modifying the whole flow, increasing the risk of bugs.
- Validation rules mixed with data collection: it’s hard to isolate or reuse validations.
- Rigid, inflexible flow: not simple to reorder, skip, or repeat steps without rewriting code.
- No organized retry mechanism: any error terminates the program, no fine-grained retry control.
- Low clarity about the flow: as code grows, it becomes harder to understand the order and logic of each step.
2. Reasoning for Decoupling
We want to split the process into independent steps, each responsible for: • Asking; • Validating; • Deciding whether to retry or move forward.
The Pipeline Pattern solves this by chaining handlers that share a mutable context.
2.1 Why this pattern?
When you need to execute all steps in a fixed order – but want to keep each one small, testable, and with its own business rules – the Pipeline Pattern is a natural fit. It models the flow as a chain of independent handlers that receive a shared context, perform their work, and then trigger the next step.
Main reasons to adopt it here:
- Guaranteed sequence – the pipeline ensures
Name → BirthDate → Documentalways happens in that order. - Low coupling – inserting, removing, or reordering steps only requires adjusting how they’re chained together at pipeline assembly.
- Localized retry – each handler controls its own retry policy without scattering
for/ifloops throughout the main code. - Single responsibility focus – validation, parsing, and user feedback live together in the right step.
- Testability – small handlers can be tested with mocks without running the whole CLI.
2.2 Essential Components
- Shared Context – structure with the collected fields.
HandlerInterface – each step implementsExecuteandSetNext.BaseHandler– providesCallNextto move forward in the pipeline.
3. Ok, but how do we do this?
The key is to treat each step as an autonomous action. Name, date, and document have completely different validation criteria and sometimes different retry counts. Therefore, each step gets its own handler, responsible for:
- Asking the user for input.
- Applying all validations specific to that field.
- Managing retry policy locally (how many attempts, what messages to show).
- If successful, filling the context and calling the next step.
This separation ensures clear rules, small code, and the ability to test each piece in isolation.
3.1 Base Implementation
First, we need a structure that lets each piece connect like a “pipeline.” At first glance, this resembles the Chain of Responsibility pattern, but we are not applying CoR here. In CoR, each step chooses whether to handle or delegate; in our case, all steps are mandatory and executed sequentially – a typical characteristic of the Pipeline Pattern.
// UserContext keeps the state between steps
type UserContext struct {
Name string
BirthDate time.Time
Document string
}
// Common interface
type Handler interface {
Execute(ctx *UserContext) error
SetNext(next Handler) Handler
}
// Base to link steps
type BaseHandler struct { next Handler }
func (b *BaseHandler) SetNext(next Handler) Handler { b.next = next; return next }
func (b *BaseHandler) CallNext(ctx *UserContext) error {
if b.next != nil { return b.next.Execute(ctx) }
return nil
}
3.2 NameHandler Example
type NameHandler struct {
BaseHandler
maxRetries int
}
func (h *NameHandler) Execute(ctx *UserContext) error {
for i := 0; i < h.maxRetries; i++ {
name, _ := askForInput("Enter your name: ")
fmt.Printf("Attempt %d\n", i+1)
if len(strings.TrimSpace(name)) < 2 {
fmt.Println("Name must have at least 2 characters")
continue
}
ctx.Name = name
break
}
if ctx.Name == "" {
return errors.New("maximum retries reached for name input")
}
return h.CallNext(ctx)
}
3.3 BirthDateHandler
type BirthDateHandler struct {
BaseHandler
}
func (h *BirthDateHandler) Execute(ctx *UserContext) error {
dateStr, _ := askForInput("Enter your birth date (MM/DD/YYYY): ")
birthDate, err := time.Parse("01/02/2006", dateStr)
if err != nil || birthDate.After(time.Now()) {
return fmt.Errorf("invalid date")
}
ctx.BirthDate = birthDate
return h.CallNext(ctx)
}
3.4 DocumentHandler
type DocumentHandler struct {
BaseHandler
}
func (h *DocumentHandler) Execute(ctx *UserContext) error {
doc, _ := askForInput("Enter your SSN: ")
if len(doc) == 0 {
return errors.New("invalid SSN")
}
ctx.Document = doc
return h.CallNext(ctx)
}
3.5 Pipeline Assembly
func main() {
ctx := &UserContext{}
name := &NameHandler{maxRetries: 3}
birth := &BirthDateHandler{}
doc := &DocumentHandler{}
name.SetNext(birth).SetNext(doc)
if err := name.Execute(ctx); err != nil {
log.Fatal(err)
}
fmt.Printf("Collected data: %+v\n", ctx)
}
4. Conclusion
Refactoring to the Pipeline Pattern transformed a fragile script into a robust, modular flow ready to grow.
Complete code
package main
import (
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/AlecAivazis/survey/v2"
)
type UserContext struct {
Name string
BirthDate time.Time
Document string
}
type Handler interface {
Execute(ctx *UserContext) error
SetNext(next Handler) Handler
}
type BaseHandler struct {
next Handler
}
func (h *BaseHandler) SetNext(next Handler) Handler {
h.next = next
return next
}
func (h *BaseHandler) CallNext(ctx *UserContext) error {
if h.next != nil {
return h.next.Execute(ctx)
}
return nil
}
type NameHandler struct {
BaseHandler
maxRetries int
}
func (h *NameHandler) Execute(ctx *UserContext) error {
for i := 0; i < h.maxRetries; i++ {
name, _ := askForInput("Enter your name: ")
fmt.Printf("Attempt %d\n", i+1)
if len(strings.TrimSpace(name)) < 2 {
fmt.Println("Name must have at least 2 characters")
continue
}
ctx.Name = name
break
}
if ctx.Name == "" {
return errors.New("maximum retries reached for name input")
}
return h.CallNext(ctx)
}
type BirthDateHandler struct {
BaseHandler
}
func (h *BirthDateHandler) Execute(ctx *UserContext) error {
dateStr, _ := askForInput("Enter your birth date (MM/DD/YYYY): ")
birthDate, err := time.Parse("01/02/2006", dateStr)
if err != nil || birthDate.After(time.Now()) {
return fmt.Errorf("invalid date")
}
ctx.BirthDate = birthDate
return h.CallNext(ctx)
}
type DocumentHandler struct {
BaseHandler
}
func (h *DocumentHandler) Execute(ctx *UserContext) error {
doc, _ := askForInput("Enter your SSN: ")
if len(doc) == 0 {
return errors.New("invalid SSN")
}
ctx.Document = doc
return h.CallNext(ctx)
}
func main() {
ctx := &UserContext{}
nameHandler := &NameHandler{maxRetries: 3}
birthHandler := &BirthDateHandler{}
docHandler := &DocumentHandler{}
nameHandler.SetNext(birthHandler).SetNext(docHandler)
if err := nameHandler.Execute(ctx); err != nil {
log.Fatal("Pipeline error:", err)
}
fmt.Printf("Collected data: %+v\n", ctx)
}
func askForInput(prompt string) (string, error) {
var result string
q := &survey.Input{Message: prompt}
if err := survey.AskOne(q, &result); err != nil {
return "", err
}
return result, nil
}