Ir para o conteúdo
Backend

Design Patterns que Todo Dev Senior Deveria Dominar em TypeScript

Marcos Soares
Atualizado em 
11 minutos de leitura
Ilustracao 3D de sete estruturas geometricas de vidro translucido conectadas representando design patterns em TypeScript
Ouça este artigo
0:00Design Patterns que Todo Dev Senior Deveria Dominar em TypeScript--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

Por que patterns ainda importam em 2024?

Design Patterns não são relíquias do Gang of Four. Eles são vocabulário compartilhado entre devs experientes.

Quando alguém diz "usa um Strategy aqui", toda a equipe entende a intenção. Sem explicações longas. Sem diagramas. A comunicação flui.

O problema? Muitos devs decoram a teoria e nunca implementam de verdade. Especialmente em TypeScript, onde o sistema de tipos transforma patterns clássicos em soluções elegantes e type-safe.

Vamos aos 7 patterns que mais uso em produção.

Setup do projeto

Crie um projeto TypeScript limpo para acompanhar os exemplos:

Bash
mkdir ts-patterns && cd ts-patterns
npm init -y
npm install typescript tsx -D
npx tsc --init --strict --target ES2022 --module NodeNext --moduleResolution NodeNext

Rode qualquer arquivo com:

Bash
npx tsx src/nome-do-arquivo.ts

1. Singleton: Estado global controlado

O Singleton garante uma única instância de uma classe. Em TypeScript, usamos private constructor para impedir instanciação externa.

Quando usar

Conexões de banco, loggers, caches em memória. Qualquer recurso que deve existir uma única vez no ciclo de vida da aplicação.

Implementação real

TYPESCRIPT
// src/singleton.ts
class DatabaseConnection {
  private static instance: DatabaseConnection | null = null;
  private connectionId: string;
 
  private constructor() {
    this.connectionId = crypto.randomUUID();
    console.log(`[DB] Nova conexão criada: ${this.connectionId}`);
  }
 
  static getInstance(): DatabaseConnection {
    if (!DatabaseConnection.instance) {
      DatabaseConnection.instance = new DatabaseConnection();
    }
    return DatabaseConnection.instance;
  }
 
  query(sql: string): string {
    return `[${this.connectionId}] Executando: ${sql}`;
  }
}
 
// Teste
const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();
 
console.log(db1 === db2); // true
console.log(db1.query("SELECT * FROM users"));

A mensagem "Nova conexão criada" aparece uma única vez. Ambas as variáveis apontam para o mesmo objeto.

Versão funcional (sem classe)

Nem tudo precisa de classe. Closures resolvem:

TYPESCRIPT
// src/singleton-functional.ts
function createLogger() {
  let instance: { log: (msg: string) => void } | null = null;
 
  return () => {
    if (!instance) {
      const id = crypto.randomUUID().slice(0, 8);
      instance = {
        log: (msg: string) => console.log(`[${id}] ${msg}`),
      };
    }
    return instance;
  };
}
 
const getLogger = createLogger();
 
const logger1 = getLogger();
const logger2 = getLogger();
 
logger1.log("teste"); // [abc12345] teste
console.log(logger1 === logger2); // true

2. Strategy: Troque algoritmos em runtime

O Strategy encapsula algoritmos intercambiáveis. O cliente escolhe qual usar sem alterar o código que consome.

Esse é o pattern que mais uso no dia a dia. Processamento de pagamentos, validações, formatações, tudo vira Strategy.

Implementação com interfaces

TYPESCRIPT
// src/strategy.ts
interface PricingStrategy {
  calculate(basePrice: number): number;
  readonly name: string;
}
 
class RegularPricing implements PricingStrategy {
  readonly name = "regular";
  calculate(basePrice: number): number {
    return basePrice;
  }
}
 
class PremiumPricing implements PricingStrategy {
  readonly name = "premium";
  calculate(basePrice: number): number {
    return basePrice * 0.8; // 20% desconto
  }
}
 
class BlackFridayPricing implements PricingStrategy {
  readonly name = "black-friday";
  calculate(basePrice: number): number {
    return basePrice * 0.5; // 50% desconto
  }
}
 
class ShoppingCart {
  private items: { name: string; price: number }[] = [];
  private strategy: PricingStrategy;
 
  constructor(strategy: PricingStrategy) {
    this.strategy = strategy;
  }
 
  setStrategy(strategy: PricingStrategy): void {
    console.log(`Estratégia alterada para: ${strategy.name}`);
    this.strategy = strategy;
  }
 
