Fundamentos de Programação/02 - Código que Presta/Semana 06 - Código Limpo e Refatoração9 min
Single Responsibility Principle
- "Uma razão para mudar" é sobre quem pede a mudança, não sobre quantidade de código — concorda?
- Como distinguir uma classe que faz duas coisas de uma que faz uma coisa em dois passos?
- Qual o risco de aplicar SRP demais e virar sopa de classes anêmicas?
- Que arquivo seu muda por dois motivos diferentes e independentes?
Conceito
O enunciado popular — "uma classe deve fazer uma coisa só" — é vago ao ponto de ser inútil. Quantas coisas é uma coisa?
A formulação precisa de Robert Martin é outra:
Um módulo deve ser responsável por um, e apenas um, ator.
Ou seja: a razão para mudar vem de quem pede a mudança. Não do número de linhas, não do número de métodos.
Isso desfaz a confusão de imediato:
| Situação | Atores | SRP? |
|---|---|---|
| Classe que valida CPF, calcula imposto e formata relatório | compliance, financeiro, negócio → 3 | violado |
| Classe que faz uma coisa em dez passos | um | ok — é longa, não é violação |
| Classe que persiste e valida regra de negócio | infra, negócio → 2 | violado |
O teste prático: liste quem pede mudanças nesse arquivo. Se são grupos diferentes, com prioridades diferentes e prazos diferentes, você tem um arquivo que vai ser alterado por dois motivos que não conversam — e conflitos, regressões e revisões confusas nascem daí.
O sintoma objetivo está no git log: um arquivo que muda por motivos não
relacionados. "Ajusta alíquota de ICMS" e "muda formato do CSV" no mesmo arquivo é o
sinal, e ele é auditável.
O risco de aplicar demais é real e menos falado: você quebra tudo em classes de um método, elas ficam anêmicas (só dados, sem comportamento), e a lógica migra para "orquestradores" que sabem de todas. Você trocou uma classe deus por uma sopa de classes mais um coordenador deus — e agora a lógica está espalhada em vez de concentrada, que é pior para entender.
O ponto de equilíbrio: coesão. Um módulo coeso é um em que os elementos mudam juntos. SRP é a formulação negativa disso — separe o que muda por motivos diferentes; mantenha junto o que muda junto.
Em Go
A unidade natural de responsabilidade em Go é o pacote e a função, não a classe. Go não tem classes, e a cultura favorece funções livres sobre métodos — menos estado significa menos razões para mudar.
Então SRP em Go se lê: "esse pacote tem um propósito que eu consigo nomear numa frase?"
E aqui a nomeação denuncia a violação: se o melhor nome que você acha é util, helper,
common ou shared, o propósito é "coisas". util viola SRP por definição — é o
pacote cujo critério de entrada é não ter critério.
Compare como a stdlib faz:
| Pacote | Propósito numa frase |
|---|---|
strings |
operações sobre strings |
sort |
ordenar |
net/http |
falar HTTP |
encoding/json |
converter entre Go e JSON |
Nenhum deles é net/httputil... exceto que net/http/httputil existe, e é
exatamente onde foram as coisas que não couberam em nenhuma frase. Vale como exemplo de
que até a stdlib acumula sobras.
Onde Go ajuda: o compilador impõe grafo de importação acíclico (4. Grafos). Um pacote que faz coisas demais tende a precisar importar de todo lado, e cedo ou tarde alguém importa de volta — e aí é erro de compilação, não decisão de arquitetura. Go transforma parte da erosão arquitetural em erro de build.
Onde Go atrapalha: visibilidade é por pacote (1. Encapsulamento), então dividir responsabilidades frequentemente obriga a criar mais pacotes do que você criaria classes em Java. Isso empurra na direção certa e é mais trabalhoso.
O comando que encontra os candidatos:
git log --format= --name-only --since='6 months ago' \
| grep '\.go$' | sort | uniq -c | sort -rn | head -20
Arquivo no topo e grande e com mudanças de assuntos diferentes = classe deus.
Respostas às perguntas-guia
1. "Uma razão para mudar" é sobre quem pede a mudança, não sobre quantidade de código — concorda?
Conceito: sim, e essa é a formulação que torna o princípio aplicável. Tamanho é um indício fraco; ator é o critério.
Em Go: traduzindo para pacotes — se dois times diferentes abrem PR no mesmo pacote por motivos não relacionados, o pacote tem dois donos e nenhum.
2. Como distinguir uma classe que faz duas coisas de uma que faz uma coisa em dois passos?
Conceito: pergunte se os dois passos mudariam pelo mesmo pedido. "Buscar e formatar" são dois passos de uma coisa se sempre mudam juntos; são duas coisas se o formato muda sem a busca mudar.
Em Go: o cheiro concreto é a função que recebe dados e faz I/O. CalculaTotal puro
e SalvaPedido separados testam melhor e mudam por motivos diferentes — a regra muda por
pedido do negócio, o I/O muda por pedido da infra.
3. Qual o risco de aplicar SRP demais e virar sopa de classes anêmicas?
Conceito: a lógica migra para orquestradores, e você troca concentração por dispersão. Coesão é o contrapeso: mantenha junto o que muda junto.
Em Go: o sintoma é o pacote com um arquivo por tipo, cada tipo com um método, e um
pacote service que importa todos e contém a lógica real. Nesse ponto o service é a
classe deus, com passos extras.
4. Que arquivo seu muda por dois motivos diferentes e independentes?
Conceito: rode o git log por arquivo e leia as mensagens de commit. Se elas
pertencem a assuntos distintos, achou.
Em Go: o suspeito quase universal é o handler/controller que valida entrada, aplica
regra de negócio, monta SQL e serializa resposta — quatro atores num arquivo. A separação
mínima: handler (HTTP), serviço (regra), repositório (persistência).
Trade-offs
Do conceito:
- Separar por ator compra mudanças isoladas e revisões claras; cobra mais arquivos, mais indireção e mais navegação.
- Manter junto compra contexto local e cobra acoplamento entre atores.
- SRP levado ao extremo produz dispersão, que é um problema diferente e não melhor.
Em Go:
- Pacote como unidade compra encapsulamento real e cobra criar pacotes para separar o que em Java seriam duas classes privadas.
- Funções livres em vez de métodos reduzem estado e razões para mudar, e cobram passar mais parâmetros.
- Grafo acíclico imposto pelo compilador transforma erosão arquitetural em erro de build — barato quando você está certo, e um obstáculo quando você quer só prototipar.
Exemplo prático
Um handler com quatro atores, e a separação por ator:
package main
import (
"errors"
"fmt"
"strings"
)
// ==================== ANTES: quatro atores num tipo ====================
// muda por pedido de: compliance (validação), financeiro (regra de desconto),
// infra (persistência) e produto (formato da resposta).
type PedidoHandlerRuim struct{ banco map[int]string }
func (h *PedidoHandlerRuim) Criar(entrada string) (string, error) {
// ator 1: compliance — formato da entrada
partes := strings.Split(entrada, ";")
if len(partes) != 2 {
return "", errors.New("entrada inválida")
}
// ator 2: financeiro — regra de desconto
total := len(partes[1]) * 1000
if total > 5000 {
total = total * 90 / 100
}
// ator 3: infra — persistência
id := len(h.banco) + 1
h.banco[id] = fmt.Sprintf("%s:%d", partes[0], total)
// ator 4: produto — formato da saída
return fmt.Sprintf(`{"id":%d,"total":%d}`, id, total), nil
}
// ==================== DEPOIS: um ator por unidade ====================
// --- ator: compliance. Muda quando o contrato de entrada muda. ---
type Entrada struct {
Cliente string
Itens []string
}
func ParseEntrada(s string) (Entrada, error) {
partes := strings.Split(s, ";")
if len(partes) != 2 || partes[0] == "" {
return Entrada{}, fmt.Errorf("entrada %q: esperado 'cliente;itens'", s)
}
return Entrada{Cliente: partes[0], Itens: strings.Split(partes[1], ",")}, nil
}
// --- ator: financeiro. Puro, testável sem nada, muda por pedido do negócio. ---
type Centavos int64
const precoItem Centavos = 1000
func CalculaTotal(itens int) Centavos {
total := Centavos(itens) * precoItem
if total > 5000 {
total = total * 90 / 100 // desconto por volume
}
return total
}
// --- ator: infra. Interface no CONSUMIDOR (ver Dependency Inversion). ---
type repositorio interface {
Salva(cliente string, total Centavos) (int, error)
}
type repoMemoria struct{ dados map[int]string }
func (r *repoMemoria) Salva(cliente string, total Centavos) (int, error) {
id := len(r.dados) + 1
r.dados[id] = fmt.Sprintf("%s:%d", cliente, total)
return id, nil
}
// --- ator: produto. Muda quando o formato da resposta muda. ---
type Resposta struct {
ID int
Total Centavos
}
func (r Resposta) JSON() string {
return fmt.Sprintf(`{"id":%d,"total":%d}`, r.ID, r.Total)
}
// --- o orquestrador: só sequencia. Não contém regra de ninguém. ---
type CriarPedido struct{ repo repositorio }
func (c CriarPedido) Executa(entrada string) (Resposta, error) {
e, err := ParseEntrada(entrada)
if err != nil {
return Resposta{}, err
}
total := CalculaTotal(len(e.Itens))
id, err := c.repo.Salva(e.Cliente, total)
if err != nil {
return Resposta{}, fmt.Errorf("salvando pedido: %w", err)
}
return Resposta{ID: id, Total: total}, nil
}
func main() {
ruim := &PedidoHandlerRuim{banco: map[int]string{}}
out, _ := ruim.Criar("ana;a,b,c,d,e,f")
fmt.Println("antes: ", out)
uc := CriarPedido{repo: &repoMemoria{dados: map[int]string{}}}
r, _ := uc.Executa("ana;a,b,c,d,e,f")
fmt.Println("depois:", r.JSON())
// a regra do financeiro agora é testável sozinha, sem banco e sem parser
fmt.Println("--- regra isolada ---")
for _, n := range []int{1, 5, 6, 10} {
fmt.Printf("%2d itens -> %d centavos\n", n, CalculaTotal(n))
}
// e o parser, sozinho
_, err := ParseEntrada("sem-ponto-e-virgula")
fmt.Println("parser isolado:", err)
// trocar a infra não toca em nenhuma regra
uc2 := CriarPedido{repo: repoQueFalha{}}
_, err = uc2.Executa("ana;a")
fmt.Println("infra trocada: ", err)
}
type repoQueFalha struct{}
func (repoQueFalha) Salva(string, Centavos) (int, error) {
return 0, errors.New("banco indisponível")
}
antes: total=9900 e depois: total=5400. Não é o refactor mudando comportamento —
é um bug que estava escondido na versão emaranhada.
A versão ruim calcula len(partes[1]) * 1000: ela conta caracteres
("a,b,c,d,e,f" tem 11) em vez de itens (6). O bug sobreviveu porque a regra do
financeiro estava soterrada entre parsing, persistência e formatação — ninguém olhava
aquela linha como "a regra de preço".
Extraída, CalculaTotal(itens int) Centavos é uma função pura de uma linha. O bug fica
impossível de não ver, e a tabela de 1, 5, 6, 10 itens na saída é o teste que
ninguém teria escrito para o handler original.
Esse é o argumento de SRP que vale mais que o estético: regra de negócio escondida entre I/O não é revisada.
O ganho concreto, então: CalculaTotal é uma função pura de int para Centavos —
testável em uma linha, sem banco, sem parser, sem HTTP. E quando o financeiro pedir para
mudar a faixa de desconto, o diff toca um arquivo.
Relacionado
- 1. Code Smells — classe deus é a violação mais visível
- 3. Dependency Inversion Principle — separar por ator exige inverter a dependência de infra
- 4. Refatorações Nomeadas — extrair classe é o movimento que aplica SRP
- 1. Encapsulamento — semana 5, em Go a fronteira é o pacote
Parte de Semana 06 - Código Limpo e Refatoração · 00 - MOC Fundamentos de Programação