Introdução

Em 1994, quatro pesquisadores da computação — Erich Gamma, Richard Helm, Ralph Johnson e John Vlissides — publicaram um livro que mudaria para sempre a forma como engenheiros de software pensam sobre seus problemas. “Design Patterns: Elements of Reusable Object-Oriented Software” catalogou 23 soluções recorrentes para problemas recorrentes no desenvolvimento orientado a objetos. A comunidade os apelidou de Gang of Four (GoF) — a Gangue dos Quatro.

Três décadas depois, em um mundo de cloud pública, arquiteturas híbridas, Kubernetes, serverless, times distribuídos globalmente e microserviços que se comunicam por eventos, esses mesmos padrões continuam sendo referência. Não porque o mundo parou. Porque os problemas fundamentais de software não mudaram — apenas a escala e o ambiente em que eles aparecem mudaram.

Este artigo explora:

  • Por que precisamos de arquitetura de software — e o que acontece quando ignoramos
  • A origem e o propósito do GoF — contexto histórico, motivações e a estrutura dos 23 padrões
  • Como os padrões se aplicam independentemente da linguagem — GoF não é Java, nem C++, é uma linguagem de design
  • A revolução da cloud e dos microserviços — como os padrões clássicos evoluíram e quais novos surgiram
  • Times de desenvolvimento e sustentação — por que a arquitetura é o elo que mantém sistemas vivos por mais de uma geração de desenvolvedores

Para quem é este artigo: desenvolvedores com experiência prática que querem elevar seu raciocínio de solução de problemas para o nível de arquitetura de software.

📦 Código-fonte: A implementação completa deste artigo está no repositório blog-zocateli-sample no GitHub. Clone, explore e adapte ao seu contexto.


Por Que Arquitetura de Software Importa

Software sem arquitetura: o custo invisível

Imagine um sistema bancário construído por 50 desenvolvedores ao longo de 8 anos, sem nenhuma convenção arquitetural. Cada desenvolvedor resolveu os problemas do seu jeito. O resultado é o que a industria chama de Big Ball of Mud — uma massa amorfa onde qualquer mudança pode quebrar algo em qualquer lugar.

Esse não é um cenário hipotético. É o estado natural de sistemas que crescem sem intenção arquitetural. Os sintomas são reconhecíveis:

  • Acoplamento invisível: alterar o módulo de pagamentos quebra o módulo de relatórios, e ninguém sabe por quê
  • Conhecimento tribal: apenas 2 desenvolvedores sabem como aquela parte “funciona de verdade”
  • Medo de deploy: “sexta não sobe” virou cultura porque ninguém tem confiança no sistema
  • Regressão constante: cada nova funcionalidade parece trazer 3 bugs antigos de volta
  • Onboarding demorado: um desenvolvedor novo leva 6 meses para ser produtivo

Arquitetura não é o que você faz antes de escrever código — é a intenção que guia cada decisão de design ao longo de toda a vida do sistema.

O que é arquitetura de software, afinal?

Martin Fowler define arquitetura de software como “as decisões importantes e difíceis de reverter” em um sistema. Ralph Johnson a define como “as decisões compartilhadas pelos desenvolvedores sobre como organizar o sistema”.

Essas definições revelam duas dimensões da arquitetura:

  1. Técnica: modularidade, acoplamento, coesão, contratos entre componentes, fluxo de dados, tratamento de falhas
  2. Social: vocabulário comum entre pessoas, convenções que permitem que um time de 20 trabalhhe com consistência

A segunda dimensão é frequentemente ignorada — e é justamente onde os padrões GoF brilham.

O custo do débito técnico arquitetural

O débito técnico arquitetural é diferente e mais grave que o débito técnico de código. Refatorar um método é uma tarde. Mudar a arquitetura de um sistema em produção pode levar meses, com alto risco. Por isso, decisões arquiteturais erradas compõem juros rápidos:

1
2
3
4
Ano 1: "Vamos colocar lógica de negócio diretamente no controller, é mais rápido"
Ano 2: "Temos 300 controllers com 500 linhas cada, impossível testar"
Ano 3: "Precisamos de 6 meses para migrar para qualquer outra coisa"
Ano 4: "Não conseguimos contratar seniors porque ninguém quer trabalhar nesse código"

Arquitetura não elimina o débito técnico — mas o direciona e o torna visível e gerenciável.


A Origem do Gang of Four

O contexto histórico: a crise do software orientado a objetos

Em 1994, a programação orientada a objetos tinha vivido seu auge de hype. C++, Smalltalk, e mais recentemente Java prometiam que herança, encapsulamento e polimorfismo resolveriam todos os problemas de reutilização de código. A promessa não se cumpriu sozinha.

O problema não era a orientação a objetos em si — era que desenvolvedores não tinham um vocabulário compartilhado para expressar como usar as ferramentas da OO. Cada projeto reinventava as mesmas soluções para os mesmos problemas. Um desenvolvedor experiente em Smalltalk tinha soluções consolidadas para “como desacoplar um algoritmo de suas implementações concretas” — mas essas soluções viviam em sua cabeça, não em livros.

Gamma, Helm, Johnson e Vlissides perceberam que estavam, independentemente, chegando às mesmas soluções para os mesmos tipos de problema. A ideia do livro nasceu de uma conferência OOPSLA em 1990, quando Erich Gamma e Richard Helm trocaram notas sobre padrões recorrentes. O processo de escrita durou 4 anos.

