Um tutorial prático mostrando como transformar um fluxo de coleta de dados monolítico em um design modular, testável e extensível.


Digamos que você esteja construindo um CLI que entrevista o usuário, pedindo nome, data de nascimento e número de documento. A versão inicial funciona, mas logo se torna um bloco de código difícil de manter:

  • como você adiciona validações sem aumentar a complexidade?
  • como você implementa lógica de confirmação e repetição para cada entrada?

1. Cenário Inicial

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: "Nome:"}, &name); err != nil {
        log.Fatal(err)
    }

    if len(strings.TrimSpace(name)) < 2 {
        log.Fatal("O nome deve ter pelo menos 2 caracteres")
    }

    if err := survey.AskOne(&survey.Input{Message: "Data de nascimento (DD/MM/AAAA):"}, &dateStr); err != nil {
        log.Fatal(err)
    }

    birthDate, err := time.Parse("02/01/2006", dateStr)
    if err != nil || birthDate.After(time.Now()) {
        log.Fatal("Data de nascimento inválida")
    }

    if err := survey.AskOne(&survey.Input{Message: "Documento:"}, &document); err != nil {
        log.Fatal(err)
    }

    if len(document) == 0 {
        log.Fatal("O documento não pode estar vazio")
    }

    fmt.Printf("Coletados: %s | %s | %s\n", name, birthDate.Format("2006-01-02"), document)
}

Aqui vemos alguns problemas:

  1. Dificuldade em adicionar novas etapas: cada novo campo ou validação requer modificar todo o fluxo, aumentando o risco de bugs.
  2. Regras de validação misturadas com coleta de dados: é difícil isolar ou reutilizar validações.
  3. Fluxo rígido e inflexível: não é simples reordenar, pular ou repetir etapas sem reescrever código.
  4. Sem mecanismo de repetição organizado: qualquer erro termina o programa, sem controle refinado de repetição.
  5. Baixa clareza sobre o fluxo: à medida que o código cresce, torna-se mais difícil entender a ordem e lógica de cada etapa.

2. Raciocínio para Desacoplamento

Queremos dividir o processo em etapas independentes, cada uma responsável por: • Perguntar; • Validar; • Decidir se repete ou avança.

O Padrão Pipeline resolve isso encadeando handlers que compartilham um contexto mutável.

2.1 Por que este padrão?

Quando você precisa executar todas as etapas em uma ordem fixa – mas quer manter cada uma pequena, testável e com suas próprias regras de negócio – o Padrão Pipeline é uma escolha natural. Ele modela o fluxo como uma cadeia de handlers independentes que recebem um contexto compartilhado, realizam seu trabalho e então acionam a próxima etapa.

Principais razões para adotá-lo aqui:

  • Sequência garantida – o pipeline garante que Nome → DataNascimento → Documento sempre aconteça nessa ordem.
  • Baixo acoplamento – inserir, remover ou reordenar etapas requer apenas ajustar como elas são encadeadas na montagem do pipeline.
  • Repetição localizada – cada handler controla sua própria política de repetição sem espalhar loops for/if por todo o código principal.
  • Foco em responsabilidade única – validação, análise e feedback ao usuário vivem juntos na etapa correta.
  • Testabilidade – pequenos handlers podem ser testados com mocks sem executar todo o CLI.

2.2 Componentes Essenciais

  1. Contexto Compartilhado – estrutura com os campos coletados.
  2. Interface Handler – cada etapa implementa Execute e SetNext.
  3. BaseHandler – fornece CallNext para avançar no pipeline.

3. Ok, mas como fazemos isso?

A chave é tratar cada etapa como uma ação autônoma. Nome, data e documento têm critérios de validação completamente diferentes e às vezes contagens de repetição diferentes. Portanto, cada etapa recebe seu próprio handler, responsável por:

  1. Perguntar ao usuário pela entrada.
  2. Aplicar todas as validações específicas para esse campo.
  3. Gerenciar política de repetição localmente (quantas tentativas, quais mensagens mostrar).
  4. Se bem-sucedido, preencher o contexto e chamar a próxima etapa.

Esta separação garante regras claras, código pequeno e a capacidade de testar cada peça isoladamente.

3.1 Implementação Base

Primeiro, precisamos de uma estrutura que permita que cada peça se conecte como um “pipeline”. À primeira vista, isso se assemelha ao padrão Chain of Responsibility, mas não estamos aplicando CoR aqui. No CoR, cada etapa escolhe se trata ou delega; no nosso caso, todas as etapas são obrigatórias e executadas sequencialmente – uma característica típica do Padrão Pipeline.

// UserContext mantém o estado entre etapas
type UserContext struct {
    Nome         string
    DataNascimento time.Time
    Documento    string
}

// Interface comum
type Handler interface {
    Execute(ctx *UserContext) error
    SetNext(next Handler) Handler
}

// Base para vincular etapas
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 Exemplo de NomeHandler

type NomeHandler struct {
    BaseHandler
    maxRetries int
}

func (h *NomeHandler) Execute(ctx *UserContext) error {
    for i := 0; i < h.maxRetries; i++ {
        nome, _ := askForInput("Digite seu nome: ")
        fmt.Printf("Tentativa %d\n", i+1)
        if len(strings.TrimSpace(nome)) < 2 {
            fmt.Println("O nome deve ter pelo menos 2 caracteres")
            continue
        }
        ctx.Nome = nome
        break
    }
    if ctx.Nome == "" {
        return errors.New("número máximo de tentativas alcançado para entrada de nome")
    }
    return h.CallNext(ctx)
}

3.3 DataNascimentoHandler

type DataNascimentoHandler struct {
    BaseHandler
}

func (h *DataNascimentoHandler) Execute(ctx *UserContext) error {
    dataStr, _ := askForInput("Digite sua data de nascimento (DD/MM/AAAA): ")
    dataNascimento, err := time.Parse("02/01/2006", dataStr)
    if err != nil || dataNascimento.After(time.Now()) {
        return fmt.Errorf("data inválida")
    }
    ctx.DataNascimento = dataNascimento
    return h.CallNext(ctx)
}

3.4 DocumentoHandler

type DocumentoHandler struct {
    BaseHandler
}

func (h *DocumentoHandler) Execute(ctx *UserContext) error {
    doc, _ := askForInput("Digite seu CPF: ")
    if len(doc) == 0 {
        return errors.New("CPF inválido")
    }
    ctx.Documento = doc
    return h.CallNext(ctx)
}

3.5 Montagem do Pipeline

func main() {
    ctx := &UserContext{}
    nome := &NomeHandler{maxRetries: 3}
    data := &DataNascimentoHandler{}
    doc := &DocumentoHandler{}

    nome.SetNext(data).SetNext(doc)

    if err := nome.Execute(ctx); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Dados coletados: %+v\n", ctx)
}

4. Conclusão

Refatorar para o Padrão Pipeline transformou um script frágil em um fluxo robusto e modular pronto para crescer.

Código completo

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
}