SOLID na Prática: Os Cinco Princípios com Exemplos Reais em TypeScript
SRP, OCP, LSP, ISP e DIP aplicados em código TypeScript real. God Classes, extension points com Strategy Pattern, contratos de interface e Inversão de Dependência com tsyringe — SOLID como ferramenta de design, não decoração de currículo.
SOLID é o acrônimo mais citado em entrevistas e o mais mal aplicado na prática. Os cinco princípios não são regras para decorar — são guias de design que tornam código mais fácil de estender, testar e modificar. Este artigo mostra cada princípio com o anti-pattern (o que evitar) e a solução refatorada em TypeScript real.
S — Single Responsibility Principle (SRP)
"Uma classe deve ter apenas um motivo para mudar." O motivo para mudar é quem pede a mudança: regra de negócio, infraestrutura de banco, provedor de e-mail. Se três pessoas diferentes da empresa podem pedir mudanças no mesmo arquivo, ele tem três responsabilidades.
// ❌ RUIM: CreateUserService faz tudo
export class CreateUserService {
async execute(data: unknown) {
// Valida os dados (responsabilidade de validação)
if (!data.email) throw new Error('Email obrigatório');
// Acessa o banco diretamente (responsabilidade de persistência)
const exists = await db.query('SELECT * FROM users WHERE email = $1', [data.email]);
if (exists.rows.length) throw new Error('Email já cadastrado');
// Faz o hash (responsabilidade de criptografia)
const hash = await bcrypt.hash(data.password, 10);
await db.query('INSERT INTO users...');
// Envia e-mail (responsabilidade de notificação)
await nodemailer.sendMail({ to: data.email, subject: 'Bem-vindo!' });
}
}
// ✅ BOM: Cada classe tem uma responsabilidade
// UsersController → extrai dados do HTTP e devolve resposta
// CreateUserUseCase → orquestra a regra de negócio
// IUsersRepository → persiste e consulta usuários
// IMailProvider → envia e-mails
// CreateUserInputSchema (Zod) → valida e normaliza entrada
// Quando o provedor de e-mail mudar (Nodemailer → SES),
// apenas o MailProvider muda. O Use Case não é tocado.O — Open/Closed Principle (OCP)
"Aberto para extensão, fechado para modificação." Quando precisar de novo comportamento, adicione código — não modifique código existente que já funciona e tem testes.
// ❌ RUIM: if/else gigante que precisa ser modificado para cada novo provedor
class NotificationService {
async send(type: string, message: string, userId: string) {
if (type === 'email') { /* ... */ }
else if (type === 'sms') { /* ... */ }
else if (type === 'push') { /* ... */ }
// Para adicionar WhatsApp: precisa modificar esta classe
}
}
// ✅ BOM: Strategy Pattern — extensão sem modificação
interface INotificationStrategy {
send(message: string, userId: string): Promise<void>;
}
class EmailNotificationStrategy implements INotificationStrategy {
async send(message: string, userId: string): Promise<void> { /* ... */ }
}
class SMSNotificationStrategy implements INotificationStrategy {
async send(message: string, userId: string): Promise<void> { /* ... */ }
}
// Para WhatsApp: cria WhatsAppNotificationStrategy — sem tocar nas existentes
class WhatsAppNotificationStrategy implements INotificationStrategy {
async send(message: string, userId: string): Promise<void> { /* ... */ }
}
class NotificationService {
constructor(private strategy: INotificationStrategy) {}
async send(message: string, userId: string): Promise<void> {
return this.strategy.send(message, userId);
}
}L — Liskov Substitution Principle (LSP)
"Objetos de uma subclasse devem poder substituir objetos da superclasse sem quebrar o comportamento do programa." O LSP é violado quando uma implementação concreta não honra o contrato da interface:
// O LSP garante que InMemoryUsersRepository possa substituir TypeORMUsersRepository
// em testes sem que o Use Case perceba a diferença
interface IUsersRepository {
findById(id: string): Promise<User | null>;
}
// ❌ VIOLA LSP: InMemory lança erro em vez de retornar null
class BrokenInMemoryRepository implements IUsersRepository {
async findById(id: string): Promise<User | null> {
// Contrato: retorna null quando não encontrado
// Violação: lança exceção — comportamento diferente do TypeORM
throw new Error('Not found'); // ← quebra o contrato!
}
}
// ✅ RESPEITA LSP: mesma semântica da implementação real
class InMemoryUsersRepository implements IUsersRepository {
private items: Array<User> = [];
async findById(id: string): Promise<User | null> {
return this.items.find((u) => u.id === id) ?? null; // ← honra o contrato
}
}I — Interface Segregation Principle (ISP)
"Clientes não devem ser forçados a depender de interfaces que não usam." Interfaces gordas criam acoplamento desnecessário:
// ❌ RUIM: interface grande que força implementar métodos não usados
interface IEmailService {
sendWelcomeEmail(user: User): Promise<void>;
sendPasswordResetEmail(user: User, token: string): Promise<void>;
sendOrderConfirmation(order: Order): Promise<void>;
sendMarketingEmail(users: Array<User>, campaign: string): Promise<void>;
}
// Para testar CreateUserUseCase, o FakeMailProvider precisa implementar
// sendOrderConfirmation e sendMarketingEmail que ele nunca usa
// ✅ BOM: interfaces focadas por contexto de uso
interface IWelcomeMailSender {
sendWelcomeEmail(user: User): Promise<void>;
}
interface IPasswordResetMailSender {
sendPasswordResetEmail(user: User, token: string): Promise<void>;
}
// Use Case depende apenas da interface que usa
class CreateUserUseCase {
constructor(private mailSender: IWelcomeMailSender) {}
// Não sabe que sendPasswordResetEmail existe
}D — Dependency Inversion Principle (DIP)
"Módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender de abstrações." O Use Case é alto nível (regra de negócio). TypeORM é baixo nível (infraestrutura). O DIP exige que o Use Case dependa da interface, não da implementação:
// ❌ RUIM: Use Case importa TypeORM diretamente
import { dataSource } from '../typeorm';
export class CreateUserUseCase {
async execute(dto: CreateUserDTO) {
const repo = dataSource.getRepository(UserModel); // ← depende de infra
const exists = await repo.findOneBy({ email: dto.email });
// ...
}
}
// ✅ BOM: Use Case depende da abstração
import { injectable, inject } from 'tsyringe';
@injectable()
export class CreateUserUseCase {
constructor(
@inject('UsersRepository') // Abstração — não sabe qual classe concreta é
private usersRepo: IUsersRepository,
@inject('MailProvider')
private mailProvider: IWelcomeMailSender
) {}
async execute(dto: CreateUserDTO): Promise<User> {
const exists = await this.usersRepo.existsByEmail(dto.email);
if (exists) throw new AppError('E-mail já cadastrado.', 409);
const passwordHash = await hash(dto.password, 12);
const user = await this.usersRepo.create({ ...dto, passwordHash });
await this.mailProvider.sendWelcomeEmail(user);
return user;
}
}
// O container decide qual implementação injetar:
// - Em produção: TypeORMUsersRepository + NodemailerMailProvider
// - Em testes: InMemoryUsersRepository + FakeMailProviderSOLID não significa criar uma interface para tudo. O guia prático: crie abstrações (interfaces) quando o componente pode ter múltiplas implementações (banco de dados, provedor de e-mail, storage), quando precisa ser substituído em testes, ou quando pode mudar independentemente do restante. Para casos simples sem essas necessidades, o acoplamento direto é aceitável — SOLID é uma ferramenta, não um dogma.
Conclusão
Os cinco princípios do SOLID trabalham juntos: SRP cria classes focadas, OCP permite extensão sem modificação, LSP garante contratos confiáveis, ISP mantém interfaces enxutas, e DIP inverte as dependências colocando a lógica de negócio no centro. O resultado é um código onde adicionar features é criar novos arquivos, não modificar os existentes — e onde testes unitários são naturais, não sacrifícios.