Javascript

Padrões de Projeto em JavaScript Já leu

23 min de leitura

Padrões de Projeto em JavaScript
Depois de anos resolvendo os mesmos tipos de problemas, a indústria de software percebeu que certas soluções elegantes se repetem independentemente da linguagem ou do domínio. Esses padrões foram catalogados, nomeados e

Depois de anos resolvendo os mesmos tipos de problemas, a indústria de software percebeu que certas soluções elegantes se repetem independentemente da linguagem ou do domínio. Esses padrões foram catalogados, nomeados e documentados — tornando possível comunicar soluções complexas com uma única palavra.

Quando um desenvolvedor diz "vamos usar um Observer aqui" ou "isso parece um Strategy", toda a equipe imediatamente entende a estrutura da solução proposta. Padrões de projeto são um vocabulário compartilhado.

O livro clássico que catalogou 23 padrões fundamentais foi publicado em 1994 pelo "Gang of Four" — Erich Gamma, Richard Helm, Ralph Johnson e John Vlissides. Décadas depois, esses padrões continuam relevantes. Neste artigo vamos estudar os mais úteis no ecossistema JavaScript moderno, com exemplos práticos que você já poderia usar hoje na sua aplicação.


As três categorias de padrões

Os padrões são organizados em três categorias segundo o tipo de problema que resolvem.

Padrões Criacionais lidam com a criação de objetos. Quando a criação direta com new se torna rígida demais ou muito acoplada, esses padrões oferecem alternativas mais flexíveis.

Padrões Estruturais lidam com a composição de classes e objetos. Como combinar partes menores em estruturas maiores mantendo o sistema flexível.

Padrões Comportamentais lidam com a comunicação entre objetos. Como definir responsabilidades e algoritmos de forma que possam variar independentemente.


Padrões Criacionais

Singleton — uma única instância

O Singleton garante que uma classe tenha apenas uma instância e fornece um ponto de acesso global a ela. Em JavaScript, módulos já são singletons por natureza — o Node.js faz cache do módulo na primeira importação e retorna o mesmo objeto nas subsequentes.

Este padrão é ideal para conexões de banco de dados, clientes de API, caches e configurações globais — recursos que devem ser compartilhados em toda a aplicação.

// apps/api/src/config/database.js
// O módulo inteiro é um singleton — mongoose mantém uma única conexão
// por processo. Múltiplos require() do mesmo módulo retornam o mesmo objeto.

const mongoose = require('mongoose');

let conectado = false;

// A função protege contra múltiplas chamadas simultâneas ao connect()
// que poderiam causar avisos do Mongoose
async function conectar() {
  if (conectado) {
    console.log('[DB] Já conectado — reutilizando conexão existente.');
    return;
  }

  await mongoose.connect(process.env.MONGODB_URL);
  conectado = true;
  console.log('[DB] Conexão estabelecida.');
}

function desconectar() {
  return mongoose.connection.close();
}

// O objeto exportado é o mesmo em todos os arquivos que o importam
// Node.js armazena em cache — primeira require executa o código,
// as demais retornam o exports já calculado
module.exports = { conectar, desconectar };
// Singleton explícito — para casos onde o módulo não é suficiente
// Útil quando a instância precisa ser criada com parâmetros dinâmicos

class GerenciadorDeCache {
  // Guarda a instância no próprio constructor como propriedade estática
  static #instancia = null;

  #cache = new Map();
  #ttlPadrao;

  // Construtor privado (convenção com _) — não use new diretamente
  constructor(ttlPadrao = 300) {
    if (GerenciadorDeCache.#instancia) {
      // Se alguém tentar criar uma segunda instância, retorna a existente
      return GerenciadorDeCache.#instancia;
    }
    this.#ttlPadrao = ttlPadrao;
    GerenciadorDeCache.#instancia = this;
  }

  static obterInstancia(ttlPadrao) {
    if (!GerenciadorDeCache.#instancia) {
      new GerenciadorDeCache(ttlPadrao);
    }
    return GerenciadorDeCache.#instancia;
  }

  definir(chave, valor, ttl = this.#ttlPadrao) {
    const expira = Date.now() + ttl * 1000;
    this.#cache.set(chave, { valor, expira });
  }