A influência de Christopher Alexander

Os autores se inspiraram explicitamente em Christopher Alexander, um arquiteto (de construções físicas, não de software) que em 1977 publicou “A Pattern Language” — um catálogo de 253 padrões recorrentes em arquitetura urbana e de edificações, desde o design de cidades até o posicionamento de janelas.

A ideia central de Alexander: problemas recorrentes em um contexto têm soluções recorrentes que podem ser nomeadas, documentadas e comunicadas. Quando um arquiteto diz “esta sala precisa de um pé-direito duplo”, todos os outros arquitetos sabem exatamente o que isso significa — sem precisar explicar do zero.

GoF aplicou essa ideia ao software: ao dizer “use um Strategy aqui”, você comunica uma estrutura inteira de design em uma única palavra.

A estrutura de um padrão GoF

Cada um dos 23 padrões segue um formato consistente:

CampoDescrição
NomeO vocabulário — “Strategy”, “Factory”, “Observer”
IntençãoO que o padrão faz, em uma frase
Também Conhecido ComoNomes alternativos
MotivaçãoCenário concreto que justifica o padrão
AplicabilidadeQuando usar
EstruturaDiagrama de classes (UML)
ParticipantesAs classes e suas responsabilidades
ColaboraçõesComo os participantes interagem
ConsequênciasTrade-offs — o que você ganha e o que você abre mão
ImplementaçãoNotas de implementação, variações
Exemplo de CódigoC++ no livro original
Padrões RelacionadosConexões com outros padrões

O campo Consequências é o mais ignorado e o mais valioso. Todo padrão tem um custo. Conhecer o custo é o que diferencia um arquiteto de alguém que apenas aplica padrões mecanicamente.


Os 23 Padrões GoF

Os 23 padrões são divididos em três categorias, baseadas no tipo de problema que resolvem:

Mapa dos 23 padrões GoF organizados por categoria: Criacionais, Estruturais e Comportamentais

Padrões Criacionais — Como os Objetos São Criados

Encapsulam e abstraem o processo de criação de objetos. O objetivo é desacoplar o código que usa um objeto do código que o instancia.

Factory Method

Define uma interface para criar um objeto, mas deixa as subclasses decidirem qual classe instanciar.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Independente de linguagem — o princípio:
// "Quero criar algo, mas não quero saber COMO é criado"

public abstract class NotificadorBase
{
    // Factory Method: subclasses definem qual canal usar
    protected abstract ICanal CriarCanal();

    public void Notificar(string mensagem)
    {
        var canal = CriarCanal();  // criação delegada à subclass
        canal.Enviar(mensagem);
    }
}

public class NotificadorEmail : NotificadorBase
{
    protected override ICanal CriarCanal() => new CanalEmail();
}

public class NotificadorSms : NotificadorBase
{
    protected override ICanal CriarCanal() => new CanalSms();
}

// O código de orquestração não precisa saber qual canal é usado:
NotificadorBase notificador = ambiente == "prod"
    ? new NotificadorEmail()
    : new NotificadorSms();
notificador.Notificar("Pedido confirmado");

Abstract Factory

Cria famílias de objetos relacionados sem especificar suas classes concretas.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Problema: você precisa de componentes que sejam consistentes entre si (mesma "família")
// Exemplo: componentes de UI que devem ser todos "Light" ou todos "Dark"

public interface IUiFactory
{
    IBotao CriarBotao();
    IInput CriarInput();
    ICard CriarCard();
}

public class LightThemeFactory : IUiFactory
{
    public IBotao CriarBotao() => new BotaoLight();
    public IInput CriarInput() => new InputLight();
    public ICard CriarCard() => new CardLight();
}

public class DarkThemeFactory : IUiFactory
{
    public IBotao CriarBotao() => new BotaoDark();
    public IInput CriarInput() => new InputDark();
    public ICard CriarCard() => new CardDark();
}

// A tela usa a factory — nunca instancia diretamente:
public class TelaCheckout(IUiFactory factory)
{
    private readonly IBotao _btnConfirmar = factory.CriarBotao();
    private readonly IInput _inputCartao   = factory.CriarInput();
}

Builder

Separa a construção de um objeto complexo de sua representação.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Resolve o "telescoping constructor" problem:
// Em vez de: new Pedido(1, "João", true, false, null, 0, "SP", "BR", ...)
// Use:

var pedido = new PedidoBuilder()
    .ParaCliente(clienteId: 1)
    .ComItem(produtoId: 10, quantidade: 2)
    .ComItem(produtoId: 15, quantidade: 1)
    .ComDesconto(10)
    .ComEnderecoEntrega("Rua A", "São Paulo", "SP")
    .ComPrioridadeExpressa()
    .Build();

public class PedidoBuilder
{
    private readonly Pedido _pedido = new();

    public PedidoBuilder ParaCliente(int clienteId)
        { _pedido.ClienteId = clienteId; return this; }

    public PedidoBuilder ComItem(int produtoId, int quantidade)
        { _pedido.Itens.Add(new Item(produtoId, quantidade)); return this; }

    public PedidoBuilder ComDesconto(decimal percentual)
        { _pedido.Desconto = percentual; return this; }

    public PedidoBuilder ComPrioridadeExpressa()
        { _pedido.Prioridade = PrioridadePedido.Expressa; return this; }

    public Pedido Build()
    {
        _pedido.Validar();  // validação centralizada no Build
        return _pedido;
    }
}

Prototype

