trilha

Fundamentos de Programação/02 - Código que Presta/Semana 06 - Código Limpo e Refatoração9 min

Code Smells

Perguntas-guia
  • Método longo, classe deus, feature envy, primitive obsession: reconhece os quatro no seu código?
  • Smell é bug? Se não é, por que vale corrigir?
  • Qual smell do seu projeto atual mais te custa tempo por semana?
  • Quando um smell é aceitável e mexer nele é perda de tempo?

Conceito

Um code smell é um sintoma na estrutura do código que sugere um problema mais profundo. A palavra "cheiro" é deliberada: não é prova, é indício — algo que merece investigação, não correção automática.

Smell não é bug. O código funciona, passa nos testes, está em produção. Então por que corrigir?

Porque smell é dívida cujo juro você paga em toda leitura e em toda alteração. Ele não te custa uma falha; te custa tempo, todas as vezes. E o custo é composto: código difícil de entender é difícil de mudar com segurança, e mudanças inseguras produzem os bugs de verdade.

Os quatro do plano, e o sinal concreto de cada um:

Método longo. O problema real não é o comprimento — é misturar níveis de abstração. O sinal mais confiável: você precisou de comentários para separar seções. Cada comentário-seção é o nome de um método que quer nascer.

Classe deus. Sabe demais e faz demais. Sinais, em ordem de confiabilidade:

  1. o nome é genérico — Manager, Helper, Util, Service, Handler
  2. tem campos que não se relacionam entre si
  3. todo commit toca nela (o sinal mais objetivo, e verificável no git log)

Feature envy. Um método usa mais dados de outro objeto do que do próprio. Sinal visual: outro.GetA() + outro.GetB()*outro.GetC(). A correção é mover o método para onde os dados moram — o método está com inveja da classe errada.

Primitive obsession. Usar tipos primitivos para conceitos de domínio: string para CPF, int para dinheiro, float para porcentagem, map[string]string para um objeto. O custo é triplo: validação espalhada por todo lugar que recebe, nenhuma segurança de tipo (dá para somar CPF com telefone), e unidade ambígua (é reais ou centavos? segundos ou milissegundos?).

Quando um smell é aceitável:

  • código que não vai mudar (script de migração já executado, gerado)
  • protótipo com prazo, explicitamente descartável
  • quando a correção introduziria 2. Abstração prematura

O critério de priorização é único e simples: quantas vezes esse arquivo muda? Smell em código que muda toda semana custa toda semana. Smell em código estável não custa nada.

Em Go

Anti-nomes. Go tem uma cultura forte de nomeação, e ela é uma ferramenta de detecção de smell:

Nome Por que é smell
util, helper, common, misc o propósito é "coisas" — viola SRP por definição
manager, service, handler sozinhos não dizem o que fazem
base, abstract Go não tem herança; o nome é importado de outro paradigma
pacote models/, pkg/ organiza por camada técnica, não por domínio

A convenção de Go é nomear o pacote pelo que ele oferece: http, json, bytes, sort. pedido.Criar() lê melhor que services.PedidoService.CreatePedido().

Primitive obsession é mais fácil de cometer em Go porque a linguagem não tem enum e string é barata. E é mais fácil de corrigir do que na média, porque tipo definido custa zero em runtime (1. Tipos e Sistema de Tipos):

type CPF string
type Centavos int64
type IDUsuario int64

Mesma representação, custo zero, e o compilador passa a recusar somar CPF com telefone.

Método longo em Go tem um agravante: if err != nil { return ... } infla qualquer função. A correção não é esconder o erro (nem panic, nem ignorar) — é extrair a operação inteira para uma função que devolve um erro só.

Smells específicos de Go:

  • any / interface{} em assinatura pública — perdeu a tipagem
  • retornar (T, bool, error) — dois mecanismos de "deu errado"
  • context.Context que não é o primeiro parâmetro (ou pior: guardado em struct)
  • naked return (return sem valores, com retornos nomeados) em função longa — o leitor precisa procurar o que está sendo devolvido
  • função com 6 parâmetros — vira struct de configuração ou functional options
  • else depois de return — Go prefera early return

Ferramentas: go vet (correção), golangci-lint (agregador — inclui gocyclo para complexidade ciclomática, dupl para duplicação, revive para estilo), gofmt (não negociável e não configurável, o que elimina toda discussão de formatação).