  obter(chave) {
    const entrada = this.#cache.get(chave);
    if (!entrada) return null;
    // Verifica se o item expirou antes de retornar
    if (Date.now() > entrada.expira) {
      this.#cache.delete(chave);
      return null;
    }
    return entrada.valor;
  }

  deletar(chave) {
    this.#cache.delete(chave);
  }

  limpar() {
    this.#cache.clear();
  }
}

// Independente de onde for importado, é sempre a mesma instância
const cache = GerenciadorDeCache.obterInstancia(300);
module.exports = cache;

Factory — criando objetos sem expor a implementação

O Factory (ou Factory Method) define uma interface para criar um objeto, mas deixa as subclasses ou funções específicas decidirem qual classe instanciar. Desacopla o código que usa o objeto do código que o cria.

Este padrão é especialmente útil quando o tipo de objeto a ser criado depende de configuração ou contexto em tempo de execução.

// Exemplo prático: fábrica de serviços de notificação
// A aplicação não precisa saber qual serviço está sendo usado

// Interface que todos os notificadores devem implementar
class Notificador {
  async enviar(destinatario, assunto, mensagem) {
    throw new Error('enviar() deve ser implementado.');
  }
}

// Implementação para email
class NotificadorEmail extends Notificador {
  constructor(config) {
    super();
    // Inicializa nodemailer ou outro serviço de email com a config
    this.config = config;
  }

  async enviar(destinatario, assunto, mensagem) {
    console.log(`[Email] Para: ${destinatario} | Assunto: ${assunto}`);
    // await transportador.sendMail(...)
  }
}

// Implementação para SMS
class NotificadorSMS extends Notificador {
  constructor(config) {
    super();
    this.config = config;
  }

  async enviar(destinatario, assunto, mensagem) {
    // assunto é ignorado em SMS — só a mensagem importa
    console.log(`[SMS] Para: ${destinatario} | Msg: ${mensagem}`);
    // await twilioClient.messages.create(...)
  }
}

// Implementação para log em console (útil em desenvolvimento/testes)
class NotificadorConsole extends Notificador {
  async enviar(destinatario, assunto, mensagem) {
    console.log(`[Notificação] ${assunto}: ${mensagem} → ${destinatario}`);
  }
}

// A Factory — decide qual implementação usar baseado na configuração
// O código que chama createNotificador() não precisa saber qual classe usar
function criarNotificador(tipo = process.env.NOTIFICADOR || 'console') {
  const config = {
    email: {
      host: process.env.SMTP_HOST,
      port: Number(process.env.SMTP_PORT) || 587,
      auth: {
        user: process.env.SMTP_USER,
        pass: process.env.SMTP_PASS,
      },
    },
    sms: {
      accountSid: process.env.TWILIO_SID,
      authToken: process.env.TWILIO_TOKEN,
      from: process.env.TWILIO_FROM,
    },
  };

  switch (tipo) {
    case 'email':
      return new NotificadorEmail(config.email);
    case 'sms':
      return new NotificadorSMS(config.sms);
    case 'console':
    default:
      return new NotificadorConsole();
  }
}

// Uso — o serviço de tarefas não sabe nada sobre email ou SMS
class ServicoTarefa {
  constructor() {
    // Em produção usa email, em teste usa console — configurado por variável de ambiente
    this.notificador = criarNotificador();
  }

  async criarComLembrete(usuarioEmail, dados) {
    const tarefa = await Tarefa.create(dados);

    // Não importa qual notificador está sendo usado — a interface é a mesma
    await this.notificador.enviar(
      usuarioEmail,
      'Nova tarefa criada',
      `Sua tarefa "${tarefa.titulo}" foi criada com prazo para ${tarefa.prazo}.`
    );

    return tarefa;
  }
}

Builder — construindo objetos complexos passo a passo

O Builder separa a construção de um objeto complexo da sua representação, permitindo que o mesmo processo de construção crie diferentes representações. É especialmente útil para objetos com muitas configurações opcionais ou quando a ordem de configuração importa.

// Construindo queries MongoDB complexas de forma legível e segura

class QueryBuilder {
  #model;
  #filtros = {};
  #projecao = null;
  #ordenacao = null;
  #paginacao = { skip: 0, limit: 10 };
  #populares = [];
  #usarLean = false;

  // O construtor recebe apenas o obrigatório — o model
  constructor(model) {
    this.#model = model;
  }