Cria novos objetos copiando (clonando) um objeto existente.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Útil quando criar do zero é caro (consulta ao banco, inicialização pesada)
public abstract class Componente
{
    public abstract Componente Clone();
    public string Nome { get; set; } = "";
    public Dictionary<string, string> Configuracoes { get; set; } = [];
}

public class ComponenteNginx : Componente
{
    public override Componente Clone()
    {
        var clone = (ComponenteNginx)MemberwiseClone();
        // Deep copy das configurações mutáveis
        clone.Configuracoes = new Dictionary<string, string>(Configuracoes);
        return clone;
    }
}

// Em vez de criar e configurar do zero toda vez:
var templateNginx = new ComponenteNginx { Nome = "nginx-template" };
templateNginx.Configuracoes["porta"] = "80";
templateNginx.Configuracoes["workers"] = "4";

// Clonar é muito mais barato:
var nginx1 = (ComponenteNginx)templateNginx.Clone();
nginx1.Nome = "nginx-producao";

Singleton

Garante que uma classe tenha apenas uma instância e provê acesso global a ela.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Em .NET moderno, prefira injeção de dependência com Lifetime.Singleton
// mas o padrão ainda é relevante para entender o conceito:

// ⚠️ Thread-safe com Lazy<T> (mais idiomático em .NET):
public sealed class GerenciadorCache
{
    private static readonly Lazy<GerenciadorCache> _instancia =
        new(() => new GerenciadorCache());

    private GerenciadorCache() { }  // construtor privado

    public static GerenciadorCache Instancia => _instancia.Value;

    public object? Obter(string chave) { /* ... */ return null; }
    public void Definir(string chave, object valor) { /* ... */ }
}

// Uso:
GerenciadorCache.Instancia.Definir("config", dados);

// ✅ Em ASP.NET Core: use services.AddSingleton<IGerenciadorCache, GerenciadorCache>()
// O contêiner de DI garante a única instância com thread-safety

Padrões Estruturais — Como os Objetos São Compostos

Tratam da composição de classes e objetos para formar estruturas maiores, mantendo flexibilidade e eficiência.

Adapter

Converte a interface de uma classe em outra interface esperada pelos clientes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Problema clássico: nova SDK de pagamento com interface diferente da atual
public interface IGatewayPagamento  // interface que SEU sistema espera
{
    Task<ResultadoPagamento> ProcessarAsync(DadosPagamento dados);
}

// Classe existente de terceiro com interface diferente:
public class StripeClient  // você NÃO controla essa classe
{
    public StripeResult Charge(string token, long amountCents, string currency)
        { /* Stripe SDK */ return new StripeResult(); }
}

// Adapter: adapta a interface do Stripe para a interface esperada pelo sistema
public class StripeAdapter(StripeClient stripe) : IGatewayPagamento
{
    public async Task<ResultadoPagamento> ProcessarAsync(DadosPagamento dados)
    {
        var resultado = await Task.Run(() =>
            stripe.Charge(dados.Token, (long)(dados.Valor * 100), dados.Moeda));

        return new ResultadoPagamento(
            Sucesso: resultado.Status == "succeeded",
            TransacaoId: resultado.Id);
    }
}

// O sistema nunca sabe que está usando Stripe:
IGatewayPagamento gateway = new StripeAdapter(new StripeClient());
await gateway.ProcessarAsync(dadosDoPedido);

Decorator

Adiciona responsabilidades a um objeto dinamicamente, sem alterar sua classe.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// O poder real: compor comportamentos sem explodir a hierarquia de herança

public interface IRepositorioPedidos
{
    Task<Pedido?> ObterAsync(int id);
    Task SalvarAsync(Pedido pedido);
}

// Decorator 1: adiciona cache
public class PedidosComCache(IRepositorioPedidos inner, ICache cache)
    : IRepositorioPedidos
{
    public async Task<Pedido?> ObterAsync(int id)
    {
        var cached = await cache.GetAsync<Pedido>($"pedido:{id}");
        if (cached is not null) return cached;

        var pedido = await inner.ObterAsync(id);
        if (pedido is not null)
            await cache.SetAsync($"pedido:{id}", pedido, TimeSpan.FromMinutes(5));
        return pedido;
    }

    public Task SalvarAsync(Pedido pedido) => inner.SalvarAsync(pedido);
}

// Decorator 2: adiciona logging
public class PedidosComLogging(IRepositorioPedidos inner, ILogger logger)
    : IRepositorioPedidos
{
    public async Task<Pedido?> ObterAsync(int id)
    {
        logger.LogInformation("Buscando pedido {Id}", id);
        var result = await inner.ObterAsync(id);
        logger.LogInformation("Pedido {Id}: {Status}", id, result is null ? "não encontrado" : "encontrado");
        return result;
    }

    public Task SalvarAsync(Pedido pedido) => inner.SalvarAsync(pedido);
}

// Composition root — você empilha os decorators como quiser:
IRepositorioPedidos repo =
    new PedidosComLogging(
        new PedidosComCache(
            new PedidosEfCore(dbContext),
            redisCache),
        logger);

Facade

Provê uma interface simplificada para um subsistema complexo.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Problema: finalizar um pedido envolve 6 subsistemas diferentes
// Sem Facade: cada controller conhece e orquestra todos os subsistemas

