Design Patterns no Dia a Dia: Factory, Singleton, Strategy e Observer
Os quatro padrões GoF mais usados no Node.js moderno: Singleton com módulo-cache e classe, Factory Method para DI sem framework, Strategy para algoritmos intercambiáveis e Observer com EventEmitter.
Os 23 padrões do livro da Gang of Four (GoF) foram publicados em 1994 para C++. Muitos desenvolvedores os consideram acadêmicos e distantes do cotidiano. A realidade é oposta: você já usa Factory toda vez que chama makeCreateUserService(), Singleton toda vez que importa o cliente Redis, Strategy quando escolhe entre LocalStorageProvider e S3StorageProvider, e Observer quando usa EventEmitter do Node.js.
Este artigo decodifica os quatro padrões criacionais e comportamentais mais aplicados no ecossistema Node.js/TypeScript — com o código real que você escreve no dia a dia, não exemplos de livro didático.
1. Singleton: Uma Única Instância
O Singleton garante que uma classe tenha apenas uma instância durante o ciclo de vida da aplicação. Use quando criar múltiplas instâncias é custoso (conexão com banco, pool de conexões) ou incorreto (configuração global, logger).
No Node.js, o módulo cache já cria um comportamento Singleton nativamente: um arquivo importado múltiplas vezes retorna sempre o mesmo objeto. Você pode usar isso diretamente:
import Redis from 'ioredis';
// Este objeto é criado UMA VEZ e reutilizado em todos os imports
// O Node.js faz cache do módulo automaticamente
const client = new Redis({
host: process.env.REDIS_HOST ?? 'localhost',
port: Number(process.env.REDIS_PORT ?? 6379),
lazyConnect: true,
});
export { client as redisClient };
// Em qualquer arquivo:
// import { redisClient } from './infra/redis/client';
// A mesma instância é retornada sempreexport class AppLogger {
private static instance: AppLogger | null = null;
private readonly logs: Array<string> = [];
// Construtor privado: força o uso de getInstance()
private constructor(private readonly serviceName: string) {}
static getInstance(serviceName?: string): AppLogger {
if (!AppLogger.instance) {
if (!serviceName) throw new Error('serviceName requerido na primeira inicialização');
AppLogger.instance = new AppLogger(serviceName);
}
return AppLogger.instance;
}
info(message: string, meta?: Record<string, unknown>): void {
const entry = `[${new Date().toISOString()}] [${this.serviceName}] INFO: ${message}`;
this.logs.push(entry);
console.log(entry, meta ?? '');
}
error(message: string, err?: unknown): void {
const entry = `[${new Date().toISOString()}] [${this.serviceName}] ERROR: ${message}`;
this.logs.push(entry);
console.error(entry, err);
}
// Para testes: permite resetar o singleton
static reset(): void {
AppLogger.instance = null;
}
}2. Factory Method: Encapsulando a Criação de Objetos
O Factory Method encapsula a lógica de construção de objetos complexos. É especialmente útil quando você tem injeção de dependências manual (sem tsyringe/inversify) ou precisa de diferentes configurações por contexto:
import { CreateUserUseCase } from '../CreateUserUseCase';
import { TypeORMUsersRepository } from '../../infra/typeorm/repositories/TypeORMUsersRepository';
import { Argon2HashProvider } from '../../infra/providers/hash/Argon2HashProvider';
import { SESMailProvider } from '../../infra/providers/mail/SESMailProvider';
import { RedisCacheProvider } from '../../infra/providers/cache/RedisCacheProvider';
// Factory: monta todas as dependências e retorna o Use Case pronto
export function makeCreateUserUseCase(): CreateUserUseCase {
const usersRepository = new TypeORMUsersRepository();
const hashProvider = new Argon2HashProvider();
const mailProvider = new SESMailProvider();
const cacheProvider = new RedisCacheProvider();
return new CreateUserUseCase(
usersRepository,
hashProvider,
mailProvider,
cacheProvider
);
}
// Uso no Controller:
export class UsersController {
async create(req: Request, res: Response): Promise<void> {
// O controller não conhece nenhuma das dependências
const useCase = makeCreateUserUseCase();
const user = await useCase.execute(req.body);
res.status(201).json(user);
}
}import { CreateUserUseCase } from '../CreateUserUseCase';
import { InMemoryUsersRepository } from '../../tests/repositories/InMemoryUsersRepository';
import { FakeHashProvider } from '../../tests/providers/FakeHashProvider';
import { FakeMailProvider } from '../../tests/providers/FakeMailProvider';
import { InMemoryCacheProvider } from '../../tests/providers/InMemoryCacheProvider';
// Factory para testes: substitui todas as implementações reais por Fakes
export function makeCreateUserUseCaseTest() {
const usersRepository = new InMemoryUsersRepository();
const hashProvider = new FakeHashProvider();
const mailProvider = new FakeMailProvider();
const cacheProvider = new InMemoryCacheProvider();
const useCase = new CreateUserUseCase(
usersRepository,
hashProvider,
mailProvider,
cacheProvider
);
// Retorna o useCase E os repositórios/providers para asserções
return { useCase, usersRepository, mailProvider };
}3. Strategy: Algoritmos Intercambiáveis
O Strategy define uma família de algoritmos, encapsula cada um e os torna intercambiáveis em runtime. Você já usa isso com providers de storage, hash e mail. Aqui um exemplo mais direto — estratégias de sorting/filtro em relatórios:
// Interface da Strategy
export interface IDiscountStrategy {
calculate(orderTotal: number, customerId: string): Promise<number>;
}
// Estratégia 1: Desconto por percentual
export class PercentageDiscountStrategy implements IDiscountStrategy {
constructor(private readonly percentage: number) {}
async calculate(orderTotal: number): Promise<number> {
return orderTotal * (1 - this.percentage / 100);
}
}
// Estratégia 2: Desconto para usuários premium (consulta o banco)
export class PremiumCustomerDiscountStrategy implements IDiscountStrategy {
constructor(private readonly usersRepo: IUsersRepository) {}
async calculate(orderTotal: number, customerId: string): Promise<number> {
const user = await this.usersRepo.findById(customerId);
if (user?.plan === 'PREMIUM') {
return orderTotal * 0.85; // 15% de desconto
}
return orderTotal;
}
}
// Estratégia 3: Sem desconto
export class NoDiscountStrategy implements IDiscountStrategy {
async calculate(orderTotal: number): Promise<number> {
return orderTotal;
}
}
// O contexto usa a strategy sem conhecer qual é:
export class OrderPricingService {
constructor(private readonly discountStrategy: IDiscountStrategy) {}
async calculateFinalPrice(orderTotal: number, customerId: string): Promise<number> {
return this.discountStrategy.calculate(orderTotal, customerId);
}
}
// Uso — seleção da strategy em runtime:
const strategy = promotionCode
? new PercentageDiscountStrategy(20)
: new PremiumCustomerDiscountStrategy(usersRepo);
const pricingService = new OrderPricingService(strategy);4. Observer: Reagindo a Eventos
O Observer define uma dependência um-para-muitos: quando um objeto muda, todos os seus dependentes são notificados. O Node.js tem suporte nativo via EventEmitter — que é essencialmente o Observer pattern:
import { EventEmitter } from 'events';
// Eventos de domínio tipados
interface DomainEvents {
'user.created': { userId: string; email: string; name: string };
'order.placed': { orderId: string; customerId: string; total: number };
'order.paid': { orderId: string; paymentId: string };
}
class TypedEventEmitter extends EventEmitter {
emit<K extends keyof DomainEvents>(event: K, data: DomainEvents[K]): boolean {
return super.emit(event, data);
}
on<K extends keyof DomainEvents>(
event: K,
listener: (data: DomainEvents[K]) => void | Promise<void>
): this {
return super.on(event, listener);
}
}
// Singleton: um único bus de eventos para toda a aplicação
export const eventBus = new TypedEventEmitter();
// Registra os listeners (handlers) em um arquivo de setup:
// eventBus.on('user.created', async ({ userId, email }) => {
// await mailProvider.send({ to: email, subject: 'Bem-vindo!', html: '...' });
// });
//
// eventBus.on('order.paid', async ({ orderId }) => {
// await notificationService.notifyCustomer(orderId);
// await invoiceService.generate(orderId);
// });
// Nos Use Cases:
export class CreateOrderUseCase {
async execute(dto: CreateOrderDTO): Promise<Order> {
const order = await this.ordersRepo.create(dto);
// Emite o evento — não sabe quem vai ouvir
eventBus.emit('order.placed', {
orderId: order.id,
customerId: dto.customerId,
total: order.total,
});
return order;
}
}O EventEmitter nativo funciona para monólitos. Para sistemas distribuídos (múltiplas instâncias ou microsserviços), substitua-o por um message broker real como Redis Pub/Sub, RabbitMQ ou AWS SNS/SQS — mantendo a mesma interface de emit/on como abstração.
Conclusão
Os Design Patterns não são padrões para memorizar — são vocabulário compartilhado. Quando você fala 'vamos usar um Strategy aqui', toda a equipe entende a estrutura proposta. Singleton para recursos de instância única (conexões, clientes HTTP). Factory para encapsular construção complexa e facilitar testes. Strategy para comportamentos intercambiáveis sem if/else. Observer para desacoplamento de reações a eventos de domínio.