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;
// Não existe construtor privado em JavaScript: o truque abaixo é devolver a
// instância já criada quando alguém chama new pela segunda vez. Prefira sempre
// obterInstancia() — o new funciona, mas ignora o argumento silenciosamente.
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".
Ver solução — os seis padrões na aplicação do Módulo 6, com os testes que os provam
// PADRÕES DE PROJETO — os seis, aplicados na aplicação do Módulo 6.
//
// 1 Singleton → src/database.js
// 2 Factory → src/notificadores.js
// 3 Builder → src/queryBuilder.js (usado em GET /tarefas)
// 4 Observer → src/eventos.js
// 5 Strategy → src/estrategias.js (usado em GET /produtos)
// 6 Compound → src/componentes/Tabs.jsx
//
// Tudo abaixo roda: 22 testes verdes (18 no servidor, com Mongo de verdade via
// mongodb-memory-server; 4 no jsdom para as abas).
// ---- package.json
{
"name": "modulo6-lote18",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "jest",
"test:um": "jest --runInBand"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@bull-board/api": "^9.3.2",
"@bull-board/express": "^9.3.2",
"@tanstack/react-query": "^5.101.4",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"mongoose": "^9.9.3",
"nodemailer": "^9.0.5",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@babel/preset-env": "^8.0.2",
"@babel/preset-react": "^8.0.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.5",
"babel-jest": "^30.4.1",
"bullmq": "^6.2.0",
"ioredis": "^6.0.0",
"jest": "^30.4.2",
"jest-environment-jsdom": "^30.4.1",
"mongodb-memory-server": "^11.2.0",
"redis-memory-server": "^0.17.1",
"supertest": "^7.2.2"
},
"private": true
}
// ---- babel.config.js
module.exports = {
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
["@babel/preset-react", { runtime: "automatic" }],
],
};
// ---- jest.config.js
module.exports = {
testEnvironment: "node",
testTimeout: 60000,
setupFilesAfterEnv: ["<rootDir>/tests/setup.js"],
};
// ---- tests/setup.js
require("@testing-library/jest-dom");
if (typeof TextEncoder === "undefined") {
const { TextEncoder, TextDecoder } = require("util");
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
}
// ---- src/database.js
// 1 — SINGLETON: uma conexão, mesmo sob chamadas simultâneas.
const mongoose = require("mongoose");
let promessa = null; // guarda a PROMESSA, não a conexão já resolvida
let reusos = 0;
function conectar(url = process.env.MONGODB_URL) {
if (promessa) {
reusos++;
console.log(`[db] conexão reutilizada (reuso nº ${reusos})`);
return promessa;
}
console.log("[db] abrindo a única conexão do processo");
promessa = mongoose.connect(url).then((m) => m.connection);
return promessa;
}
async function desconectar() {
if (!promessa) return;
await promessa;
await mongoose.disconnect();
promessa = null;
reusos = 0;
}
const estatisticas = () => ({ reusos, conectado: promessa !== null });
module.exports = { conectar, desconectar, estatisticas };
// ---- src/notificadores.js
// 2 — FACTORY: quem decide a implementação é a fábrica, não quem usa.
class NotificadorEmail {
constructor(transporte) {
this.transporte = transporte;
this.nome = "email";
}
async enviar({ para, assunto, texto }) {
return this.transporte.sendMail({ to: para, subject: assunto, text: texto });
}
}
class NotificadorConsole {
constructor() {
this.nome = "console";
this.enviados = [];
}
async enviar({ para, assunto, texto }) {
this.enviados.push({ para, assunto, texto });
console.log(`[notificacao] para=${para} assunto="${assunto}"`);
return { accepted: [para], simulado: true };
}
}
// O ambiente entra por parâmetro: uma fábrica que lê process.env por dentro
// não tem como ser testada nos dois ramos sem mexer no ambiente do processo.
function criarNotificador({ ambiente = process.env.NODE_ENV, transporte } = {}) {
if (ambiente === "producao") {
if (!transporte) throw new Error("NotificadorEmail exige um transporte SMTP");
return new NotificadorEmail(transporte);
}
return new NotificadorConsole();
}
module.exports = { criarNotificador, NotificadorEmail, NotificadorConsole };
// ---- src/queryBuilder.js
// 3 — BUILDER: a rota descreve o que quer; o builder monta a query.
class QueryBuilder {
constructor(model) {
this.model = model;
this.filtro = {};
this.projecao = null;
this.ordenacao = {};
this.pagina = 1;
this.porPagina = 20;
this.usarLean = false;
}
onde(campos) {
for (const [chave, valor] of Object.entries(campos)) {
// sem esta linha, ?status= ausente vira { status: undefined } e o
// Mongoose transforma isso em { status: null } — que casa com documentos
// sem o campo, e não com "todos".
if (valor === undefined || valor === "") continue;
this.filtro[chave] = valor;
}
return this;
}
campos(lista) {
this.projecao = Array.isArray(lista) ? lista.join(" ") : lista;
return this;
}
paginar(pagina = 1, porPagina = 20) {
this.pagina = Math.max(1, Number(pagina) || 1);
this.porPagina = Math.min(100, Math.max(1, Number(porPagina) || 20));
return this;
}
ordenarPor(campo, direcao = "desc") {
if (campo) this.ordenacao[campo] = direcao === "asc" ? 1 : -1;
return this;
}
lean(ativo = true) {
this.usarLean = ativo;
return this;
}
montar() {
// desempate obrigatório: ordenar só por um campo com valores repetidos
// devolve o mesmo documento em duas páginas diferentes.
const ordenacao = { ...this.ordenacao, _id: -1 };
let q = this.model
.find(this.filtro)
.sort(ordenacao)
.skip((this.pagina - 1) * this.porPagina)
.limit(this.porPagina);
if (this.projecao) q = q.select(this.projecao);
if (this.usarLean) q = q.lean();
return q;
}
async executar() {
const [itens, total] = await Promise.all([
this.montar(),
this.model.countDocuments(this.filtro),
]);
return {
itens,
total,
pagina: this.pagina,
paginas: Math.ceil(total / this.porPagina) || 1,
};
}
}
const query = (model) => new QueryBuilder(model);
module.exports = { QueryBuilder, query };
// ---- src/eventos.js
// 4 — OBSERVER: quem cria a tarefa não sabe quem se interessa por isso.
const { EventEmitter } = require("node:events");
const TAREFA_CRIADA = "tarefa:criada";
const barramento = new EventEmitter();
barramento.setMaxListeners(20);
// Ouvinte que pode falhar: envolvido, senão uma rejeição aqui derruba o
// processo inteiro (unhandled rejection é fatal no Node moderno).
function ouvir(evento, ouvinte) {
barramento.on(evento, (...args) => {
Promise.resolve()
.then(() => ouvinte(...args))
.catch((erro) => barramento.emit("ouvinte:erro", { evento, erro }));
});
}
function registrarOuvintes({ cache, logger = console }) {
ouvir(TAREFA_CRIADA, (tarefa) => {
cache.invalidar(`estatisticas:${tarefa.usuarioId}`);
});
ouvir(TAREFA_CRIADA, (tarefa) => {
logger.log(`[auditoria] tarefa ${tarefa.id} criada por ${tarefa.usuarioId}`);
});
barramento.on("ouvinte:erro", ({ evento, erro }) => {
logger.error(`[eventos] ouvinte de ${evento} falhou: ${erro.message}`);
});
}
module.exports = { barramento, ouvir, registrarOuvintes, TAREFA_CRIADA };
// ---- src/estrategias.js
// 5 — STRATEGY: trocar o algoritmo sem tocar em quem o chama.
const estrategias = {
precoAscendente: { rotulo: "Menor preço", ordenacao: { preco: 1 } },
precoDescendente: { rotulo: "Maior preço", ordenacao: { preco: -1 } },
maisRecentes: { rotulo: "Mais recentes", ordenacao: { criadoEm: -1 } },
};
const PADRAO = "maisRecentes";
// `estrategias[req.query.ordenar]` direto é uma porta aberta: ?ordenar=toString
// devolve uma função herdada de Object.prototype e a rota quebra (ou pior).
// Object.hasOwn corta a cadeia de protótipos.
function escolherEstrategia(nome) {
return Object.hasOwn(estrategias, nome) ? estrategias[nome] : estrategias[PADRAO];
}
module.exports = { estrategias, escolherEstrategia, PADRAO };
// ---- src/modelos.js
const mongoose = require("mongoose");
const tarefaSchema = new mongoose.Schema(
{
titulo: { type: String, required: true },
prioridade: { type: Number, default: 3 },
concluida: { type: Boolean, default: false },
usuarioId: String,
},
{ timestamps: true }
);
const produtoSchema = new mongoose.Schema({
nome: String,
preco: Number,
criadoEm: { type: Date, default: Date.now },
});
const Tarefa = mongoose.models.Tarefa || mongoose.model("Tarefa", tarefaSchema);
const Produto = mongoose.models.Produto || mongoose.model("Produto", produtoSchema);
module.exports = { Tarefa, Produto };
// ---- src/app.js
const express = require("express");
const { query } = require("./queryBuilder");
const { escolherEstrategia } = require("./estrategias");
const { barramento, TAREFA_CRIADA } = require("./eventos");
const { Tarefa, Produto } = require("./modelos");
function criarApp({ notificador }) {
const app = express();
app.use(express.json());
// BUILDER na rota: cada parâmetro da querystring vira uma chamada legível.
app.get("/tarefas", async (req, res) => {
const resultado = await query(Tarefa)
.onde({ usuarioId: req.query.usuarioId, concluida: req.query.concluida })
.campos(["titulo", "prioridade", "concluida"])
.ordenarPor(req.query.ordenarPor || "prioridade", req.query.direcao)
.paginar(req.query.pagina, req.query.porPagina)
.lean()
.executar();
res.json(resultado);
});
app.post("/tarefas", async (req, res) => {
const tarefa = await Tarefa.create(req.body);
await notificador.enviar({
para: "dono@exemplo.com",
assunto: "Nova tarefa",
texto: tarefa.titulo,
});
// OBSERVER: a rota anuncia o fato e segue. Quem escuta é problema de quem escuta.
barramento.emit(TAREFA_CRIADA, { id: String(tarefa._id), usuarioId: tarefa.usuarioId });
res.status(201).json(tarefa);
});
// STRATEGY na rota: ?ordenar=precoAscendente|precoDescendente|maisRecentes
app.get("/produtos", async (req, res) => {
const estrategia = escolherEstrategia(req.query.ordenar);
const produtos = await Produto.find().sort({ ...estrategia.ordenacao, _id: -1 }).lean();
res.json({ ordenacao: estrategia.rotulo, produtos });
});
return app;
}
module.exports = { criarApp };
// ---- src/componentes/Tabs.jsx
// 6 — COMPOUND COMPONENTS: o estado mora no pai, a marcação fica com quem usa.
import { createContext, useContext, useId, useState } from "react";
const TabsContexto = createContext(null);
function usarTabs(quem) {
const ctx = useContext(TabsContexto);
if (!ctx) throw new Error(`<${quem}> só funciona dentro de <Tabs>`);
return ctx;
}
export function Tabs({ inicial, children }) {
const [ativa, setAtiva] = useState(inicial);
const prefixo = useId();
return (
<TabsContexto.Provider value={{ ativa, setAtiva, prefixo }}>
<div className="tabs">{children}</div>
</TabsContexto.Provider>
);
}
Tabs.Lista = function Lista({ children }) {
usarTabs("Tabs.Lista");
return (
<div role="tablist" className="tabs__lista">
{children}
</div>
);
};
Tabs.Aba = function Aba({ id, children }) {
const { ativa, setAtiva, prefixo } = usarTabs("Tabs.Aba");
const selecionada = ativa === id;
return (
<button
type="button"
role="tab"
id={`${prefixo}-aba-${id}`}
aria-controls={`${prefixo}-painel-${id}`}
aria-selected={selecionada}
tabIndex={selecionada ? 0 : -1}
onClick={() => setAtiva(id)}
>
{children}
</button>
);
};
Tabs.Painel = function Painel({ id, children }) {
const { ativa, prefixo } = usarTabs("Tabs.Painel");
if (ativa !== id) return null;
return (
<div
role="tabpanel"
id={`${prefixo}-painel-${id}`}
aria-labelledby={`${prefixo}-aba-${id}`}
>
{children}
</div>
);
};
// ---- src/componentes/DetalheProduto.jsx
import { Tabs } from "./Tabs.jsx";
export function DetalheProduto({ produto }) {
return (
<article>
<h1>{produto.nome}</h1>
<Tabs inicial="informacoes">
{/* o agrupamento abaixo é de propósito: com React.Children.map +
cloneElement, esta <div> quebraria o componente. Com contexto, não. */}
<div className="cabecalho">
<Tabs.Lista>
<Tabs.Aba id="informacoes">Informações</Tabs.Aba>
<Tabs.Aba id="descricao">Descrição</Tabs.Aba>
{produto.controlaEstoque && <Tabs.Aba id="estoque">Estoque</Tabs.Aba>}
</Tabs.Lista>
</div>
<Tabs.Painel id="informacoes">
<dl>
<dt>SKU</dt>
<dd>{produto.sku}</dd>
<dt>Preço</dt>
<dd>{produto.precoFormatado}</dd>
</dl>
</Tabs.Painel>
<Tabs.Painel id="descricao">
<p>{produto.descricao}</p>
</Tabs.Painel>
<Tabs.Painel id="estoque">
<p>{produto.estoque} unidades disponíveis</p>
</Tabs.Painel>
</Tabs>
</article>
);
}
// ---- tests/padroes.test.js
const mongoose = require("mongoose");
const request = require("supertest");
const { MongoMemoryServer } = require("mongodb-memory-server");
const db = require("../src/database");
const { criarNotificador, NotificadorEmail, NotificadorConsole } = require("../src/notificadores");
const { query } = require("../src/queryBuilder");
const { barramento, ouvir, registrarOuvintes, TAREFA_CRIADA } = require("../src/eventos");
const { estrategias, escolherEstrategia } = require("../src/estrategias");
const { criarApp } = require("../src/app");
const { Tarefa, Produto } = require("../src/modelos");
let mongo;
beforeAll(async () => {
mongo = await MongoMemoryServer.create();
process.env.MONGODB_URL = mongo.getUri();
});
afterAll(async () => {
await db.desconectar();
await mongo.stop();
});
describe("1 · Singleton", () => {
test("duas chamadas simultâneas abrem UMA conexão", async () => {
const espia = jest.spyOn(mongoose, "connect");
const [a, b] = await Promise.all([db.conectar(), db.conectar()]);
await db.conectar();
expect(espia).toHaveBeenCalledTimes(1);
expect(a).toBe(b);
expect(db.estatisticas()).toEqual({ reusos: 2, conectado: true });
espia.mockRestore();
});
test("cachear a conexão RESOLVIDA não basta — a promessa é que tem de ser cacheada", async () => {
let abertas = 0;
const abrir = () => new Promise((ok) => setTimeout(() => ok(++abertas), 10));
let conexao = null;
const ingenuo = async () => (conexao ??= await abrir());
await Promise.all([ingenuo(), ingenuo(), ingenuo()]);
expect(abertas).toBe(3); // três conexões: todas viram null antes do await
abertas = 0;
let promessa = null;
const correto = () => (promessa ??= abrir());
await Promise.all([correto(), correto(), correto()]);
expect(abertas).toBe(1);
});
});
describe("2 · Factory", () => {
test("producao devolve NotificadorEmail e usa o transporte", async () => {
const transporte = { sendMail: jest.fn().mockResolvedValue({ messageId: "1" }) };
const n = criarNotificador({ ambiente: "producao", transporte });
expect(n).toBeInstanceOf(NotificadorEmail);
await n.enviar({ para: "a@b.c", assunto: "oi", texto: "t" });
expect(transporte.sendMail).toHaveBeenCalledWith({ to: "a@b.c", subject: "oi", text: "t" });
});
test("desenvolvimento e teste devolvem NotificadorConsole", () => {
for (const ambiente of ["desenvolvimento", "teste", undefined]) {
expect(criarNotificador({ ambiente })).toBeInstanceOf(NotificadorConsole);
}
});
test("producao sem transporte falha na fábrica, e não na hora de enviar", () => {
expect(() => criarNotificador({ ambiente: "producao" })).toThrow(/transporte SMTP/);
});
});
describe("3 · Builder", () => {
beforeAll(async () => {
await db.conectar();
await Tarefa.deleteMany({});
for (let i = 1; i <= 30; i++) {
await Tarefa.create({
titulo: `Tarefa ${i}`,
prioridade: 3,
concluida: i % 2 === 0,
usuarioId: "u1",
});
}
});
test("filtro vazio não vira { concluida: null }", async () => {
const b = query(Tarefa).onde({ usuarioId: "u1", concluida: undefined, titulo: "" });
expect(b.filtro).toEqual({ usuarioId: "u1" });
const { total } = await b.executar();
expect(total).toBe(30);
});
test("paginar limita, conta e informa o número de páginas", async () => {
const r = await query(Tarefa).onde({ usuarioId: "u1" }).paginar(2, 10).executar();
expect(r.itens).toHaveLength(10);
expect(r).toMatchObject({ total: 30, pagina: 2, paginas: 3 });
});
test("porPagina tem teto — ?porPagina=100000 não vira varredura da coleção", async () => {
expect(query(Tarefa).paginar(1, 100000).porPagina).toBe(100);
expect(query(Tarefa).paginar(-5, 0).pagina).toBe(1);
});
test("campos() projeta e lean() devolve objeto puro, sem métodos de documento", async () => {
const { itens } = await query(Tarefa).campos(["titulo"]).lean().paginar(1, 1).executar();
expect(itens[0].save).toBeUndefined();
expect(itens[0].prioridade).toBeUndefined();
expect(Object.keys(itens[0]).sort()).toEqual(["_id", "titulo"]);
});
test("o desempate por _id dá ordem total quando o campo ordenado empata", async () => {
const semDesempate = await Tarefa.find({ usuarioId: "u1" }).sort({ prioridade: -1 }).lean();
const comDesempate = await Tarefa.find({ usuarioId: "u1" }).sort({ prioridade: -1, _id: -1 }).lean();
const ids = (l) => l.map((t) => String(t._id));
console.log(
"ordem sem desempate === ordem com desempate?",
JSON.stringify(ids(semDesempate)) === JSON.stringify(ids(comDesempate))
);
// o que dá para garantir: com o desempate, a ordem é decrescente por _id
const ordenados = [...ids(comDesempate)].sort().reverse();
expect(ids(comDesempate)).toEqual(ordenados);
});
test("skip/limit repete item quando alguém insere entre a página 1 e a 2", async () => {
const p1 = await query(Tarefa).onde({ usuarioId: "u1" }).ordenarPor("createdAt", "desc").paginar(1, 10).lean().executar();
await Tarefa.create({ titulo: "chegou agora", prioridade: 3, usuarioId: "u1" });
const p2 = await query(Tarefa).onde({ usuarioId: "u1" }).ordenarPor("createdAt", "desc").paginar(2, 10).lean().executar();
const repetidos = p1.itens.filter((a) => p2.itens.some((b) => String(a._id) === String(b._id)));
expect(repetidos).toHaveLength(1);
expect(repetidos[0].titulo).toBe("Tarefa 21");
await Tarefa.deleteOne({ titulo: "chegou agora" });
});
});
describe("4 · Observer", () => {
afterEach(() => barramento.removeAllListeners());
test("os dois ouvintes reagem ao mesmo evento", async () => {
const cache = { invalidar: jest.fn() };
const logger = { log: jest.fn(), error: jest.fn() };
registrarOuvintes({ cache, logger });
barramento.emit(TAREFA_CRIADA, { id: "t1", usuarioId: "u9" });
await new Promise(process.nextTick);
expect(cache.invalidar).toHaveBeenCalledWith("estatisticas:u9");
expect(logger.log).toHaveBeenCalledWith("[auditoria] tarefa t1 criada por u9");
});
test("ouvinte async que rejeita não derruba o processo nem impede os outros", async () => {
const logger = { log: jest.fn(), error: jest.fn() };
registrarOuvintes({ cache: { invalidar: jest.fn() }, logger });
ouvir(TAREFA_CRIADA, async () => {
throw new Error("SMTP fora do ar");
});
barramento.emit(TAREFA_CRIADA, { id: "t2", usuarioId: "u9" });
await new Promise((ok) => setTimeout(ok, 10));
expect(logger.error).toHaveBeenCalledWith(
"[eventos] ouvinte de tarefa:criada falhou: SMTP fora do ar"
);
expect(logger.log).toHaveBeenCalled(); // o ouvinte de auditoria rodou assim mesmo
});
test("um ouvinte SÍNCRONO que lança interrompe os seguintes — por isso ouvir() existe", () => {
const chamado = jest.fn();
barramento.on(TAREFA_CRIADA, () => {
throw new Error("boom");
});
barramento.on(TAREFA_CRIADA, chamado);
expect(() => barramento.emit(TAREFA_CRIADA, {})).toThrow("boom");
expect(chamado).not.toHaveBeenCalled();
});
});
describe("5 e 3 · Strategy e Builder na rota", () => {
let app;
beforeAll(async () => {
await db.conectar();
app = criarApp({ notificador: criarNotificador({ ambiente: "teste" }) });
await Produto.deleteMany({});
await Produto.create([
{ nome: "caro e velho", preco: 300, criadoEm: new Date("2024-01-01") },
{ nome: "barato e novo", preco: 10, criadoEm: new Date("2026-01-01") },
{ nome: "médio", preco: 100, criadoEm: new Date("2025-01-01") },
]);
});
test("as três estratégias ordenam de verdade", async () => {
const casos = {
precoAscendente: ["barato e novo", "médio", "caro e velho"],
precoDescendente: ["caro e velho", "médio", "barato e novo"],
maisRecentes: ["barato e novo", "médio", "caro e velho"],
};
for (const [ordenar, esperado] of Object.entries(casos)) {
const r = await request(app).get(`/produtos?ordenar=${ordenar}`).expect(200);
expect(r.body.produtos.map((p) => p.nome)).toEqual(esperado);
expect(r.body.ordenacao).toBe(estrategias[ordenar].rotulo);
}
});
test("?ordenar desconhecido cai no padrão em vez de quebrar", async () => {
const r = await request(app).get("/produtos?ordenar=maisBaratoDoMundo").expect(200);
expect(r.body.ordenacao).toBe("Mais recentes");
});
test("?ordenar=toString não devolve função herdada do Object.prototype", async () => {
expect(typeof estrategias["toString"]).toBe("function"); // a armadilha existe
expect(escolherEstrategia("toString")).toBe(estrategias.maisRecentes);
expect(escolherEstrategia("constructor")).toBe(estrategias.maisRecentes);
await request(app).get("/produtos?ordenar=toString").expect(200);
});
test("POST /tarefas notifica pelo dublê e anuncia o evento", async () => {
const cache = { invalidar: jest.fn() };
const logger = { log: jest.fn(), error: jest.fn() };
registrarOuvintes({ cache, logger });
const r = await request(app)
.post("/tarefas")
.send({ titulo: "escrever o lote 18", usuarioId: "u1" })
.expect(201);
await new Promise(process.nextTick);
expect(cache.invalidar).toHaveBeenCalledWith("estatisticas:u1");
expect(r.body.titulo).toBe("escrever o lote 18");
barramento.removeAllListeners();
});
});
// ---- tests/Tabs.test.jsx
/**
* @jest-environment jsdom
*/
import { Children, cloneElement, useState } from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Tabs } from "../src/componentes/Tabs.jsx";
import { DetalheProduto } from "../src/componentes/DetalheProduto.jsx";
const produto = {
nome: "Teclado 60%",
sku: "TEC-60",
precoFormatado: "R$ 349,90",
descricao: "Compacto, sem bloco numérico.",
estoque: 7,
controlaEstoque: true,
};
test("6 · as três abas aparecem e só o painel ativo é renderizado", async () => {
const usuario = userEvent.setup();
render(<DetalheProduto produto={produto} />);
const lista = screen.getByRole("tablist");
expect(within(lista).getAllByRole("tab").map((b) => b.textContent)).toEqual([
"Informações",
"Descrição",
"Estoque",
]);
expect(screen.getByText("TEC-60")).toBeInTheDocument();
expect(screen.queryByText(/7 unidades/)).not.toBeInTheDocument();
await usuario.click(screen.getByRole("tab", { name: "Estoque" }));
expect(screen.getByText("7 unidades disponíveis")).toBeInTheDocument();
expect(screen.queryByText("TEC-60")).not.toBeInTheDocument();
});
test("6 · aria-selected, aria-controls e roving tabindex acompanham a aba ativa", async () => {
const usuario = userEvent.setup();
render(<DetalheProduto produto={produto} />);
const informacoes = screen.getByRole("tab", { name: "Informações" });
const descricao = screen.getByRole("tab", { name: "Descrição" });
expect(informacoes).toHaveAttribute("aria-selected", "true");
expect(informacoes).toHaveAttribute("tabindex", "0");
expect(descricao).toHaveAttribute("tabindex", "-1");
const painel = screen.getByRole("tabpanel");
expect(informacoes.getAttribute("aria-controls")).toBe(painel.id);
expect(painel.getAttribute("aria-labelledby")).toBe(informacoes.id);
await usuario.click(descricao);
expect(descricao).toHaveAttribute("aria-selected", "true");
expect(informacoes).toHaveAttribute("aria-selected", "false");
});
test("6 · usar uma peça fora do <Tabs> falha com mensagem que diz o que fazer", () => {
const erro = jest.spyOn(console, "error").mockImplementation(() => {});
expect(() => render(<Tabs.Aba id="x">solta</Tabs.Aba>)).toThrow(
"<Tabs.Aba> só funciona dentro de <Tabs>"
);
erro.mockRestore();
});
test("6 · a versão com cloneElement quebra com filhos aninhados; a de contexto não", async () => {
// Implementação ingênua, a que quase todo tutorial mostra:
function TabsIngenuo({ children, inicial }) {
const [ativa, setAtiva] = useState(inicial);
return Children.map(children, (filho) => cloneElement(filho, { ativa, setAtiva }));
}
const AbaIngenua = ({ id, ativa, setAtiva, children }) => (
<button role="tab" aria-selected={ativa === id} onClick={() => setAtiva(id)}>
{children}
</button>
);
const { unmount } = render(
<TabsIngenuo inicial="a">
<AbaIngenua id="a">A</AbaIngenua>
<AbaIngenua id="b">B</AbaIngenua>
</TabsIngenuo>
);
expect(screen.getByRole("tab", { name: "A" })).toHaveAttribute("aria-selected", "true");
unmount();
// agora com um invólucro no meio — nada de props chega às abas
const erro = jest.spyOn(console, "error").mockImplementation(() => {});
render(
<TabsIngenuo inicial="a">
<div>
<AbaIngenua id="a">A</AbaIngenua>
<AbaIngenua id="b">B</AbaIngenua>
</div>
</TabsIngenuo>
);
// a prop foi parar na <div>, e a aba não sabe qual está ativa
expect(screen.getByRole("tab", { name: "A" })).toHaveAttribute("aria-selected", "false");
erro.mockRestore();
// a versão de contexto atravessa o mesmo invólucro sem reclamar
render(
<Tabs inicial="a">
<div>
<Tabs.Lista>
<Tabs.Aba id="a">A ctx</Tabs.Aba>
</Tabs.Lista>
</div>
</Tabs>
);
expect(screen.getByRole("tab", { name: "A ctx" })).toHaveAttribute("aria-selected", "true");
});
Os seis padrões resolvem problemas diferentes, mas três armadilhas se repetem. O Singleton tem de cachear a promessa, não a conexão: if (conexao) return conexao parece certo e abre três conexões quando três partes do boot chamam conectar() ao mesmo tempo — todas passam pelo if antes de a primeira resolver. O Strategy não pode indexar o objeto com o que veio da querystring: estrategias[req.query.ordenar] com ?ordenar=toString devolve uma função herdada de Object.prototype — Object.hasOwn corta a herança. E o Compound Component só sobrevive com contexto: a versão de React.Children.map + cloneElement, que quase todo tutorial mostra, para de funcionar no dia em que alguém envolve as abas numa <div> — a prop vai parar no invólucro. Os três casos estão nos testes, inclusive a versão quebrada, lado a lado com a que funciona.
O maior valor dos padrões é serem vocabulário: dizer "isto aqui é um Adapter" poupa um parágrafo de explicação numa revisão de código. O risco é o inverso — aplicar o padrão antes de o problema aparecer, trocando três linhas claras por uma fábrica com interface e injeção. E em JavaScript vários deles já vêm de graça: um módulo é um Singleton, uma função de alta ordem é um Strategy, e o EventEmitter é um Observer pronto para usar.
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
- Refactoring Guru — catálogo de padrões: https://refactoring.guru/design-patterns
Exercícios
Exercício 1
O módulo é um singleton, e o cache funciona perfeitamente em desenvolvimento. Em produção, o mesmo dado é buscado repetidamente no banco. O que mudou?
// cache.js — um Map por módulo, "singleton por natureza"
const cache = new Map();
module.exports = { cache };
// em produção:
// pm2 start src/index.js -i max ← modo cluster, 8 workers
Ver resposta
✓ Resposta: O módulo é singleton por processo, e agora há oito processos. O cache do require garante que, dentro de um mesmo processo Node, todos os arquivos recebam o mesmo objeto — e só isso. Com o PM2 em modo cluster existem oito Map independentes, e uma requisição atendida pelo worker 3 não enxerga o que o worker 5 guardou; a taxa de acerto despenca para cerca de um oitavo. Em ambiente serverless é pior ainda, porque cada instância fria começa com o cache vazio e some depois. A confusão nasce de tratar "singleton" como se significasse "único no sistema", quando o alcance real é o processo. O mesmo raciocínio vale para qualquer estado guardado em módulo — contador de rate limit, conexão de fila, lista de sockets. Para valer entre processos, o estado precisa sair da memória: Redis é a resposta usual. Há ainda uma armadilha vizinha que aparece nos testes: o Jest cria um registro de módulos por arquivo de teste, então o singleton é recriado em cada suíte — o que é bom, porque isola —, mas dentro do mesmo arquivo ele persiste entre os testes e carrega estado de um caso para o outro. É por isso que jest.resetModules() existe, e por isso singleton com estado mutável é difícil de testar.
Exercício 2
A aplicação vai ficando lenta ao longo do dia e o uso de memória só cresce. O EventEmitter imprime um aviso no log. Qual é a causa?
function ComponenteDeRelatorio({ relatorioId }) {
useEffect(() => {
emissor.on('relatorio:pronto', (dados) => {
atualizarTela(dados);
});
}, [relatorioId]);
}
Ver resposta
✓ Resposta: O ouvinte é registrado e nunca removido. O efeito roda a cada mudança de relatorioId e a cada montagem do componente, e cada execução acrescenta mais uma função à lista do emissor — que as guarda para sempre. Duas consequências: o vazamento de memória, porque cada função retém por closure tudo o que estava no escopo dela, e o comportamento duplicado, já que um único evento passa a disparar dez, cinquenta, duzentas atualizações de tela. O aviso MaxListenersExceededWarning aparece ao passar de onze ouvintes no mesmo evento e é justamente o Node dizendo "isto parece vazamento" — e quase sempre está certo. A correção é devolver a limpeza no efeito: guardar a função numa constante e chamar emissor.off('relatorio:pronto', manipulador) no retorno. Repare que é preciso a mesma referência para remover, exatamente como no removeEventListener do DOM — passar uma arrow nova ao off não remove nada. Duas dicas que evitam o problema: quando o ouvinte só precisa rodar uma vez, once se remove sozinho; e a tentação de elevar o limite com setMaxListeners(100) é o caminho errado, porque silencia o aviso sem consertar a causa.
Exercício 3
Este switch cresce a cada novo meio de pagamento, e toda adição mexe no mesmo arquivo. Que padrão resolve, e o que se ganha?
function calcularTaxa(metodo, valor) {
switch (metodo) {
case 'pix': return 0;
case 'debito': return valor * 0.015;
case 'credito': return valor * 0.035 + 0.39;
case 'boleto': return 3.49;
default: throw new Error('Método desconhecido');
}
}
Ver resposta
✓ Resposta: É o caso de Strategy, e em JavaScript ele não precisa de classe nenhuma: um objeto cujas chaves são os métodos e os valores são funções já é a implementação completa — const taxas = { pix: () => 0, debito: (v) => v * 0.015, … }, e o cálculo vira taxas[metodo]?.(valor). O ganho tem nome: o princípio aberto-fechado. Acrescentar um meio de pagamento passa a ser adicionar uma entrada, em vez de modificar uma função existente — e o que não se modifica não se quebra. Junto vêm três vantagens práticas: cada estratégia é testável isoladamente, o mapa pode ser montado em tempo de execução a partir de configuração ou do banco, e o arquivo para de crescer indefinidamente. Uma ressalva honesta, porém: quatro casos estáveis não justificam a troca. O switch acima é perfeitamente legível, e substituí-lo por um mapa só porque existe um padrão com nome é a forma mais comum de excesso de arquitetura. O sinal de que chegou a hora é outro: quando o mesmo switch começa a aparecer em vários lugares — um para a taxa, outro para o prazo, outro para o rótulo na tela —, aí o padrão passa a pagar, porque cada meio de pagamento vira um objeto único com todo o comportamento dele junto.
Exercício 4
As duas composições usam os mesmos decoradores, na ordem inversa. Qual a diferença de comportamento?
// A
const buscar = comLog(comCache(buscarProdutos));
// B
const buscar = comCache(comLog(buscarProdutos));
Ver resposta
✓ Resposta: Em A, o log envolve o cache: toda chamada é registrada, inclusive as que foram servidas do cache sem tocar no banco. Em B, o cache envolve o log: quando há acerto de cache, a função interna nem é chamada, e nada é registrado — o log só aparece nas vezes em que a busca realmente aconteceu. Nenhuma das duas é errada; elas respondem a perguntas diferentes. Se você quer medir o comportamento do usuário — quantas vezes essa consulta foi pedida —, precisa de A. Se quer medir o custo real — quantas idas ao banco aconteceram —, precisa de B. E é justamente por isso que a ordem engana: montado ao contrário do pretendido, o log de B faria parecer que a consulta é rara, quando ela é apenas barata. A regra que ajuda a raciocinar é que o decorador mais externo é o primeiro a receber a chamada e o último a devolver o resultado — a composição funciona como camadas de cebola. O mesmo raciocínio vale para middleware de Express, para interceptador de cliente HTTP e para qualquer wrapper: em toda composição, ordem é comportamento, e vale ser explícito sobre ela no código em vez de deixá-la ao acaso do refactor seguinte.
Exercício 5
Um desenvolvedor propõe substituir esta função por uma Factory com interface, registro de tipos e injeção de dependência. O código tem três meses e dois casos. Vale?
function criarNotificacao(tipo, dados) {
if (tipo === 'email') return { para: dados.email, assunto: dados.titulo };
return { numero: dados.telefone, texto: dados.titulo };
}
Ver resposta
✓ Resposta: Não vale. Cinco linhas legíveis virariam uma interface, duas implementações, um registro, um contêiner e quatro arquivos — e quem precisar entender o fluxo passará a pular entre eles em vez de ler um bloco. Padrão não é medida de qualidade: é solução para um problema que já existe. Aplicado antes do problema, ele cobra o preço da indireção e não entrega benefício nenhum, porque o benefício só aparece quando a variação de fato acontece. O sinal que justifica a mudança é concreto e observável: um terceiro e um quarto caso aparecendo, ou a mesma ramificação se repetindo em outros pontos do código, ou a necessidade de registrar tipos que vêm de configuração. Enquanto nada disso existe, o if é a resposta certa. Vale lembrar também que JavaScript já traz vários desses padrões de graça, e reimplementá-los é excesso duplo: um módulo é um Singleton, uma função de alta ordem é um Strategy, o EventEmitter é um Observer pronto e uma função que devolve função é um Decorator. E fica o critério para a discussão em revisão de código: a pergunta útil não é "qual padrão se aplica aqui", é "que mudança este desenho está tornando mais fácil, e ela é provável?" — se ninguém souber responder, o padrão está sobrando.