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:
1public sealed class Logger 2{ 3 private static Logger? _instance; 4 private static readonly object _lock = new(); 5 6 private Logger() { } 7 8 public static Logger Instance 9 { 10 get 11 { 12 if (_instance is not null) return _instance; 13 14 lock (_lock) 15 { 16 _instance ??= new Logger(); 17 return _instance; 18 } 19 } 20 } 21 22 public void Log(string message) 23 { 24 Console.WriteLine($"[{DateTime.Now:O}] {message}"); 25 } 26}
Versão mais segura e idiomática (Lazy<T>)
Se você realmente for fazer Singleton manual, isso é melhor:
1public sealed class Logger 2{ 3 private static readonly Lazy<Logger> _instance = 4 new(() => new Logger()); 5 6 private Logger() { } 7 8 public static Logger Instance => _instance.Value; 9 10 public void Log(string message) 11 => Console.WriteLine($"[{DateTime.Now:O}] {message}"); 12}
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:
1var pedido = new Pedido( 2 "Rafael", 3 "Rua X, 123", 4 "cartão", 5 "DESCONTO10", 6 "sem cebola", 7 items, 8 DateTime.Now.AddDays(3), 9 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:
1public class Pedido 2{ 3 public string Cliente { get; } 4 public string Endereco { get; } 5 public string Pagamento { get; } 6 public string? Cupom { get; } 7 public string? Observacao { get; } 8 public IReadOnlyList<Item> Itens { get; } 9 public DateTime EntregaEm { get; } 10 public decimal Frete { get; } 11 12 internal Pedido( 13 string cliente, 14 string endereco, 15 string pagamento, 16 string? cupom, 17 string? observacao, 18 IReadOnlyList<Item> itens, 19 DateTime entregaEm, 20 decimal frete) 21 { 22 Cliente = cliente; 23 Endereco = endereco; 24 Pagamento = pagamento; 25 Cupom = cupom; 26 Observacao = observacao; 27 Itens = itens; 28 EntregaEm = entregaEm; 29 Frete = frete; 30 } 31}
Agora o Builder:
1public class PedidoBuilder 2{ 3 private string? _cliente; 4 private string? _endereco; 5 private string? _pagamento; 6 private string? _cupom; 7 private string? _observacao; 8 private List<Item> _itens = new(); 9 private DateTime? _entregaEm; 10 private decimal _frete; 11 12 public PedidoBuilder ParaCliente(string cliente) 13 { 14 _cliente = cliente; 15 return this; 16 } 17 18 public PedidoBuilder ComEndereco(string endereco) 19 { 20 _endereco = endereco; 21 return this; 22 } 23 24 public PedidoBuilder PagandoCom(string pagamento) 25 { 26 _pagamento = pagamento; 27 return this; 28 } 29 30 public PedidoBuilder ComCupom(string cupom) 31 { 32 _cupom = cupom; 33 return this; 34 } 35 36 public PedidoBuilder Observacao(string obs) 37 { 38 _observacao = obs; 39 return this; 40 } 41 42 public PedidoBuilder ComItens(IEnumerable<Item> itens) 43 { 44 _itens = itens.ToList(); 45 return this; 46 } 47 48 public PedidoBuilder EntregaEm(DateTime data) 49 { 50 _entregaEm = data; 51 return this; 52 } 53 54 public PedidoBuilder ComFrete(decimal frete) 55 { 56 _frete = frete; 57 return this; 58 } 59 60 public Pedido Build() 61 { 62 // validação forte: builder bom falha cedo 63 if (string.IsNullOrWhiteSpace(_cliente)) throw new InvalidOperationException("Cliente é obrigatório"); 64 if (string.IsNullOrWhiteSpace(_endereco)) throw new InvalidOperationException("Endereço é obrigatório"); 65 if (string.IsNullOrWhiteSpace(_pagamento)) throw new InvalidOperationException("Pagamento é obrigatório"); 66 if (_itens.Count == 0) throw new InvalidOperationException("Pedido precisa de itens"); 67 if (_entregaEm is null) throw new InvalidOperationException("Data de entrega é obrigatória"); 68 69 return new Pedido( 70 _cliente!, 71 _endereco!, 72 _pagamento!, 73 _cupom, 74 _observacao, 75 _itens, 76 _entregaEm.Value, 77 _frete); 78 } 79}
Uso:
1var pedido = new PedidoBuilder() 2 .ParaCliente("Rafael") 3 .ComEndereco("Rua X, 123") 4 .PagandoCom("cartão") 5 .ComCupom("DESCONTO10") 6 .Observacao("sem cebola") 7 .ComItens(items) 8 .EntregaEm(DateTime.Now.AddDays(3)) 9 .ComFrete(15.90m) 10 .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
1public interface INotificacao 2{ 3 void Enviar(string mensagem); 4} 5 6public class NotificacaoEmail : INotificacao 7{ 8 public void Enviar(string mensagem) => Console.WriteLine($"EMAIL: {mensagem}"); 9} 10 11public class NotificacaoSms : INotificacao 12{ 13 public void Enviar(string mensagem) => Console.WriteLine($"SMS: {mensagem}"); 14}
Factory:
1public static class NotificacaoFactory 2{ 3 public static INotificacao Criar(string tipo) => 4 tipo switch 5 { 6 "email" => new NotificacaoEmail(), 7 "sms" => new NotificacaoSms(), 8 _ => throw new ArgumentException("Tipo inválido") 9 }; 10}
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:
1public interface INotificacaoFactory 2{ 3 INotificacao Criar(string tipo); 4} 5 6public class NotificacaoFactory : INotificacaoFactory 7{ 8 private readonly IServiceProvider _sp; 9 10 public NotificacaoFactory(IServiceProvider sp) 11 { 12 _sp = sp; 13 } 14 15 public INotificacao Criar(string tipo) => 16 tipo switch 17 { 18 "email" => _sp.GetRequiredService<NotificacaoEmail>(), 19 "sms" => _sp.GetRequiredService<NotificacaoSms>(), 20 _ => throw new ArgumentException("Tipo inválido") 21 }; 22}
E no container:
1services.AddTransient<NotificacaoEmail>(); 2services.AddTransient<NotificacaoSms>(); 3services.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:
1public class PagamentoFacade 2{ 3 private readonly ValidadorCartao _validador; 4 private readonly CalculadoraImpostos _impostos; 5 private readonly DescontoService _desconto; 6 private readonly GatewayPagamento _gateway; 7 private readonly NotaFiscalService _notaFiscal; 8 private readonly EmailService _email; 9 10 public PagamentoFacade( 11 ValidadorCartao validador, 12 CalculadoraImpostos impostos, 13 DescontoService desconto, 14 GatewayPagamento gateway, 15 NotaFiscalService notaFiscal, 16 EmailService email) 17 { 18 _validador = validador; 19 _impostos = impostos; 20 _desconto = desconto; 21 _gateway = gateway; 22 _notaFiscal = notaFiscal; 23 _email = email; 24 } 25 26 public ResultadoPagamento Processar(Pedido pedido) 27 { 28 _validador.ValidarCartao(pedido.Cartao); 29 30 var total = _impostos.Calcular(pedido); 31 total = _desconto.Aplicar(total, pedido.Cupom); 32 33 var transacao = _gateway.Cobrar(pedido.Cartao, total); 34 35 _notaFiscal.Gerar(pedido, transacao); 36 _email.EnviarConfirmacao(pedido.Cliente); 37 38 return new ResultadoPagamento(transacao.Id, total); 39 } 40}
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:
1public interface ILogger 2{ 3 void Info(string msg); 4 void Erro(string msg); 5}
Serilog tem outro contrato. Adapter:
1public class SerilogAdapter : ILogger 2{ 3 private readonly Serilog.ILogger _serilog; 4 5 public SerilogAdapter(Serilog.ILogger serilog) 6 { 7 _serilog = serilog; 8 } 9 10 public void Info(string msg) => _serilog.Information(msg); 11 public void Erro(string msg) => _serilog.Error(msg); 12}
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
1public interface IPrecoStrategy 2{ 3 decimal Calcular(Pedido pedido); 4} 5 6public class PrecoNormal : IPrecoStrategy 7{ 8 public decimal Calcular(Pedido pedido) 9 => pedido.Itens.Sum(i => i.Preco); 10} 11 12public class PrecoBlackFriday : IPrecoStrategy 13{ 14 public decimal Calcular(Pedido pedido) 15 => pedido.Itens.Sum(i => i.Preco) * 0.7m; 16}
Resolver a estratégia:
1public class PrecoService 2{ 3 private readonly IPrecoStrategy _strategy; 4 5 public PrecoService(IPrecoStrategy strategy) 6 { 7 _strategy = strategy; 8 } 9 10 public decimal Calcular(Pedido pedido) => _strategy.Calcular(pedido); 11}
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:
1public class EmailHandler : IHandler<PedidoCriadoEvent> 2{ 3 public void Handle(PedidoCriadoEvent e) 4 { 5 _email.Enviar(e.Pedido.Cliente, "Pedido confirmado!"); 6 } 7}
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.