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:

  1. Difficulty adding new steps: every new field or validation requires modifying the whole flow, increasing the risk of bugs.
  2. Validation rules mixed with data collection: it’s hard to isolate or reuse validations.
  3. Rigid, inflexible flow: not simple to reorder, skip, or repeat steps without rewriting code.
  4. No organized retry mechanism: any error terminates the program, no fine-grained retry control.
  5. 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 → Document always 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/if loops 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

  1. Shared Context – structure with the collected fields.
  2. Handler Interface – each step implements Execute and SetNext.
  3. BaseHandler – provides CallNext to 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:

  1. Asking the user for input.
  2. Applying all validations specific to that field.
  3. Managing retry policy locally (how many attempts, what messages to show).
  4. 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
}