Respostas às perguntas-guia

1. Método longo, classe deus, feature envy, primitive obsession: reconhece os quatro no seu código?

Conceito: os sinais objetivos são: comentários separando seções (método longo), arquivo que aparece em todo commit (classe deus), cadeia de getters de outro objeto (feature envy), string/int carregando conceito de domínio (primitive obsession).

Em Go, onde procurar primeiro:

  • pacote chamado util ou helper
  • função com mais de ~3 blocos if err != nil seguidos
  • map[string]any atravessando a fronteira de um pacote
  • string como ID, CPF, e-mail, ou unidade monetária

2. Smell é bug? Se não é, por que vale corrigir?

Conceito: não é. Vale porque o custo é o custo de mudança, pago em toda leitura e alteração, e composto (código obscuro produz mudanças inseguras, que produzem bugs).

Em Go: há um custo extra concreto — smell de pacote (util) tende a criar dependências que aumentam o risco de importação circular, que o compilador recusa (4. Grafos). Em Go, arquitetura ruim vira erro de compilação mais cedo que em outras linguagens.

3. Qual smell do seu projeto atual mais te custa tempo por semana?

Conceito: mede-se, não se adivinha: cruze a lista de smells com a frequência de alteração dos arquivos.

Em Go, o comando que responde isso:

git log --format= --name-only --since='6 months' | sort | uniq -c | sort -rn | head -20

Os arquivos do topo são onde corrigir smell paga. Se um deles se chama util.go ou tem 1.000 linhas, você achou o alvo da semana.

4. Quando um smell é aceitável e mexer nele é perda de tempo?

Conceito: quando o código não muda, quando é descartável por decisão, ou quando a "correção" seria abstração prematura.

Em Go: código gerado (//go:generate, protobuf, sqlc, mocks) é cheio de smells e não se toca — ele é regenerado. A convenção é o cabeçalho // Code generated by ... DO NOT EDIT., e o golangci-lint já ignora esses arquivos por padrão.

Trade-offs

Do conceito:

  • Corrigir smell compra custo de mudança menor no futuro; cobra tempo agora e o risco de introduzir bug numa mudança que não precisava acontecer.
  • Ignorar smell é a decisão certa em código estável e a errada em código quente.
  • Catalogar smells dá vocabulário comum e cria o risco de virar checklist aplicado sem julgamento.

Em Go:

  • Tipo definido contra primitive obsession custa zero em runtime e cobra conversões explícitas nas bordas (JSON, SQL) — ruído real.
  • gofmt elimina a discussão de formatação; o custo é que você não escolhe nada.
  • Linter agressivo pega smell de verdade e também acusa muito falso positivo; a configuração do golangci-lint é uma decisão de equipe, não um default.

Exemplo prático

Os quatro smells, e a versão corrigida ao lado:

package main

import (
	"errors"
	"fmt"
	"regexp"
	"strings"
)

// ==================== ANTES: primitive obsession ====================
// Tudo é string e int. Nada impede trocar cpf por telefone, nem somar
// centavos com reais, nem passar um e-mail no lugar do nome.
type PedidoRuim struct {
	ClienteCPF   string
	ClienteEmail string
	Total        int // reais? centavos? ninguém sabe
	Desconto     int
}

func criaPedidoRuim(cpf, email string, total, desc int) (*PedidoRuim, error) {
	if len(cpf) != 11 { // validação espalhada por todo chamador
		return nil, errors.New("cpf inválido")
	}
	if !strings.Contains(email, "@") {
		return nil, errors.New("email inválido")
	}
	return &PedidoRuim{cpf, email, total, desc}, nil
}

// ==================== DEPOIS: tipos de domínio ====================
type CPF string
type Email string
type Centavos int64 // a unidade está NO TIPO

var reEmail = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)

func NovoCPF(s string) (CPF, error) {
	limpo := strings.Map(func(r rune) rune {
		if r >= '0' && r <= '9' {
			return r
		}
		return -1
	}, s)
	if len(limpo) != 11 {
		return "", fmt.Errorf("cpf %q: precisa de 11 dígitos, tem %d", s, len(limpo))
	}
	return CPF(limpo), nil // validado UMA vez, na fronteira
}