// Com Facade: um único ponto de entrada para o caso de uso
public class ServicoFinalizacaoPedido(
    IEstoqueService estoque,
    IFinanceiroService financeiro,
    ILogisticaService logistica,
    INotificacaoService notificacao,
    IAuditService auditoria)
{
    public async Task<ResultadoFinalizacao> FinalizarAsync(int pedidoId)
    {
        // Orquestra a complexidade internamente
        await estoque.ReservarItensAsync(pedidoId);
        var cobranca = await financeiro.CobrarAsync(pedidoId);

        if (!cobranca.Aprovada)
        {
            await estoque.LiberarReservaAsync(pedidoId);
            return ResultadoFinalizacao.FalhaNoPagamento(cobranca.Motivo);
        }

        var envio = await logistica.AgendarEnvioAsync(pedidoId);
        await notificacao.NotificarClienteAsync(pedidoId, envio.PrevisaoEntrega);
        await auditoria.RegistrarAsync(pedidoId, "PEDIDO_FINALIZADO");

        return ResultadoFinalizacao.Sucesso(cobranca.TransacaoId, envio.CodigoRastreio);
    }
}

// Controller: limpo, sem complexidade de orquestração:
[HttpPost("{id}/finalizar")]
public async Task<IActionResult> Finalizar(int id)
{
    var resultado = await _servico.FinalizarAsync(id);
    return resultado.Sucesso ? Ok(resultado) : BadRequest(resultado);
}

Proxy

Provê um substituto para outro objeto para controlar o acesso a ele.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Proxy virtual: carrega dados sob demanda (lazy loading)
// Proxy de proteção: verifica permissões antes de delegar
// Proxy remoto: abstrai comunicação de rede

// Proxy de proteção:
public class RepositorioPedidosProtegido(
    IRepositorioPedidos inner,
    IHttpContextAccessor httpContext) : IRepositorioPedidos
{
    public async Task<Pedido?> ObterAsync(int id)
    {
        var usuario = httpContext.HttpContext?.User;

        if (usuario?.IsInRole("Admin") != true)
        {
            // Restringe acesso ao pedido do próprio usuário
            var pedido = await inner.ObterAsync(id);
            var userId = usuario?.FindFirst("sub")?.Value;
            return pedido?.ClienteId.ToString() == userId ? pedido : null;
        }

        return await inner.ObterAsync(id);
    }

    public Task SalvarAsync(Pedido pedido) => inner.SalvarAsync(pedido);
}

Composite, Bridge, Flyweight

Os demais padrões estruturais resolvem problemas igualmente comuns:

  • Composite — trata objetos individuais e coleções de forma uniforme (árvores de menus, categorias hierárquicas, expressões matemáticas)
  • Bridge — separa abstração de implementação para que ambas evoluam independentemente (drivers de banco, renderizadores de relatório)
  • Flyweight — compartilha eficientemente objetos que ocorrem em grande número (caracteres em um editor de texto, partículas em jogos)

Padrões Comportamentais — Como os Objetos Interagem

Tratam da comunicação entre objetos e da distribuição de responsabilidades.

Strategy

Define uma família de algoritmos, encapsula cada um e os torna intercambiáveis.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Contexto: calcular frete com diferentes transportadoras
public interface ICalculadorFrete
{
    decimal Calcular(Endereco origem, Endereco destino, decimal pesoKg);
}

public class FreteCorreios : ICalculadorFrete
{
    public decimal Calcular(Endereco origem, Endereco destino, decimal pesoKg)
        => /* lógica dos Correios */ pesoKg * 3.5m + 8.0m;
}

public class FreteTransportadora : ICalculadorFrete
{
    public decimal Calcular(Endereco origem, Endereco destino, decimal pesoKg)
        => /* lógica da transportadora */ pesoKg * 2.8m + 15.0m;
}

public class FreteRetiradaLoja : ICalculadorFrete
{
    public decimal Calcular(Endereco origem, Endereco destino, decimal pesoKg)
        => 0m;  // grátis na retirada
}

// Context: usa a estratégia sem saber qual é
public class Carrinho(ICalculadorFrete calculadorFrete)
{
    public decimal CalcularFrete(Endereco destino)
        => calculadorFrete.Calcular(_enderecoLoja, destino, _pesoTotal);
}

// Flexibilidade na composição:
var carrinho = new Carrinho(cliente.PrefereFreteProprio
    ? new FreteTransportadora()
    : new FreteCorreios());

Observer

Define uma dependência um-para-muitos entre objetos: quando um muda de estado, todos os dependentes são notificados.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Base para sistemas de eventos, reactive programming, pub/sub
// .NET tem IObservable<T>/IObserver<T> nativos que seguem esse padrão

public interface IObservadorPedido
{
    Task AoAtualizarAsync(Pedido pedido);
}

public class ServicoPedidos
{
    private readonly List<IObservadorPedido> _observadores = [];

    public void Inscrever(IObservadorPedido obs) => _observadores.Add(obs);
    public void Cancelar(IObservadorPedido obs) => _observadores.Remove(obs);

    public async Task AtualizarStatusAsync(int id, StatusPedido novoStatus)
    {
        var pedido = await _repo.ObterAsync(id);
        pedido.Status = novoStatus;
        await _repo.SalvarAsync(pedido);

        // Notifica todos os observadores
        foreach (var obs in _observadores)
            await obs.AoAtualizarAsync(pedido);
    }
}

