trilha

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

Single Responsibility Principle

Perguntas-guia
  • "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")
}
A saída do exemplo entrega um bug de graça

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


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