func NovoEmail(s string) (Email, error) {
	if !reEmail.MatchString(s) {
		return "", fmt.Errorf("email %q inválido", s)
	}
	return Email(strings.ToLower(s)), nil
}

func (c Centavos) Reais() float64  { return float64(c) / 100 }
func (c Centavos) String() string  { return fmt.Sprintf("R$ %.2f", c.Reais()) }

type Cliente struct {
	CPF   CPF
	Email Email
}

type Pedido struct {
	Cliente  Cliente
	Total    Centavos
	Desconto Centavos
}

// ==================== feature envy: ANTES ====================
// Este método vive em Relatorio mas só usa dados de Pedido.
type RelatorioRuim struct{}

func (RelatorioRuim) ValorFinal(p Pedido) Centavos {
	return p.Total - p.Desconto // inveja: só toca dados de Pedido
}

// ==================== feature envy: DEPOIS ====================
// O método mudou de casa: agora mora onde os dados moram.
func (p Pedido) ValorFinal() Centavos { return p.Total - p.Desconto }

// ==================== método longo: ANTES ====================
func processaRuim(cpfStr, emailStr string, total, desc int) (string, error) {
	// valida cliente
	if len(cpfStr) != 11 {
		return "", errors.New("cpf inválido")
	}
	if !strings.Contains(emailStr, "@") {
		return "", errors.New("email inválido")
	}
	// calcula
	final := total - desc
	if final < 0 {
		final = 0
	}
	// formata
	return fmt.Sprintf("cliente %s paga R$ %.2f", cpfStr, float64(final)/100), nil
}

// ==================== método longo: DEPOIS ====================
// Cada comentário-seção virou uma função com nome. O corpo lê como a lista
// de passos que o comentário descrevia.
func processa(cpfStr, emailStr string, total, desc Centavos) (string, error) {
	cliente, err := montaCliente(cpfStr, emailStr)
	if err != nil {
		return "", fmt.Errorf("processa pedido: %w", err)
	}
	p := Pedido{Cliente: cliente, Total: total, Desconto: desc}
	return p.Descreve(), nil
}

func montaCliente(cpfStr, emailStr string) (Cliente, error) {
	cpf, err := NovoCPF(cpfStr)
	if err != nil {
		return Cliente{}, err
	}
	email, err := NovoEmail(emailStr)
	if err != nil {
		return Cliente{}, err
	}
	return Cliente{cpf, email}, nil
}

func (p Pedido) Descreve() string {
	return fmt.Sprintf("cliente %s paga %s", p.Cliente.CPF, p.ValorFinal())
}

func main() {
	// primitive obsession: o compilador não te protege de nada
	ruim, _ := criaPedidoRuim("12345678901", "a@b.com", 10000, 500)
	fmt.Printf("ruim: total=%d desconto=%d (reais? centavos?)\n", ruim.Total, ruim.Desconto)

	// tipos de domínio: a unidade e a validação estão no tipo
	s, err := processa("123.456.789-01", "ANA@Exemplo.com", 10000, 500)
	fmt.Println("bom: ", s, err)

	_, err = processa("123", "ana@exemplo.com", 100, 0)
	fmt.Println("cpf curto:", err)
	_, err = processa("12345678901", "sem-arroba", 100, 0)
	fmt.Println("email ruim:", err)

	// o compilador agora recusa a troca de conceitos
	var cpf CPF = "12345678901"
	var email Email = "a@b.com"
	// cpf = email  // NÃO compila: cannot use email (Email) as CPF
	fmt.Println("tipos distintos:", cpf != "", email != "")

	// feature envy corrigida: o método mora onde os dados moram
	p := Pedido{Total: 10000, Desconto: 1550}
	fmt.Println("valor final:", p.ValorFinal(), "| Stringer:", p.Total)
}

Repare no que a correção da primitive obsession entregou de graça: a validação passou a existir uma vez (em NovoCPF), a unidade ficou documentada no tipo (Centavos), e a formatação virou responsabilidade do próprio valor (String()). Três smells caíram com uma mudança.

Relacionado


Parte de Semana 06 - Código Limpo e Refatoração · 00 - MOC Fundamentos de Programação

Buscar

Busca por título, seção e texto das notas