  addItem(name: string, price: number): void {
    this.items.push({ name, price });
  }
 
  getTotal(): number {
    const base = this.items.reduce((sum, item) => sum + item.price, 0);
    return this.strategy.calculate(base);
  }
}
 
// Uso
const cart = new ShoppingCart(new RegularPricing());
cart.addItem("Teclado", 300);
cart.addItem("Mouse", 150);
 
console.log(`Regular: R$${cart.getTotal()}`); // 450
 
cart.setStrategy(new BlackFridayPricing());
console.log(`Black Friday: R$${cart.getTotal()}`); // 225

Adicionar uma nova estratégia? Crie uma classe. Zero alteração no ShoppingCart. Isso é Open/Closed Principle na prática.

3. Observer: Eventos desacoplados

O Observer permite que objetos reajam a mudanças sem acoplamento direto. É a base do EventEmitter do Node.js e do addEventListener do DOM.

Implementação type-safe

TYPESCRIPT
// src/observer.ts
type EventMap = {
  "user:created": { id: string; email: string };
  "user:deleted": { id: string };
  "order:placed": { orderId: string; total: number };
};
 
class TypedEventEmitter<T extends Record<string, unknown>> {
  private listeners = new Map<keyof T, Set<(data: any) => void>>();
 
  on<K extends keyof T>(event: K, callback: (data: T[K]) => void): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
 
    this.listeners.get(event)!.add(callback);
 
    // Retorna função de unsubscribe
    return () => {
      this.listeners.get(event)?.delete(callback);
    };
  }
 
  emit<K extends keyof T>(event: K, data: T[K]): void {
    this.listeners.get(event)?.forEach((cb) => cb(data));
  }
}
 
// Uso
const emitter = new TypedEventEmitter<EventMap>();
 
const unsubscribe = emitter.on("user:created", (data) => {
  // TypeScript sabe que data tem id e email
  console.log(`Bem-vindo, ${data.email}!`);
});
 
emitter.on("order:placed", (data) => {
  // TypeScript sabe que data tem orderId e total
  console.log(`Pedido ${data.orderId}: R$${data.total}`);
});
 
emitter.emit("user:created", { id: "1", email: "[email protected]" });
emitter.emit("order:placed", { orderId: "ORD-42", total: 299.9 });
 
// Cancelar inscrição
unsubscribe();
emitter.emit("user:created", { id: "2", email: "[email protected]" }); // silêncio

O EventMap garante autocomplete e validação em tempo de compilação. Emitir um evento com payload errado gera erro. Isso é impossível em JavaScript puro.

4. Factory: Criação sem new espalhado

O Factory centraliza a criação de objetos. Quando a lógica de instanciação é complexa ou depende de condições, extraia para uma factory.

Implementação com discriminated unions

TYPESCRIPT
// src/factory.ts
interface BaseNotification {
  send(message: string): void;
}
 
class EmailNotification implements BaseNotification {
  constructor(private to: string) {}
 
  send(message: string): void {
    console.log(`📧 Email para ${this.to}: ${message}`);
  }
}
 
class SMSNotification implements BaseNotification {
  constructor(private phone: string) {}
 
  send(message: string): void {
    console.log(`📱 SMS para ${this.phone}: ${message}`);
  }
}
 
class PushNotification implements BaseNotification {
  constructor(private deviceId: string) {}
 
  send(message: string): void {
    console.log(`🔔 Push para ${this.deviceId}: ${message}`);
  }
}
 
type NotificationConfig =
  | { type: "email"; to: string }
  | { type: "sms"; phone: string }
  | { type: "push"; deviceId: string };
 
function createNotification(config: NotificationConfig): BaseNotification {
  switch (config.type) {
    case "email":
      return new EmailNotification(config.to);
    case "sms":
      return new SMSNotification(config.phone);
    case "push":
      return new PushNotification(config.deviceId);
  }
}
 
// Uso
const notifications: NotificationConfig[] = [
  { type: "email", to: "[email protected]" },
  { type: "sms", phone: "+5511999999999" },
  { type: "push", deviceId: "device-abc-123" },
];
 
notifications.forEach((config) => {
  const notifier = createNotification(config);
  notifier.send("Seu pedido foi confirmado!");
});

O switch com discriminated unions é exhaustive. Se você adicionar um novo tipo no NotificationConfig e esquecer o case, o TypeScript reclama. Segurança total.

5. Builder: Objetos complexos passo a passo