  // Cada método de configuração retorna this — permite encadeamento fluente
  onde(filtros) {
    // Mescla com filtros existentes — permite chamadas múltiplas
    this.#filtros = { ...this.#filtros, ...filtros };
    return this;
  }

  campos(...campos) {
    this.#projecao = campos.join(' ');
    return this;
  }

  ordenarPor(campo, direcao = 'asc') {
    const prefixo = direcao === 'desc' ? '-' : '';
    this.#ordenacao = `${prefixo}${campo}`;
    return this;
  }

  paginar(pagina, porPagina = 10) {
    const paginaNum = Math.max(1, pagina);
    const limite = Math.min(50, porPagina);
    this.#paginacao = {
      skip: (paginaNum - 1) * limite,
      limit: limite,
    };
    return this;
  }

  popular(campo, campos = '') {
    this.#populares.push({ path: campo, select: campos });
    return this;
  }

  lean() {
    this.#usarLean = true;
    return this;
  }

  // build() executa a query com todas as configurações acumuladas
  async build() {
    let query = this.#model.find(this.#filtros);

    if (this.#projecao) query = query.select(this.#projecao);
    if (this.#ordenacao) query = query.sort(this.#ordenacao);

    query = query.skip(this.#paginacao.skip).limit(this.#paginacao.limit);

    for (const pop of this.#populares) {
      query = query.populate(pop.path, pop.select);
    }

    if (this.#usarLean) query = query.lean();

    return query;
  }

  // buildComTotal() retorna dados e total para paginação
  async buildComTotal() {
    const [dados, total] = await Promise.all([
      this.build(),
      this.#model.countDocuments(this.#filtros),
    ]);

    return {
      dados,
      total,
      pagina: Math.floor(this.#paginacao.skip / this.#paginacao.limit) + 1,
      totalPaginas: Math.ceil(total / this.#paginacao.limit),
    };
  }
}

// Uso — leitura fluente que documenta a intenção
const resultado = await new QueryBuilder(Tarefa)
  .onde({ usuario: req.usuario._id, status: 'pendente' })
  .onde({ prioridade: 'alta' })        // pode chamar onde() múltiplas vezes
  .campos('titulo', 'prazo', 'prioridade')
  .ordenarPor('prazo', 'asc')          // mais urgentes primeiro
  .paginar(req.query.pagina, 10)
  .lean()
  .buildComTotal();

Padrões Estruturais

Adapter — compatibilizando interfaces incompatíveis

O Adapter converte a interface de uma classe em outra interface que o cliente espera. É o padrão que você usa quando precisa integrar uma biblioteca externa cujas interfaces não combinam com o seu código.

Em aplicações reais, o Adapter é essencial para isolar dependências externas — se você precisar trocar a biblioteca, apenas o adapter muda.

// Problema: diferentes serviços de pagamento têm APIs completamente diferentes
// Solução: um Adapter para cada serviço que expõe a mesma interface

// Interface comum que nossa aplicação usa — estável, independente do provedor
class ServicoPagamento {
  async cobrar(valor, moeda, dadosCartao, descricao) {
    throw new Error('cobrar() deve ser implementado.');
  }

  async reembolsar(transacaoId, valor) {
    throw new Error('reembolsar() deve ser implementado.');
  }
}

// Adapter para Stripe — traduz nossa interface para a API do Stripe
class StripeAdapter extends ServicoPagamento {
  #stripe;

  constructor(chaveSecreta) {
    super();
    // Stripe tem sua própria API — o adapter esconde essa complexidade
    const Stripe = require('stripe');
    this.#stripe = new Stripe(chaveSecreta);
  }

  async cobrar(valor, moeda, dadosCartao, descricao) {
    // Converte nossos parâmetros para o formato que o Stripe espera
    // Stripe trabalha com centavos — multiplica por 100
    const intencao = await this.#stripe.paymentIntents.create({
      amount: Math.round(valor * 100),
      currency: moeda.toLowerCase(),
      payment_method_data: {
        type: 'card',
        card: {
          number: dadosCartao.numero,
          exp_month: dadosCartao.mesValidade,
          exp_year: dadosCartao.anoValidade,
          cvc: dadosCartao.cvv,
        },
      },
      description: descricao,
      confirm: true,
    });

    // Traduz a resposta do Stripe para o nosso formato padrão
    return {
      id: intencao.id,
      status: intencao.status === 'succeeded' ? 'aprovado' : 'pendente',
      valor: intencao.amount / 100,
      moeda: intencao.currency.toUpperCase(),
    };
  }

  async reembolsar(transacaoId, valor) {
    const reembolso = await this.#stripe.refunds.create({
      payment_intent: transacaoId,
      amount: valor ? Math.round(valor * 100) : undefined,
    });

    return {
      id: reembolso.id,
      status: reembolso.status === 'succeeded' ? 'aprovado' : 'pendente',
      valor: reembolso.amount / 100,
    };
  }
}

// Adapter para PagSeguro — mesma interface, implementação completamente diferente
class PagSeguroAdapter extends ServicoPagamento {
  #apiUrl;
  #token;

  constructor(token, sandbox = false) {
    super();
    this.#token = token;
    this.#apiUrl = sandbox
      ? 'https://sandbox.api.pagseguro.com'
      : 'https://api.pagseguro.com';
  }

  async cobrar(valor, moeda, dadosCartao, descricao) {
    // PagSeguro tem uma API completamente diferente — mas o código
    // que chama cobrar() não precisa saber disso
    const resposta = await fetch(`${this.#apiUrl}/charges`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${this.#token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        reference_id: crypto.randomUUID(),
        description: descricao,
        amount: { value: Math.round(valor * 100), currency: moeda },
        payment_method: {
          type: 'CREDIT_CARD',
          card: {
            number: dadosCartao.numero,
            exp_month: dadosCartao.mesValidade,
            exp_year: dadosCartao.anoValidade,
            security_code: dadosCartao.cvv,
          },
        },
      }),
    });

    const dados = await resposta.json();

    return {
      id: dados.id,
      status: dados.status === 'PAID' ? 'aprovado' : 'pendente',
      valor,
      moeda,
    };
  }

  async reembolsar(transacaoId, valor) {
    // implementação específica do PagSeguro...
  }
}

