A maioria dos cursos de design patterns ensina UML, exemplos em Java dos anos 90, e uma lista de 23 patterns para "conhecer". O resultado: engenheiros que sabem nomear patterns mas que não os reconhecem quando aparecem em código real, e não sabem quando aplicá-los.
Patterns são vocabulário. Quando você reconhece um pattern num sistema, você entende intenções de design que não estão escritas no código. Quando você aplica um pattern, você comunica uma decisão de design para quem vai ler depois.
Estes são os patterns que aparecem em todo codebase de produção que já analisei, com implementação em TypeScript moderno.
Repository Pattern
Abstrai o acesso a dados atrás de uma interface. O código de domínio não sabe se os dados vêm de um banco SQL, de uma API, ou de memória.
interface UserRepository {
findById(id: string): Promise<User | null>
findByEmail(email: string): Promise<User | null>
save(user: User): Promise<User>
delete(id: string): Promise<void>
}
class PrismaUserRepository implements UserRepository {
constructor(private readonly db: PrismaClient) {}
async findById(id: string): Promise<User | null> {
return this.db.user.findUnique({ where: { id } })
}
async save(user: User): Promise<User> {
return this.db.user.upsert({
where: { id: user.id },
create: user,
update: user,
})
}
// ...
}
// Em testes: substitua por InMemoryUserRepository
// sem mudar nada no código de domínio
Quando usar: sempre que você acessa dados persistidos. Facilita testes, permite trocar a source de dados, e mantém lógica de domínio limpa.
Strategy Pattern
Encapsula algoritmos intercambiáveis. Em vez de if/else gigante para comportamento variável, você define uma interface e múltiplas implementações.
interface NotificationStrategy {
send(to: string, message: string): Promise<void>
}
class EmailNotification implements NotificationStrategy {
async send(to: string, message: string): Promise<void> {
await sendEmail({ to, body: message })
}
}
class SlackNotification implements NotificationStrategy {
async send(to: string, message: string): Promise<void> {
await postToSlack({ channel: to, text: message })
}
}
class NotificationService {
constructor(private readonly strategy: NotificationStrategy) {}
async notify(user: User, message: string): Promise<void> {
await this.strategy.send(user.contact, message)
}
}
Quando usar: quando você tem variações de um algoritmo que podem crescer independentemente. Evita switch statements que precisam mudar toda vez que uma nova variante é adicionada.
Observer Pattern
Permite que objetos sejam notificados de eventos sem acoplamento direto. O padrão por trás de sistemas de eventos, reactive programming, e webhooks.
type EventHandler<T> = (payload: T) => void | Promise<void>
class EventBus {
private readonly handlers: Map<string, EventHandler<unknown>[]> = new Map()
on<T>(event: string, handler: EventHandler<T>): void {
const existing = this.handlers.get(event) ?? []
this.handlers.set(event, [...existing, handler as EventHandler<unknown>])
}
async emit<T>(event: string, payload: T): Promise<void> {
const handlers = this.handlers.get(event) ?? []
await Promise.all(handlers.map(h => h(payload)))
}
}
// Uso
const bus = new EventBus()
bus.on<{ userId: string }>('user.created', async ({ userId }) => {
await sendWelcomeEmail(userId)
})
bus.on<{ userId: string }>('user.created', async ({ userId }) => {
await createDefaultSettings(userId)
})
Decorator Pattern
Adiciona comportamento a um objeto sem modificar sua classe. Em TypeScript, implementado tanto com classes quanto com higher-order functions.
// Com funções — mais idiomático em TypeScript moderno
function withRetry<T extends (...args: unknown[]) => Promise<unknown>>(
fn: T,
maxAttempts = 3
): T {
return (async (...args: Parameters<T>) => {
let lastError: Error
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn(...args)
} catch (error) {
lastError = error as Error
if (attempt < maxAttempts) {
await new Promise(r => setTimeout(r, attempt * 1000))
}
}
}
throw lastError!
}) as T
}
const fetchWithRetry = withRetry(fetchUserFromAPI, 3)
Builder Pattern
Constrói objetos complexos passo a passo. Especialmente útil quando um objeto tem muitos parâmetros opcionais ou configuração variável.
class QueryBuilder {
private table = ''
private conditions: string[] = []
private limitValue: number | null = null
private orderByField: string | null = null
from(table: string): this {
this.table = table
return this
}
where(condition: string): this {
this.conditions.push(condition)
return this
}
limit(n: number): this {
this.limitValue = n
return this
}
orderBy(field: string): this {
this.orderByField = field
return this
}
build(): string {
let query = `SELECT * FROM ${this.table}`
if (this.conditions.length) {
query += ` WHERE ${this.conditions.join(' AND ')}`
}
if (this.orderByField) query += ` ORDER BY ${this.orderByField}`
if (this.limitValue) query += ` LIMIT ${this.limitValue}`
return query
}
}
// Uso fluente e legível
const query = new QueryBuilder()
.from('users')
.where('active = true')
.where('role = "admin"')
.orderBy('created_at')
.limit(10)
.build()
Quando não usar patterns
O erro mais comum com patterns não é não conhecê-los — é over-engineering: aplicar patterns em código simples que não precisa deles.
Se você tem um único algoritmo que não vai variar, Strategy é complexidade desnecessária. Se você tem um único subscriber num sistema de eventos, Observer é overhead sem benefício. Se você tem um objeto com dois campos, Builder é demais.
Patterns resolvem problemas de variabilidade e extensibilidade. Se você não tem esses problemas, você não precisa das soluções.