Voltar para Artigos
Arquitetura12 min de leitura

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.

12 de agosto de 2026

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:

client.ts (Singleton via módulo)
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 sempre
Singleton clássico com classe (quando precisa de controle explícito)
export 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:

makeCreateUserUseCase.ts
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);
  }
}
Factory para testes: FakeFactory
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:

IDiscountStrategy.ts
// 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:

DomainEventBus.ts
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.