// Observadores registrados:
var servico = new ServicoPedidos(repo);
servico.Inscrever(new NotificadorEmailCliente(emailService));
servico.Inscrever(new AtualizadorEstoque(estoqueService));
servico.Inscrever(new RegistradorAuditoria(auditService));
servico.Inscrever(new GatewayWebhook(webhookService));

// Em .NET moderno: MediatR + domain events implementam exatamente este padrão

Command

Encapsula uma solicitação como um objeto, permitindo parametrizar operações, enfileirar, logar e desfazer.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// O padrão Command está no coração de CQRS, event sourcing e undo/redo

public interface IComando<TResultado> { }

public record CancelarPedidoComando(int PedidoId, string Motivo)
    : IComando<ResultadoCancelamento>;

public class CancelarPedidoHandler(
    IRepositorioPedidos repo,
    IEventBus eventBus,
    IAuditService auditoria)
    : IHandlerComando<CancelarPedidoComando, ResultadoCancelamento>
{
    public async Task<ResultadoCancelamento> ExecutarAsync(CancelarPedidoComando cmd)
    {
        var pedido = await repo.ObterAsync(cmd.PedidoId)
            ?? throw new PedidoNaoEncontradoException(cmd.PedidoId);

        if (!pedido.PodeCancelar())
            return ResultadoCancelamento.Falha("Status não permite cancelamento");

        pedido.Cancelar(cmd.Motivo);
        await repo.SalvarAsync(pedido);

        // Publica evento para outros serviços (Observer + Command combinados)
        await eventBus.PublicarAsync(new PedidoCanceladoEvent(pedido.Id, cmd.Motivo));
        await auditoria.RegistrarAsync(pedido.Id, "PEDIDO_CANCELADO", cmd.Motivo);

        return ResultadoCancelamento.Sucesso();
    }
}

Chain of Responsibility, Template Method, State, Iterator, Mediator, Visitor, Memento, Interpreter

Os demais padrões comportamentais cobrem problemas igualmente recorrentes:

PadrãoProblema que resolveUso moderno
Chain of ResponsibilityProcessar requisição passando por uma cadeia de handlers até ser tratadaASP.NET Core Middleware, pipelines
Template MethodDefine o esqueleto de um algoritmo em uma classe base, deixando partes para subclassesBase classes de testes, ETL pipelines
StatePermite que um objeto altere seu comportamento quando muda de estadoMáquinas de estado, pedidos, workflows
IteratorPercorre uma coleção sem expor sua estrutura internaIEnumerable<T>, foreach, yield return
MediatorObjetos comunicam-se através de um mediador central — reduz dependências diretasMediatR, message broker, event bus
VisitorAdiciona operações a objetos sem modificar suas classesAST visitors, transformações de árvore
MementoCaptura e restaura o estado interno de um objeto sem violar encapsulamentoUndo/redo, checkpoints, snapshots
InterpreterDefine uma gramática e um interpretador para elaDSLs, validação de regras de negócio

GoF é Independente de Linguagem

Um equívoco comum é tratar os padrões GoF como “padrões Java” ou “padrões C++”. O livro original foi escrito com exemplos em C++ e Smalltalk. Mas os padrões são conceitos de design, não APIs.

O mesmo problema, linguagens diferentes

Observer em JavaScript:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// EventEmitter do Node.js é um Observer nativo
const { EventEmitter } = require('events');
class ServicoPedidos extends EventEmitter {
  async cancelar(id, motivo) {
    /* ... */
    this.emit('pedidoCancelado', { id, motivo });
  }
}
const svc = new ServicoPedidos();
svc.on('pedidoCancelado', ({ id }) => notificarCliente(id));
svc.on('pedidoCancelado', ({ id }) => atualizarEstoque(id));

Strategy em Python:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from abc import ABC, abstractmethod

class CalculadorFrete(ABC):
    @abstractmethod
    def calcular(self, peso: float) -> float: ...

class FreteCorreios(CalculadorFrete):
    def calcular(self, peso: float) -> float:
        return peso * 3.5 + 8.0

class Carrinho:
    def __init__(self, calculador: CalculadorFrete):
        self._calculador = calculador  # Strategy injetada

Factory em Go:

1
2
3
4
5
6
7
8
9
type Notificador interface { Enviar(msg string) error }

func NovoNotificador(tipo string) Notificador {
    switch tipo {
    case "email": return &NotificadorEmail{}
    case "sms":   return &NotificadorSms{}
    default:      return &NotificadorLog{}
    }
}

Por que isso importa em times multidisciplinares?

Quando você tem um time com desenvolvedores C#, Go, Python e TypeScript, os padrões GoF são a lingua franca de design. A conversa:

“Precisamos de um Observer aqui, porque vários módulos precisam reagir quando o status do pedido mudar”

…faz sentido para todos, independentemente da linguagem que cada um usa no dia a dia. Sem esse vocabulário comum, a conversa seria:

“Precisamos de uma classe que quando o status muda, chama uma lista de outras classes que têm um método que… você entendeu”


A Era da Cloud e dos Microserviços

Como a cloud mudou o contexto, não os problemas

A migração para cloud trouxe novos problemas de escala, disponibilidade e distribuição. Mas os problemas fundamentais — acoplamento, coesão, separação de responsabilidades — não mudaram. O que mudou foi onde os problemas aparecem e qual o custo de uma decisão ruim.

EraAmbienteCusto de falhaEscalaPadrão central
MonólitoÚnico servidorControladoVerticalGoF clássico
SOARede internaModeradoModeradaAdapter, Facade
MicroserviçosCloud distribuídaAlto (cascata)Horizontal infinitaPadrões distribuídos
ServerlessCloud efêmeraVariávelAuto-scalingStateless por design
HíbridoMistoComplexoHeterogêneaAbstração de infra