// Factory que cria o adapter correto conforme configuração
function criarServicoPagamento() {
  const provedor = process.env.PAGAMENTO_PROVEDOR || 'stripe';

  switch (provedor) {
    case 'stripe':
      return new StripeAdapter(process.env.STRIPE_SECRET_KEY);
    case 'pagseguro':
      return new PagSeguroAdapter(
        process.env.PAGSEGURO_TOKEN,
        process.env.NODE_ENV !== 'production'
      );
    default:
      throw new Error(`Provedor de pagamento desconhecido: ${provedor}`);
  }
}

// O serviço de pedidos usa ServicoPagamento — não Stripe nem PagSeguro diretamente
class ServicoPedido {
  #pagamento;

  constructor() {
    this.#pagamento = criarServicoPagamento();
  }

  async finalizar(pedido, dadosCartao) {
    // Este código funciona com qualquer provedor de pagamento
    // Trocar de Stripe para PagSeguro = mudar uma variável de ambiente
    const transacao = await this.#pagamento.cobrar(
      pedido.total,
      'BRL',
      dadosCartao,
      `Pedido #${pedido.id}`
    );

    await Pedido.findByIdAndUpdate(pedido.id, {
      transacaoId: transacao.id,
      status: transacao.status === 'aprovado' ? 'pago' : 'pendente',
    });

    return transacao;
  }
}

Decorator — adicionando comportamento sem modificar a classe

O Decorator envolve um objeto para adicionar comportamento novo sem alterar a classe original. É uma alternativa elegante à herança quando você precisa adicionar funcionalidades de forma opcional e combinável.

// Decorando funções de repositório com cache, logging e retry

// Repositório base — operações simples de banco
class RepositorioProduto {
  async buscarPorId(id) {
    return Produto.findById(id).lean();
  }

  async listar(filtros) {
    return Produto.find(filtros).lean();
  }

  async salvar(dados) {
    return Produto.create(dados);
  }
}

// Decorator de Cache — adiciona cache sem modificar o repositório
class RepositorioProdutoComCache {
  #repositorio;
  #cache;
  #ttl;

  constructor(repositorio, cache, ttl = 300) {
    this.#repositorio = repositorio;
    this.#cache = cache;
    this.#ttl = ttl;
  }