Construtores com 10 parâmetros são ilegíveis. O Builder resolve isso com uma API fluente.

Implementação fluente

TYPESCRIPT
// src/builder.ts
interface HttpRequest {
  url: string;
  method: "GET" | "POST" | "PUT" | "DELETE";
  headers: Record<string, string>;
  body?: string;
  timeout: number;
  retries: number;
}
 
class RequestBuilder {
  private request: Partial<HttpRequest> = {
    method: "GET",
    headers: {},
    timeout: 5000,
    retries: 0,
  };
 
  url(url: string): this {
    this.request.url = url;
    return this;
  }
 
  method(method: HttpRequest["method"]): this {
    this.request.method = method;
    return this;
  }
 
  header(key: string, value: string): this {
    this.request.headers![key] = value;
    return this;
  }
 
  json(data: unknown): this {
    this.request.body = JSON.stringify(data);
    this.request.headers!["Content-Type"] = "application/json";
    return this;
  }
 
  timeout(ms: number): this {
    this.request.timeout = ms;
    return this;
  }
 
  retries(count: number): this {
    this.request.retries = count;
    return this;
  }
 
  build(): HttpRequest {
    if (!this.request.url) {
      throw new Error("URL é obrigatória");
    }
 
    return { ...this.request } as HttpRequest;
  }
}
 
// Uso
const request = new RequestBuilder()
  .url("https://api.exemplo.com/users")
  .method("POST")
  .header("Authorization", "Bearer token-123")
  .json({ name: "João", role: "senior" })
  .timeout(10000)
  .retries(3)
  .build();
 
console.log(request);

Cada método retorna this. Isso habilita o chaining. O build() valida e retorna o objeto final imutável.

Compare com a alternativa sem Builder:

TYPESCRIPT
// Isso é legível? Não.
const req = new HttpRequest(
  "https://api.com/users",
  "POST",
  { Authorization: "Bearer token", "Content-Type": "application/json" },
  JSON.stringify({ name: "João" }),
  10000,
  3
);

Builder vence em legibilidade. Sempre.

6. Adapter: Integre interfaces incompatíveis

O Adapter traduz uma interface para outra. É essencial quando você integra bibliotecas de terceiros ou migra entre serviços.

Cenário real: trocar provedor de cache

TYPESCRIPT
// src/adapter.ts
 
// Interface que SEU código espera
interface CacheService {
  get(key: string): Promise<string | null>;
  set(key: string, value: string, ttlSeconds: number): Promise<void>;
  delete(key: string): Promise<void>;
}
 
// "Biblioteca" legada com interface diferente
class OldRedisClient {
  private store = new Map<string, { value: string; expiresAt: number }>();
 
  fetch(k: string): string | undefined {
    const entry = this.store.get(k);
    if (!entry) return undefined;
    if (Date.now() > entry.expiresAt) {
      this.store.delete(k);
      return undefined;
    }
    return entry.value;
  }
 
  put(k: string, v: string, ttlMs: number): void {
    this.store.set(k, { value: v, expiresAt: Date.now() + ttlMs });
  }
 
  remove(k: string): boolean {
    return this.store.delete(k);
  }
}
 
// Adapter que traduz OldRedisClient → CacheService
class RedisAdapter implements CacheService {
  constructor(private client: OldRedisClient) {}
 
  async get(key: string): Promise<string | null> {
    return this.client.fetch(key) ?? null;
  }
 
  async set(key: string, value: string, ttlSeconds: number): Promise<void> {
    this.client.put(key, value, ttlSeconds * 1000);
  }
 
  async delete(key: string): Promise<void> {
    this.client.remove(key);
  }
}
 
// Código de negócio usa APENAS CacheService
async function getUserFromCache(cache: CacheService, userId: string) {
  const cached = await cache.get(`user:${userId}`);
  if (cached) {
    console.log("Cache hit:", JSON.parse(cached));
    return JSON.parse(cached);
  }
 
  // Simula busca no banco
  const user = { id: userId, name: "João" };
  await cache.set(`user:${userId}`, JSON.stringify(user), 3600);
  console.log("Cache miss. Armazenado.");
  return user;
}
 
// Uso
const legacyClient = new OldRedisClient();
const cache = new RedisAdapter(legacyClient);
 
await getUserFromCache(cache, "42");
await getUserFromCache(cache, "42"); // hit

Amanhã, se trocar Redis por Memcached, crie um novo Adapter. O getUserFromCache não muda uma linha.

