Design Patterns que todo dev deveria conhecer

Rafael Coelho
· 12 min de leitura

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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
public interface ILogger { void Info(string msg); void Erro(string msg); }

Serilog tem outro contrato. Adapter:

csharp
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

csharp
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:

csharp
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:

csharp
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.