  async buscarPorId(id) {
    const chave = `produto:${id}`;

    // Tenta o cache primeiro
    const emCache = this.#cache.obter(chave);
    if (emCache) return emCache;

    // Cache miss — delega para o repositório decorado
    const produto = await this.#repositorio.buscarPorId(id);

    if (produto) this.#cache.definir(chave, produto, this.#ttl);
    return produto;
  }

  async listar(filtros) {
    // Listagens não são cacheadas por padrão (filtros variáveis)
    return this.#repositorio.listar(filtros);
  }

  async salvar(dados) {
    const produto = await this.#repositorio.salvar(dados);
    // Invalida cache de listas após criação
    return produto;
  }
}

// Decorator de Logging — adiciona observabilidade
class RepositorioProdutoComLog {
  #repositorio;
  #logger;

  constructor(repositorio, logger = console) {
    this.#repositorio = repositorio;
    this.#logger = logger;
  }

  async buscarPorId(id) {
    const inicio = performance.now();
    try {
      const resultado = await this.#repositorio.buscarPorId(id);
      const duracao = (performance.now() - inicio).toFixed(2);
      this.#logger.log(`[Repo] buscarPorId(${id}) — ${duracao}ms`);
      return resultado;
    } catch (erro) {
      this.#logger.error(`[Repo] buscarPorId(${id}) falhou: ${erro.message}`);
      throw erro;
    }
  }

  async listar(filtros) {
    const inicio = performance.now();
    const resultado = await this.#repositorio.listar(filtros);
    const duracao = (performance.now() - inicio).toFixed(2);
    this.#logger.log(`[Repo] listar() — ${resultado.length} itens em ${duracao}ms`);
    return resultado;
  }

  async salvar(dados) {
    const resultado = await this.#repositorio.salvar(dados);
    this.#logger.log(`[Repo] salvar() — produto criado: ${resultado._id}`);
    return resultado;
  }
}

// Composição de decorators — ordem importa
// A requisição passa por: Log → Cache → Repositório Base
const repositorio = new RepositorioProdutoComLog(
  new RepositorioProdutoComCache(
    new RepositorioProduto(),
    cache,
    300
  )
);

// O controller usa apenas repositorio — não sabe dos decorators
const produto = await repositorio.buscarPorId('123');

Padrões Comportamentais

Observer — reagindo a eventos

O Observer define uma dependência um-para-muitos entre objetos: quando um objeto muda de estado, todos os seus dependentes são notificados automaticamente. É a base de sistemas de eventos, pub/sub, e da própria reatividade do React.

// Sistema de eventos para a API — desacopla ações de reações

class EventEmitter {
  #handlers = new Map();

  // Registra um handler para um evento
  on(evento, handler) {
    if (!this.#handlers.has(evento)) {
      this.#handlers.set(evento, new Set());
    }
    this.#handlers.get(evento).add(handler);

    // Retorna função de cleanup — facilita remoção
    return () => this.off(evento, handler);
  }

  // Remove um handler específico
  off(evento, handler) {
    this.#handlers.get(evento)?.delete(handler);
  }

  // Handler que se remove automaticamente após a primeira execução
  once(evento, handler) {
    const wrapper = (dados) => {
      handler(dados);
      this.off(evento, wrapper);
    };
    return this.on(evento, wrapper);
  }

  // Notifica todos os handlers registrados para um evento
  async emit(evento, dados) {
    const handlers = this.#handlers.get(evento);
    if (!handlers) return;

    // Executa todos os handlers — erros em um não afetam os outros
    await Promise.allSettled(
      [...handlers].map((handler) =>
        Promise.resolve(handler(dados)).catch((erro) =>
          console.error(`[EventEmitter] Erro no handler de "${evento}":`, erro.message)
        )
      )
    );
  }
}

// Instância global de eventos da aplicação
const eventos = new EventEmitter();

// ── Publicando eventos no controller ────────────────
// O controller não sabe QUEM vai reagir ao evento — apenas emite
async function criarTarefa(req, res, next) {
  try {
    const tarefa = await Tarefa.create({
      ...req.body,
      usuario: req.usuario._id,
    });

    // Emite evento — completamente desacoplado de quem vai ouvir
    await eventos.emit('tarefa:criada', {
      tarefa,
      usuario: req.usuario,
    });

    res.status(201).json(tarefa);
  } catch (erro) {
    next(erro);
  }
}