Linha do tempo da evolução da arquitetura de software: Monólito, SOA, GoF formalizado, Cloud Native e Híbrido

Novos padrões para problemas novos

A cloud trouxe uma nova geração de padrões, muitos deles construídos sobre os princípios GoF:

Circuit Breaker (Disjuntor) — o Proxy aplicado a chamadas externas. Detecta falhas em cascata e interrompe chamadas para serviços degradados:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Polly implementa Circuit Breaker em .NET:
var pipeline = new ResiliencePipelineBuilder()
    .AddCircuitBreaker(new CircuitBreakerStrategyOptions
    {
        FailureRatio = 0.5,          // 50% de falhas → abre o disjuntor
        SamplingDuration = TimeSpan.FromSeconds(30),
        BreakDuration = TimeSpan.FromSeconds(15),
        OnOpened = args =>
        {
            logger.LogWarning("Circuit breaker ABERTO para serviço de pagamentos");
            return ValueTask.CompletedTask;
        }
    })
    .Build();

await pipeline.ExecuteAsync(async ct =>
    await gatewayPagamentos.ProcessarAsync(dados, ct));

Sidecar — o Decorator aplicado a nível de infraestrutura. Um processo auxiliar que roda ao lado do serviço principal, adicionando responsabilidades (logging, mTLS, rate limiting) sem alterar o código do serviço:

1
2
3
4
5
6
7
8
9
# Kubernetes: sidecar de logging (padrão Decorator em nível de pod)
spec:
  containers:
  - name: api-pedidos          # container principal
    image: api-pedidos:1.2
  - name: fluent-bit           # sidecar: coleta e envia logs
    image: fluent/fluent-bit
    volumeMounts:
    - mountPath: /var/log/pods

Saga — o Command + Observer aplicado a transações distribuídas. Como não existe transação ACID entre microsserviços, a Saga orquestra uma sequência de transações locais com compensações para falhas:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Saga orquestrada com MassTransit:
// 1. Reservar estoque          → se falhar → compensar: nada
// 2. Cobrar pagamento          → se falhar → compensar: liberar reserva
// 3. Agendar envio             → se falhar → compensar: estornar + liberar
// 4. Notificar cliente         → se falhar → compensar: log (não crítico)

public class FinalizarPedidoSaga : MassTransitStateMachine<FinalizarPedidoState>
{
    public State Reservando { get; private set; } = null!;
    public State Cobrando { get; private set; } = null!;
    public State Enviando { get; private set; } = null!;

    public FinalizarPedidoSaga()
    {
        Initially(
            When(PedidoFinalizado)
                .Then(ctx => ctx.Saga.PedidoId = ctx.Message.PedidoId)
                .TransitionTo(Reservando)
                .Publish(ctx => new ReservarEstoqueCommand(ctx.Saga.PedidoId)));

        During(Reservando,
            When(EstoqueReservado)
                .TransitionTo(Cobrando)
                .Publish(ctx => new CobrarPagamentoCommand(ctx.Saga.PedidoId)),
            When(EstoqueFalhou)
                .Publish(ctx => new PedidoCanceladoEvent(ctx.Saga.PedidoId, "Sem estoque"))
                .Finalize());
        // ...
    }
}

CQRS (Command Query Responsibility Segregation) — o Command + Strategy aplicado a leitura/escrita:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Segregar leitura de escrita permite otimizações independentes
// Escrita: consistência forte, validações, eventos de domínio
// Leitura: desnormalização, cache, read replicas, projeções

// Command side (escrita):
public record CriarPedidoCommand(int ClienteId, List<ItemDto> Itens)
    : ICommand<PedidoCriadoResult>;

// Query side (leitura — sem entidades de domínio, direto ao banco de leitura):
public record ListarPedidosQuery(int ClienteId, int Pagina)
    : IQuery<PagedResult<PedidoResumoDto>>;

public class ListarPedidosHandler(IReadDbContext db)
    : IQueryHandler<ListarPedidosQuery, PagedResult<PedidoResumoDto>>
{
    public async Task<PagedResult<PedidoResumoDto>> HandleAsync(ListarPedidosQuery q)
        => await db.PedidosView  // view desnormalizada, otimizada para leitura
            .Where(p => p.ClienteId == q.ClienteId)
            .OrderByDescending(p => p.DataCriacao)
            .ToPagedResultAsync(q.Pagina);
}

Cloud vs On-Premises: a decisão arquitetural mais cara

A dicotomia cloud vs on-premises não é técnica — é estratégica e tem implicações arquiteturais profundas:

DimensãoCloudOn-PremisesHíbrido
EscalabilidadeElástica (paga pelo uso)Limitada pelo hardwareBurst para cloud
LatênciaDepende da regiãoPrevisível, baixaComplexidade de roteamento
ConformidadeVaria por cloud/regiãoControle total dos dadosDados sensíveis: on-prem
CapEx vs OpExOpEx dominanteCapEx dominanteMisto
Vendor lock-inAlto (PaaS/SaaS)BaixoModerado
Complexidade opsBaixa (managed)Alta (equipe de infra)Muito alta
Padrões de apoioAdapter (abstrair cloud SDK)Sem necessidadeAnti-Corruption Layer

