Design Patterns que todo dev deveria conhecer

Se você acha que não usa design patterns, provavelmente está usando — só que sem saber.
O ponto aqui não é “dar nome bonito” pra código, é ganhar repertório. Quando você reconhece um problema recorrente, você para de resolver no improviso e começa a escolher uma solução que já foi testada por muita gente em muito projeto.
Aquele addEventListener no JavaScript é um Observer. Aquele switch que decide qual classe instanciar é um Factory. Aquele serviço que “só pode existir um” e você registra como singleton no container… adivinha.
Design patterns não são enfeite acadêmico.
São formas recorrentes de organizar código quando o sistema cresce e começa a exigir mudanças frequentes.
E a parte mais importante: pattern não é “coisa pra colocar”. Pattern é coisa que aparece quando um problema existe de verdade.
O que são design patterns de verdade?
Em 1994 saiu o livro Design Patterns: Elements of Reusable Object-Oriented Software, do grupo que ficou conhecido como “Gang of Four”.
A proposta era simples: documentar soluções reutilizáveis para problemas recorrentes em software orientado a objetos.
Não era regra, framework, obrigação moral, nem “a forma correta” de programar. Era um catálogo. Um jeito de dizer: “quando você bater nesse tipo de problema, essas soluções costumam funcionar bem”.
Eles listaram 23 padrões. Só que, no mundo real, você raramente precisa dos 23. Alguns poucos cobrem a maior parte das dores comuns em aplicações de verdade.
Aqui vamos nos 7 que aparecem o tempo todo: Singleton, Builder, Factory, Facade, Adapter, Strategy e Observer.
A estrutura do artigo vai ser sempre a mesma aqui: qual problema aparece, como você reconhece, qual solução o pattern propõe, como implementar e quais cuidados tomar.
Use com sabedoria.
1) Singleton — simples, útil e perigoso
O problema que aparece
Em certos cenários, você precisa garantir que exista uma única instância de um serviço durante toda a execução da aplicação. Isso acontece quando:
-
você tem estado compartilhado que precisa ser consistente (ex.: cache centralizado),
-
você tem recurso caro que não quer instanciar várias vezes (ex.: pool/gerenciador de conexões),
-
ou você quer ponto único de coordenação (ex.: logging, telemetry, config snapshot).
Como reconhecer (sintomas)
Você começa a ver coisas do tipo:
-
“se alguém instanciar isso duas vezes vai dar problema”
-
“isso precisa ser o mesmo em qualquer lugar do sistema”
-
“todo mundo precisa usar o mesmo objeto”
A solução
Singleton garante uma instância global acessível, controlando a criação do objeto.
Implementação básica (C#)
A versão clássica (lazy) seria:
public sealed class Logger
{
private static Logger? _instance;
private static readonly object _lock = new();
private Logger() { }
public static Logger Instance
{
get
{
if (_instance is not null) return _instance;
lock (_lock)
{
_instance ??= new Logger();
return _instance;
}
}
}
public void Log(string message)
{
Console.WriteLine($"[{DateTime.Now:O}] {message}");
}
}Versão mais segura e idiomática (Lazy<T>)
Se você realmente for fazer Singleton manual, isso é melhor:
public sealed class Logger
{
private static readonly Lazy<Logger> _instance =
new(() => new Logger());
private Logger() { }
public static Logger Instance => _instance.Value;
public void Log(string message)
=> Console.WriteLine($"[{DateTime.Now:O}] {message}");
}O “pulo do gato”: DI container já faz isso
Na prática, em .NET moderno, o “Singleton que presta” é:
services.AddSingleton<ILogger, Logger>();
E pronto. Você ganha controle de ciclo de vida, testabilidade e substituição fácil.
Trade-offs e armadilhas
Singleton vira problema quando é usado como estado global mutável. Isso cria:
-
acoplamento escondido (qualquer lugar mexe),
-
testes difíceis (estado “vaza” entre testes),
-
dependências implícitas (você não vê no construtor).
Quando usar / quando evitar
Use quando houver necessidade real de instância única e preferencialmente via DI. Evite quando for só “comodidade” (“ah, é mais fácil acessar global”).
2) Builder — quando o construtor começa a ficar grande demais
O problema que aparece
Você tem um objeto que precisa ser criado com muitos parâmetros, vários opcionais e combinações. Isso gera:
-
construtores gigantes,
-
ordem de parâmetros difícil de lembrar,
-
chamadas ilegíveis,
-
e bugs por “troquei o parâmetro sem querer”.
Como reconhecer (sintomas)
Você vê chamadas assim e sente dor física:
var pedido = new Pedido(
"Rafael",
"Rua X, 123",
"cartão",
"DESCONTO10",
"sem cebola",
items,
DateTime.Now.AddDays(3),
15.90m);Ou então você começa a criar overload atrás de overload e a classe vira um mini caos.
A solução
Builder separa a construção da representação final e permite montar o objeto passo a passo, com nomes claros.
Implementação prática
Vamos supor esse Pedido:
public class Pedido
{
public string Cliente { get; }
public string Endereco { get; }
public string Pagamento { get; }
public string? Cupom { get; }
public string? Observacao { get; }
public IReadOnlyList<Item> Itens { get; }
public DateTime EntregaEm { get; }
public decimal Frete { get; }
internal Pedido(
string cliente,
string endereco,
string pagamento,
string? cupom,
string? observacao,
IReadOnlyList<Item> itens,
DateTime entregaEm,
decimal frete)
{
Cliente = cliente;
Endereco = endereco;
Pagamento = pagamento;
Cupom = cupom;
Observacao = observacao;
Itens = itens;
EntregaEm = entregaEm;
Frete = frete;
}
}Agora o Builder:
public class PedidoBuilder
{
private string? _cliente;
private string? _endereco;
private string? _pagamento;
private string? _cupom;
private string? _observacao;
private List<Item> _itens = new();
private DateTime? _entregaEm;
private decimal _frete;
public PedidoBuilder ParaCliente(string cliente)
{
_cliente = cliente;
return this;
}
public PedidoBuilder ComEndereco(string endereco)
{
_endereco = endereco;
return this;
}
public PedidoBuilder PagandoCom(string pagamento)
{
_pagamento = pagamento;
return this;
}
public PedidoBuilder ComCupom(string cupom)
{
_cupom = cupom;
return this;
}
public PedidoBuilder Observacao(string obs)
{
_observacao = obs;
return this;
}
public PedidoBuilder ComItens(IEnumerable<Item> itens)
{
_itens = itens.ToList();
return this;
}
public PedidoBuilder EntregaEm(DateTime data)
{
_entregaEm = data;
return this;
}
public PedidoBuilder ComFrete(decimal frete)
{
_frete = frete;
return this;
}
public Pedido Build()
{
// validação forte: builder bom falha cedo
if (string.IsNullOrWhiteSpace(_cliente)) throw new InvalidOperationException("Cliente é obrigatório");
if (string.IsNullOrWhiteSpace(_endereco)) throw new InvalidOperationException("Endereço é obrigatório");
if (string.IsNullOrWhiteSpace(_pagamento)) throw new InvalidOperationException("Pagamento é obrigatório");
if (_itens.Count == 0) throw new InvalidOperationException("Pedido precisa de itens");
if (_entregaEm is null) throw new InvalidOperationException("Data de entrega é obrigatória");
return new Pedido(
_cliente!,
_endereco!,
_pagamento!,
_cupom,
_observacao,
_itens,
_entregaEm.Value,
_frete);
}
}Uso:
var pedido = new PedidoBuilder()
.ParaCliente("Rafael")
.ComEndereco("Rua X, 123")
.PagandoCom("cartão")
.ComCupom("DESCONTO10")
.Observacao("sem cebola")
.ComItens(items)
.EntregaEm(DateTime.Now.AddDays(3))
.ComFrete(15.90m)
.Build();Trade-offs
Builder adiciona classes e código extra. Vale a pena quando:
-
existem muitos campos opcionais,
-
validação de consistência é importante,
-
e legibilidade do “setup” do objeto importa.
Se a classe tem 3 parâmetros e acabou, Builder é overkill.
3) Factory — criando objetos sem espalhar lógica
O problema que aparece
Você precisa instanciar classes diferentes dependendo de um input (tipo, config, feature flag, ambiente, etc.). Se você espalha new + switch em todo lugar, vira caos.
Como reconhecer (sintomas)
Você vê:
-
switch(tipo)em 5 arquivos diferentes, -
criação duplicada,
-
mudanças que exigem “caçar” todos os lugares.
A solução
Centralizar criação em uma “fábrica”. Quem usa pede o que quer; a fábrica decide como criar.
Implementação simples
public interface INotificacao
{
void Enviar(string mensagem);
}
public class NotificacaoEmail : INotificacao
{
public void Enviar(string mensagem) => Console.WriteLine($"EMAIL: {mensagem}");
}
public class NotificacaoSms : INotificacao
{
public void Enviar(string mensagem) => Console.WriteLine($"SMS: {mensagem}");
}Factory:
public static class NotificacaoFactory
{
public static INotificacao Criar(string tipo) =>
tipo switch
{
"email" => new NotificacaoEmail(),
"sms" => new NotificacaoSms(),
_ => throw new ArgumentException("Tipo inválido")
};
}Factory + DI (mais real)
Quando começa a escalar, o “new” dentro da factory também vira um problema (dependências). A versão mais madura usa DI:
public interface INotificacaoFactory
{
INotificacao Criar(string tipo);
}
public class NotificacaoFactory : INotificacaoFactory
{
private readonly IServiceProvider _sp;
public NotificacaoFactory(IServiceProvider sp)
{
_sp = sp;
}
public INotificacao Criar(string tipo) =>
tipo switch
{
"email" => _sp.GetRequiredService<NotificacaoEmail>(),
"sms" => _sp.GetRequiredService<NotificacaoSms>(),
_ => throw new ArgumentException("Tipo inválido")
};
}E no container:
services.AddTransient<NotificacaoEmail>();
services.AddTransient<NotificacaoSms>();
services.AddSingleton<INotificacaoFactory, NotificacaoFactory>();Trade-offs
Factory resolve criação espalhada, mas pode virar “God Factory” se você jogar tudo num switch infinito. Quando isso começa, normalmente vale partir para:
-
mapeamento por dicionário (
Dictionary<string, Func<INotificacao>>) -
registro por convention
-
ou strategy resolver isso melhor dependendo do cenário.
4) Facade — simplificando quem está de fora
O problema que aparece
Você tem um fluxo de negócio com muitos passos e vários serviços internos. Sem organização, quem chama vira um “orquestrador acidental” e passa a conhecer detalhes demais.
Como reconhecer (sintomas)
-
Controllers chamando 6 serviços na mão
-
“colar” de lógica no endpoint
-
repetição do mesmo fluxo em vários lugares
A solução
Facade cria uma interface simples que encapsula a orquestração.
Implementação
Suponha serviços separados:
-
ValidadorCartao -
CalculadoraImpostos -
DescontoService -
GatewayPagamento -
NotaFiscalService -
EmailService
Facade:
public class PagamentoFacade
{
private readonly ValidadorCartao _validador;
private readonly CalculadoraImpostos _impostos;
private readonly DescontoService _desconto;
private readonly GatewayPagamento _gateway;
private readonly NotaFiscalService _notaFiscal;
private readonly EmailService _email;
public PagamentoFacade(
ValidadorCartao validador,
CalculadoraImpostos impostos,
DescontoService desconto,
GatewayPagamento gateway,
NotaFiscalService notaFiscal,
EmailService email)
{
_validador = validador;
_impostos = impostos;
_desconto = desconto;
_gateway = gateway;
_notaFiscal = notaFiscal;
_email = email;
}
public ResultadoPagamento Processar(Pedido pedido)
{
_validador.ValidarCartao(pedido.Cartao);
var total = _impostos.Calcular(pedido);
total = _desconto.Aplicar(total, pedido.Cupom);
var transacao = _gateway.Cobrar(pedido.Cartao, total);
_notaFiscal.Gerar(pedido, transacao);
_email.EnviarConfirmacao(pedido.Cliente);
return new ResultadoPagamento(transacao.Id, total);
}
}Quem chama não precisa conhecer a bagunça:
var resultado = _pagamentoFacade.Processar(pedido);
Trade-offs
Facade pode virar um “método mágico” enorme.
O ideal é:
-
encapsular orquestração, mas
-
manter dependências explícitas,
-
e não esconder regras de negócio “críticas” sem testes.
5) Adapter — quando duas APIs não conversam
O problema que aparece
Você tem uma interface interna (contrato do seu sistema) e uma dependência externa (biblioteca, API, SDK) com outro contrato.
Se você espalhar a biblioteca pelo sistema inteiro, você casa com ela.
Solução
Criar um adapter que implementa seu contrato e traduz chamadas para a API externa.
Implementação
Seu contrato:
public interface ILogger
{
void Info(string msg);
void Erro(string msg);
}Serilog tem outro contrato. Adapter:
public class SerilogAdapter : ILogger
{
private readonly Serilog.ILogger _serilog;
public SerilogAdapter(Serilog.ILogger serilog)
{
_serilog = serilog;
}
public void Info(string msg) => _serilog.Information(msg);
public void Erro(string msg) => _serilog.Error(msg);
}Trade-offs
Adapter cria uma camada a mais, mas compra:
-
desacoplamento,
-
facilidade de troca,
-
testes mais simples (mocka sua interface, não a biblioteca).
6) Strategy — comportamento como dependência (e o fim do switch infinito)
O problema que aparece
Você tem várias regras que mudam com o contexto: preço, frete, imposto, antifraude, roteamento, validações por tipo… e normalmente isso vira um switch gigantesco.
Solução
Extrair o comportamento para estratégias intercambiáveis (implementações de uma interface).
Implementação
public interface IPrecoStrategy
{
decimal Calcular(Pedido pedido);
}
public class PrecoNormal : IPrecoStrategy
{
public decimal Calcular(Pedido pedido)
=> pedido.Itens.Sum(i => i.Preco);
}
public class PrecoBlackFriday : IPrecoStrategy
{
public decimal Calcular(Pedido pedido)
=> pedido.Itens.Sum(i => i.Preco) * 0.7m;
}Resolver a estratégia:
public class PrecoService
{
private readonly IPrecoStrategy _strategy;
public PrecoService(IPrecoStrategy strategy)
{
_strategy = strategy;
}
public decimal Calcular(Pedido pedido) => _strategy.Calcular(pedido);
}Escolha de strategy pode vir de config, user tier, feature flag, etc.
Trade-offs
Strategy gera mais classes. Vale quando:
-
regra muda com frequência,
-
cresce em número de variações,
-
e você quer extensão sem mexer no que já funciona.
7) Observer — eventos e reatividade sem acoplamento
O problema que aparece
Um evento dispara várias consequências. Ex.: “pedido criado” precisa:
-
enviar email,
-
baixar estoque,
-
notificar dashboard,
-
gerar nota fiscal,
-
disparar analytics…
Se você fizer tudo dentro do PedidoService, ele vira um monstro acoplado.
Solução
Publicar um evento e permitir que handlers se inscrevam.
Implementação simples (conceitual)
Evento:
public record PedidoCriadoEvent(Pedido Pedido);
Publicação:
_eventBus.Publish(new PedidoCriadoEvent(pedido));
Handler:
public class EmailHandler : IHandler<PedidoCriadoEvent>
{
public void Handle(PedidoCriadoEvent e)
{
_email.Enviar(e.Pedido.Cliente, "Pedido confirmado!");
}
}Trade-offs
Observer facilita extensão, mas pode gerar:
-
“fluxo invisível” se você não tem observabilidade,
-
ordem de execução indefinida,
-
debug mais complexo.
A solução aqui não é “não usar”, é usar com:
-
logging/tracing,
-
nomes claros,
-
eventos bem definidos,
-
e testes de integração onde importa.
O recado final (o que separa dev bom de dev decorador)
Depois que você aprende design patterns, dá vontade de aplicar em tudo. Só que pattern não é estética — é ferramenta. Se o problema não existe, o pattern vira peso.
Um if pequeno não precisa virar Strategy. Um objeto simples não precisa de Builder. Nem toda criação precisa de Factory.
O objetivo não é “usar patterns”. O objetivo é reduzir acoplamento, aumentar clareza e facilitar mudança quando seu sistema inevitavelmente crescer.
Se você conseguir olhar para seu código e reconhecer esses problemas cedo, você para de apagar incêndio e começa a construir com intenção.