// ── Ouvintes registrados no startup da aplicação ────
// Cada ouvinte tem uma responsabilidade específica e isolada

// Ouvinte 1: enviar email de confirmação
eventos.on('tarefa:criada', async ({ tarefa, usuario }) => {
  await notificador.enviar(
    usuario.email,
    'Nova tarefa criada',
    `Sua tarefa "${tarefa.titulo}" foi criada com sucesso.`
  );
});

// Ouvinte 2: registrar no log de auditoria
eventos.on('tarefa:criada', async ({ tarefa, usuario }) => {
  await Auditoria.create({
    acao: 'TAREFA_CRIADA',
    recurso: 'Tarefa',
    recursoId: tarefa._id,
    usuarioId: usuario._id,
    dados: { titulo: tarefa.titulo },
    timestamp: new Date(),
  });
});

// Ouvinte 3: invalidar cache de estatísticas
eventos.on('tarefa:criada', ({ usuario }) => {
  cache.deletar(`stats:${usuario._id}`);
});

// Benefício: adicionar um novo comportamento ao criar tarefa
// = adicionar um novo eventos.on() — sem tocar no controller

Strategy — algoritmos intercambiáveis

O Strategy define uma família de algoritmos, encapsula cada um e os torna intercambiáveis. Permite que o algoritmo varie independentemente dos clientes que o utilizam. É perfeito para sistemas de ordenação, filtragem, cálculo de preços e qualquer lógica que pode variar.

// Sistema de ordenação de produtos com estratégias intercambiáveis

// Cada estratégia é uma função pura — fácil de testar, fácil de adicionar
const estrategiasOrdenacao = {
  // Ordena por preço do menor para o maior
  precoAscendente: (a, b) => a.preco - b.preco,

  // Ordena por preço do maior para o menor
  precoDescendente: (a, b) => b.preco - a.preco,

  // Ordena por nome em ordem alfabética
  nomeAlfabetico: (a, b) => a.nome.localeCompare(b.nome, 'pt-BR'),

  // Ordena por data de criação — mais novos primeiro
  maisRecentes: (a, b) => new Date(b.criadoEm) - new Date(a.criadoEm),

  // Ordena por relevância — produtos em estoque primeiro, depois por preço
  relevancia: (a, b) => {
    // Produtos sem estoque vão para o final
    if (a.estoque === 0 && b.estoque > 0) return 1;
    if (a.estoque > 0 && b.estoque === 0) return -1;
    // Entre produtos com estoque, ordena por preço
    return a.preco - b.preco;
  },
};

// Calculador de frete com estratégias por região
const estrategiasFrete = {
  padrão: (peso, distancia) => {
    const baseKg = 2.5;
    const basekm = 0.01;
    return peso * baseKg + distancia * basekm;
  },

  expresso: (peso, distancia) => {
    // 3x mais caro, mínimo de R$ 15
    const base = estrategiasFrete.padrão(peso, distancia) * 3;
    return Math.max(base, 15);
  },

  retirada: () => 0, // grátis para retirada em loja

  gratisAcimaDe200: (peso, distancia, valorPedido) => {
    if (valorPedido >= 200) return 0;
    return estrategiasFrete.padrão(peso, distancia);
  },
};

// Serviço de pedido que usa as estratégias
class ServicoPedido {
  calcularFrete(modalidade, peso, distancia, valorPedido) {
    const estrategia = estrategiasFrete[modalidade];

    if (!estrategia) {
      throw new Error(`Modalidade de frete desconhecida: ${modalidade}`);
    }

    return estrategia(peso, distancia, valorPedido);
  }
}

// No React — Strategy para renderização condicional por papel do usuário
const visualizacoesPorPapel = {
  admin: AdminDashboard,
  gerente: GerenteDashboard,
  vendedor: VendedorDashboard,
  cliente: ClienteDashboard,
};

function Dashboard() {
  const papel = useAuthStore((s) => s.usuario?.papel);

  // Seleciona dinamicamente qual componente renderizar
  const ComponenteDashboard = visualizacoesPorPapel[papel] || ClienteDashboard;

  return <ComponenteDashboard />;
}

Command — encapsulando operações como objetos