O Adapter como anti-lock-in: times que constroem sobre cloud providers diretamente em todo o código ficam presos. A solução é usar o padrão Adapter para abstrair a infraestrutura:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ❌ Lock-in direto ao Azure:
public class PedidoService
{
    private readonly BlobServiceClient _blob;   // Azure SDK direto
    private readonly ServiceBusClient _bus;     // Azure SDK direto

    public async Task ProcessarAsync(Pedido p)
    {
        await _blob.GetBlobContainerClient("pedidos")
            .UploadBlobAsync(p.Id.ToString(), /* ... */);
        await _bus.CreateSender("pedidos").SendMessageAsync(/* ... */);
    }
}

// ✅ Com abstração (Adapter + Facade):
public class PedidoService(IArmazenamento storage, IFilaMensagens fila)
{
    public async Task ProcessarAsync(Pedido p)
    {
        await storage.SalvarAsync("pedidos", p.Id.ToString(), p);
        await fila.PublicarAsync("pedidos", new PedidoCriadoEvent(p.Id));
    }
}

// Implementações podem ser swapped sem tocar no serviço:
// services.AddSingleton<IArmazenamento, AzureBlobStorage>();
// services.AddSingleton<IArmazenamento, S3Storage>();
// services.AddSingleton<IArmazenamento, LocalFileSystem>(); // dev/test

Times de Desenvolvimento e Times de Sustentação

Esta é, talvez, a dimensão mais subestimada da arquitetura de software. Todo sistema bem-sucedido acaba sendo mantido por pessoas que não o construíram. Às vezes por um time diferente. Às vezes anos depois. Às vezes por desenvolvedores com perfis e habilidades muito diferentes.

O ciclo de vida real de um sistema

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
Fase 1 — Construção (6-18 meses):
  Time original: sêniors motivados, conhecimento profundo do domínio
  Foco: velocidade de entrega, features, go-to-market
  Risco: decisões rápidas sem documentar trade-offs

Fase 2 — Crescimento (1-3 anos):
  Time: mistura originais + novos membros
  Foco: escalar times, manter velocidade
  Risco: divergência de padrões, "ilhas de conhecimento"

Fase 3 — Maturidade (3-10 anos):
  Time: principalmente sustentação, poucos originais
  Foco: estabilidade, correções, pequenas evoluções
  Risco: "ningúem sabe por que esse código existe"

Fase 4 — Legado (10+ anos):
  Time: sustentação, possível offshore
  Foco: manter funcionando sem quebrar
  Risco: reescrita forçada por acumulação de débito técnico

Por que os padrões são a memória institucional do sistema

Quando um desenvolvedor novo chega num sistema que usa os padrões GoF de forma consistente, ele não precisa reinventar a leitura do código. Ao ver IStrategy como dependência injetada em uma classe, ele imediatamente entende:

  1. Essa classe tem comportamento que pode variar
  2. As implementações estão em outros arquivos, nomeados XXXStrategy
  3. Para adicionar um novo comportamento, basta criar uma nova implementação
  4. O código existente não precisa mudar (Open/Closed Principle)

Sem esse vocabulário compartilhado, o mesmo cenário gera:

“Por que há uma interface aqui com apenas uma implementação? Isso parece desperdício.”

…seguido de uma refatoração bem-intencionada que remove a abstração, gerando acoplamento, tornando outro código mais difícil de testar, e quebrando o comportamento em produção.

Diversidade de times: um ativo com custo de coordenação

Times modernos de software são multidisciplinares por necessidade:

PapelFoco principalPerspectiva de arquitetura
Desenvolvedor BackendLógica de negócio, APIsPadrões de domínio, CQRS, Event Sourcing
Desenvolvedor FrontendUX, performance de renderPadrões de componente, estado global
Data EngineerPipelines, ETL, analyticsPadrões de dados, streaming
DevOps / SREDisponibilidade, observabilidadePadrões de infra, resiliência
Arquiteto de SoluçõesIntegração, domínioPadrões enterprise, contextos delimitados
Tech Lead / Staff EngDecisões de plataformaTodos os níveis
Sustentação N1/N2Triagem, correções rápidasPadrões de manutenibilidade

O risco de times sem linguagem comum:

  • Um Data Engineer implementa um pipeline com lógica de negócio que duplica o que já existe no domínio
  • Um DevOps cria scripts de deploy que assumem detalhes de implementação que o time de backend mudou
  • A sustentação de N2 corrige um bug no Adapter sem entender que há múltiplas implementações, quebrando outra

A arquitetura documentada — com padrões nomeados, decision records (ADRs), e diagramas de contexto — é o que permite que pessoas com perspectivas diferentes contribuam sem colisão.

Architecture Decision Records (ADRs)

Para cada decisão arquitetural relevante, documente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# ADR-007: Uso do padrão Strategy para cálculo de frete

**Status:** Aceito  
**Data:** 2025-03-15  
**Contexto:** O cálculo de frete precisa suportar múltiplas transportadoras,
com possibilidade de adicionar novas sem alterar o código existente.

**Decisão:** Implementar usando o padrão Strategy com injeção de dependência.
A seleção da estratégia será feita na composition root baseada na configuração.

**Consequências:**
- ✅ Adicionar nova transportadora = criar nova classe, sem alterar código existente
- ✅ Cada estratégia é testável de forma isolada
- ✅ A regra de seleção fica explícita e centralizada
- ⚠️ Mais classes no projeto (aceitável, cada uma tem responsabilidade clara)
- ❌ Não usar: quando há apenas uma forma de calcular e não há perspectiva de mudança