7. Decorator: Adicione comportamento sem herança

O Decorator envolve um objeto e adiciona funcionalidade. TypeScript 5+ suporta decorators nativos, mas o pattern funciona sem eles.

Implementação com composição

TYPESCRIPT
// src/decorator.ts
interface Logger {
  log(message: string): void;
}
 
class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(message);
  }
}
 
class TimestampDecorator implements Logger {
  constructor(private inner: Logger) {}
 
  log(message: string): void {
    const timestamp = new Date().toISOString();
    this.inner.log(`[${timestamp}] ${message}`);
  }
}
 
class PrefixDecorator implements Logger {
  constructor(
    private inner: Logger,
    private prefix: string
  ) {}
 
  log(message: string): void {
    this.inner.log(`${this.prefix} ${message}`);
  }
}
 
class FilterDecorator implements Logger {
  constructor(
    private inner: Logger,
    private minLength: number
  ) {}
 
  log(message: string): void {
    if (message.length >= this.minLength) {
      this.inner.log(message);
    }
  }
}
 
// Composição de decorators: empilhe como quiser
const logger: Logger = new FilterDecorator(
  new TimestampDecorator(new PrefixDecorator(new ConsoleLogger(), "[APP]")),
  5
);
 
logger.log("ok"); // filtrado (< 5 chars)
logger.log("Servidor iniciado na porta 3000");
// [2024-01-15T10:30:00.000Z] [APP] Servidor iniciado na porta 3000

Cada decorator tem uma responsabilidade. Você compõe comportamentos como blocos de LEGO. Sem herança profunda. Sem classes God.

Tabela resumo: quando usar cada pattern

PatternUse quando...Evite quando...
SingletonRecurso único global (DB, logger)Estado mutável compartilhado demais
StrategyAlgoritmos intercambiáveisSó existe uma variação
ObserverComunicação desacoplada entre módulosFluxo simples e linear
FactoryCriação condicional complexaConstrutor simples basta
BuilderObjetos com muitos parâmetros opcionaisObjetos com 2-3 campos
AdapterIntegração com interfaces externasCódigo 100% sob seu controle
DecoratorAdicionar comportamento incrementalLógica que não se compõe

Minha Opinião Sincera

Design Patterns são ferramentas. Não são mandamentos.

Já vi projetos onde um if/else resolvia em 5 linhas, mas alguém criou uma Factory com Strategy e Abstract Factory aninhados. Resultado: 200 linhas, 6 arquivos, zero legibilidade. Over-engineering é tão perigoso quanto código espaguete.

O que realmente separa um dev senior não é conhecer todos os patterns do catálogo GoF. É saber quando não usar um pattern.

Meus favoritos no dia a dia? Strategy e Observer. Cobrem 80% dos cenários onde patterns fazem sentido. Builder é ótimo para configurações complexas. Factory aparece naturalmente em qualquer sistema com polimorfismo.

Singleton? Uso com moderação extrema. Ele é basicamente estado global disfarçado de pattern. Em aplicações com testes automatizados, Singleton vira pesadelo: difícil de mockar, difícil de resetar. Prefiro injeção de dependência na maioria dos casos.

Sobre Decorator: o pattern em si é poderoso, mas os decorators do TypeScript (annotations com @) ainda me incomodam. Dependem de experimentalDecorators ou do stage 3 proposal, e cada framework usa de um jeito. Prefiro composição explícita como mostrei acima.

Uma crítica que faço ao ecossistema: muitos tutoriais ensinam patterns com exemplos de animais e formas geométricas. class Dog extends Animal. Isso não ensina nada. Patterns só fazem sentido quando você vê o problema real que eles resolvem: pagamentos, notificações, cache, HTTP.

Por fim, TypeScript é o melhor ambiente para patterns. O sistema de tipos garante que interfaces sejam respeitadas em compile time. Discriminated unions eliminam bugs em Factories. Generics tornam Observers type-safe. Se você vem do JavaScript puro, patterns em TS são outra experiência.

Domine esses 7. Aplique com critério. Seu código (e sua equipe) vão agradecer.

Text
 
## Leia tambem
 
- [Arquitetura Hexagonal com TypeScript](/backend/arquitetura-hexagonal-typescript-guia-pratico)
- [Prisma ORM vs Drizzle ORM](/backend/prisma-orm-vs-drizzle-orm-benchmark-real-2025)
- [WebSockets vs SSE](/backend/websockets-vs-server-sent-events-quando-usar-cada-um)
Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.