O Command encapsula uma requisição como um objeto, permitindo parametrizar clientes com diferentes requisições, enfileirar ou fazer log de requisições, e suportar operações desfazíveis.

// Sistema de operações desfazíveis — útil em editores, formulários complexos

class GerenciadorComandos {
  #historico = [];
  #posicao = -1; // posição atual no histórico

  async executar(comando) {
    // Remove tudo após a posição atual (descarta "futuros" após nova ação)
    this.#historico = this.#historico.slice(0, this.#posicao + 1);

    await comando.executar();

    this.#historico.push(comando);
    this.#posicao++;

    console.log(`[Comando] Executado: ${comando.descricao}`);
  }

  async desfazer() {
    if (this.#posicao < 0) {
      throw new Error('Nada para desfazer.');
    }

    const comando = this.#historico[this.#posicao];
    await comando.desfazer();
    this.#posicao--;

    console.log(`[Comando] Desfeito: ${comando.descricao}`);
  }

  async refazer() {
    if (this.#posicao >= this.#historico.length - 1) {
      throw new Error('Nada para refazer.');
    }

    this.#posicao++;
    const comando = this.#historico[this.#posicao];
    await comando.executar();

    console.log(`[Comando] Refeito: ${comando.descricao}`);
  }

  get podeDesfazer() { return this.#posicao >= 0; }
  get podeRefazer() { return this.#posicao < this.#historico.length - 1; }
}

// Comando concreto: alterar status de uma tarefa
class ComandoAlterarStatusTarefa {
  #tarefaId;
  #novoStatus;
  #statusAnterior = null;

  constructor(tarefaId, novoStatus) {
    this.#tarefaId = tarefaId;
    this.#novoStatus = novoStatus;
    this.descricao = `Alterar status da tarefa ${tarefaId} para ${novoStatus}`;
  }

  async executar() {
    // Salva o estado anterior para poder desfazer
    const tarefa = await Tarefa.findById(this.#tarefaId);
    this.#statusAnterior = tarefa.status;

    await Tarefa.findByIdAndUpdate(this.#tarefaId, {
      status: this.#novoStatus,
    });
  }

  async desfazer() {
    if (!this.#statusAnterior) {
      throw new Error('Comando não foi executado ainda.');
    }

    await Tarefa.findByIdAndUpdate(this.#tarefaId, {
      status: this.#statusAnterior,
    });
  }
}

// Uso
const gerenciador = new GerenciadorComandos();

await gerenciador.executar(
  new ComandoAlterarStatusTarefa('tarefa-123', 'concluida')
);

// Ops — errei
await gerenciador.desfazer(); // volta para o status anterior

// Afinal era isso mesmo
await gerenciador.refazer(); // aplica novamente

Padrões no React — os mais usados no front-end

O ecossistema React tem seus próprios padrões que emergiram da prática. Eles não são do GoF mas são igualmente importantes no dia a dia.

// ── Compound Components ────────────────────────────
// Componentes que trabalham juntos, compartilhando estado implicitamente
// Inspirado em elementos HTML como <select> e <option>

import { createContext, useContext, useState } from 'react';

const TabsContext = createContext(null);

// Componente pai que gerencia o estado
function Tabs({ children, defaultTab }) {
  const [abaAtiva, setAbaAtiva] = useState(defaultTab);

  return (
    <TabsContext.Provider value={{ abaAtiva, setAbaAtiva }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

// Componentes filhos que acessam o estado do pai via Context
function TabsList({ children }) {
  return <div className="tabs-list" role="tablist">{children}</div>;
}

function Tab({ id, children }) {
  const { abaAtiva, setAbaAtiva } = useContext(TabsContext);
  const ativa = abaAtiva === id;

  return (
    <button
      role="tab"
      aria-selected={ativa}
      className={`tab ${ativa ? 'tab--ativa' : ''}`}
      onClick={() => setAbaAtiva(id)}
    >
      {children}
    </button>
  );
}

function TabPanel({ id, children }) {
  const { abaAtiva } = useContext(TabsContext);

  if (abaAtiva !== id) return null;
  return (
    <div role="tabpanel" className="tab-panel">
      {children}
    </div>
  );
}

// API de uso expressiva e flexível
function PaginaProduto({ produto }) {
  return (
    <Tabs defaultTab="descricao">
      <TabsList>
        <Tab id="descricao">Descrição</Tab>
        <Tab id="especificacoes">Especificações</Tab>
        <Tab id="avaliacoes">Avaliações</Tab>
      </TabsList>

      <TabPanel id="descricao">
        <p>{produto.descricao}</p>
      </TabPanel>
      <TabPanel id="especificacoes">
        <EspecificacoesProduto produto={produto} />
      </TabPanel>
      <TabPanel id="avaliacoes">
        <AvaliacoesProduto produtoId={produto.id} />
      </TabPanel>
    </Tabs>
  );
}

Tarefa para você

Aplique os padrões na aplicação do Módulo 6:

// 1. Singleton — verifique que a conexão com o banco (database.js)
//    realmente é um singleton. Adicione um log que confirme que conectar()
//    chamado duas vezes não abre duas conexões.

// 2. Factory — crie uma NotificadorFactory que retorne NotificadorEmail
//    em produção e NotificadorConsole em desenvolvimento/testes.
//    Integre ao serviço de tarefas.

// 3. Builder — crie um QueryBuilder para Tarefa com suporte a:
//    .onde(), .campos(), .paginar(), .ordenarPor(), .lean()
//    Refatore a rota GET /tarefas para usar o builder.

// 4. Observer — adicione um EventEmitter à aplicação e registre
//    pelo menos dois ouvintes para 'tarefa:criada':
//    - invalidar cache de estatísticas
//    - logar no console (em produção, seria o serviço de auditoria)

// 5. Strategy — implemente três estratégias de ordenação de produtos:
//    precoAscendente, precoDescendente, maisRecentes.
//    Aceite o parâmetro ?ordenar= na rota GET /produtos.

// 6. Compound Components — implemente o componente <Tabs>
//    e use-o na página de detalhe de produto com abas:
//    "Informações", "Descrição", "Estoque".

Conclusão

Neste artigo você aprendeu:

  • O que são padrões de projeto e por que são um vocabulário compartilhado
  • As três categorias: criacionais, estruturais e comportamentais
  • Singleton — uma instância global compartilhada; como módulos Node.js já o implementam
  • Factory — criando objetos sem expor a implementação; trocar provedor sem mudar código
  • Builder — construindo objetos complexos com API fluente e legível
  • Adapter — compatibilizando interfaces incompatíveis; isolando dependências externas
  • Decorator — adicionando comportamento sem modificar classes; cache e logging compostos
  • Observer — notificações desacopladas; separando ações de reações na API
  • Strategy — algoritmos intercambiáveis em tempo de execução; ordenações e cálculos
  • Command — encapsulando operações como objetos; suporte a desfazer/refazer
  • Compound Components — padrão React para componentes compostos com estado implícito

No próximo artigo vamos aprender sobre arquitetura de software — como organizar grandes aplicações com Domain-Driven Design, Clean Architecture e princípios SOLID.


 

📚 Fontes e Referências

  • Design Patterns — Gang of Four: Gamma, Helm, Johnson, Vlissides (Addison-Wesley)
  • Patterns of Enterprise Application Architecture — Martin Fowler (Addison-Wesley)
  • JavaScript Patterns — Stoyan Stefanov (O'Reilly)
  • Refactoring Guru — Padrões de Projeto: https://refactoring.guru/pt-br/design-patterns
  • Patterns.dev — Padrões modernos em React: https://www.patterns.dev
  • Kent C. Dodds — Compound Components: https://kentcdodds.com/blog/compound-components-with-react-hooks
  • roadmap.sh — Software Design: https://roadmap.sh/software-design-architecture
Comentários

Mais em Javascript

Git avançado e fluxo de trabalho em equipe
Git avançado e fluxo de trabalho em equipe

Saber usar git add, git commit e git push é o mínimo. Em um projeto real com...

NPM: gerenciando pacotes e dependências
NPM: gerenciando pacotes e dependências

Uma das maiores vantagens do Node.js não é o que ele faz sozinho — é o que a...

Revisão do Módulo 8 + Projeto: Refatoração com Clean Architecture
Revisão do Módulo 8 + Projeto: Refatoração com Clean Architecture

Artigo 49 — Revisão do Módulo 8 + Projeto: Refatoração com Clean Architecture...