**Padrões relacionados:** Factory Method (para criar a Strategy correta), Open/Closed Principle

Esse registro vale ouro para o time de sustentação 3 anos depois. Sem ele, a pergunta “por que tem essa interface?” não tem resposta — e a resposta improvisada costuma ser errada.


Princípios que Sustentam Tudo

Os padrões GoF não existem no vácuo. Eles são expressões práticas de princípios mais fundamentais. Os mais importantes:

SOLID

PrincípioDefiniçãoPadrão GoF que o expressa
S — Single ResponsibilityUma classe, uma razão para mudarFacade (isola responsabilidades), Command
O — Open/ClosedAberto para extensão, fechado para modificaçãoStrategy, Decorator, Observer
L — Liskov SubstitutionSubclasses devem ser substituíveis pelas basesFactory Method, Template Method
I — Interface SegregationInterfaces pequenas e focadasAdapter, Proxy
D — Dependency InversionDependa de abstrações, não de implementaçõesTodos os padrões via DI

Coesão e Acoplamento

Alta coesão, baixo acoplamento — talvez o princípio mais antigo e mais violado da engenharia de software.

  • Alta coesão: cada módulo/classe faz uma coisa bem feita. Facade, Strategy, Command promovem coesão.
  • Baixo acoplamento: módulos têm poucas dependências entre si. Observer, Mediator, Event Bus reduzem acoplamento.

Lei de Demeter (Princípio do Menor Conhecimento)

Um módulo deve conhecer apenas seus colaboradores imediatos — não os colaboradores dos colaboradores.

1
2
3
4
5
6
7
// ❌ Viola Demeter — "train wreck code"
// O serviço conhece a estrutura interna de 4 objetos diferentes:
var cidade = pedido.Cliente.Endereco.Cidade.Nome;

// ✅ Respeita Demeter — adicionar método no objeto responsável:
var cidade = pedido.ObterCidadeEntrega();
// "Pegue apenas uma etapa por vez"

Armadilhas: Quando Padrões Atrapalham

Padrões aplicados sem julgamento são tão prejudiciais quanto nenhum padrão. Os anti-padrões mais comuns:

Overengineering (“patternite”)

1
2
3
4
5
6
7
// ❌ Factory para uma única implementação que não vai mudar:
public interface ICalculadorImposto { decimal Calcular(decimal valor); }
public class CalculadorImpostoFactory { public ICalculadorImposto Criar() => new CalculadorImposto(); }
public class CalculadorImposto : ICalculadorImposto { public decimal Calcular(decimal v) => v * 0.12m; }

// ✅ Quando há apenas uma implementação e nenhuma previsão de mudança:
public static decimal CalcularImposto(decimal valor) => valor * 0.12m;

Singleton como variável global

O Singleton é o padrão mais abusado. Quando tudo é Singleton, você essencialmente recria estado global:

1
2
3
4
5
6
7
8
9
// ❌ Singleton como acesso global (dificulta testes, oculta dependências):
var db = DatabaseSingleton.Instancia;       // acoplamento global
var cache = CacheSingleton.Instancia;       // impossível testar isoladamente
var config = ConfigSingleton.Instancia;     // mudanças afetam todo o app silenciosamente

// ✅ Singleton gerenciado pelo contêiner de DI (testável, substituível):
services.AddSingleton<IDatabaseContext, DatabaseContext>();
services.AddSingleton<ICache, RedisCache>();
// Nos testes: AddSingleton<ICache, InMemoryCache>()

Abstração prematura

Não use padrões para problemas que você ainda não tem:

“You Aren’t Gonna Need It” (YAGNI) — não adicione complexidade para necessidades hipotéticas.

A regra prática: quando o problema aparecer pela segunda vez, considere um padrão. Quando aparecer pela terceira, aplique-o.


Conclusão

Os padrões GoF completam 30 anos e continuam na lista de leitura obrigatória de engenheiros de software ao redor do mundo — não por veneração nostálgica, mas porque os problemas que eles resolvem não desapareceram. Nomear soluções para problemas recorrentes é uma das ferramentas mais poderosas da engenharia.

O que mudou não foram os problemas fundamentais, mas o contexto em que eles aparecem:

  • Em um monólito, um Observer mal aplicado gera código difícil de ler. Em um sistema distribuído, ele gera uma cascata de falhas que derruba múltiplos serviços.
  • Em um projeto de um desenvolvedor, um Singleton sem DI é incômodo. Em um time de 50 pessoas, ele cria dependências ocultas que ninguém vê até o sistema cair em produção.
  • Em um ambiente on-premises estático, acoplamento a uma implementação específica é gerenciável. No ecossistema cloud híbrido, ele cria dívidas de migração de meses.

A arquitetura de software — especialmente seus padrões nomeados — é o que permite que sistemas sobrevivam além de sua geração original de desenvolvedores. É o que faz com que um desenvolvedor que entra num projeto hoje possa entender as intenções de design de quem construiu o sistema há 5 anos. É o que faz com que o time de sustentação possa corrigir um bug sem acidentalmente introduzir três novos.

Aprenda os 23 padrões GoF. Não para aplicar todos eles — mas para ter um vocabulário que permite pensar, comunicar e documentar decisões de design com precisão. O software que você escreve hoje alguém vai manter amanhã. Deixe mensagens claras.


Leia Também


Referências