Os cinco princípios SOLID são guias para escrever código orientado a objetos que seja fácil de entender, estender e testar. Não são leis absolutas — são heurísticas. O objetivo final é sempre reduzir o custo de mudança.
S — Single Responsibility Principle (SRP)
Definição: Uma classe deve ter apenas um motivo para mudar. “Motivo para mudar” significa um ator ou stakeholder que pode solicitar alterações. Se dois times diferentes podem pedir mudanças em uma mesma classe, ela tem responsabilidades demais.
Motivação: classes com múltiplas responsabilidades acumulam acoplamento acidental. Quando uma responsabilidade muda, o código das outras pode ser afetado acidentalmente.
// ❌ ANTES — Invoice tem dois motivos para mudar:
// 1. Regra de negócio (cálculo)
// 2. Formato de saída (renderização)
public class Invoice {
private List<Item> items;
public Money calculateTotal() {
return items.stream()
.map(item -> item.price().multiply(item.quantity()))
.reduce(Money.ZERO, Money::add);
}
public String toHtml() {
// lógica de renderização acoplada à entidade
StringBuilder sb = new StringBuilder("<table>");
for (Item item : items) {
sb.append("<tr><td>").append(item.name()).append("</td>");
sb.append("<td>").append(item.price()).append("</td></tr>");
}
sb.append("</table>");
return sb.toString();
}
public void sendByEmail(String recipient) {
// lógica de envio também aqui — terceiro motivo para mudar
emailClient.send(recipient, this.toHtml());
}
}
// ✅ DEPOIS — cada classe com uma única responsabilidade
public class Invoice {
private final List<Item> items;
public Money calculateTotal() {
return items.stream()
.map(item -> item.price().multiply(item.quantity()))
.reduce(Money.ZERO, Money::add);
}
public List<Item> items() { return Collections.unmodifiableList(items); }
}
public class InvoiceHtmlRenderer {
public String render(Invoice invoice) {
StringBuilder sb = new StringBuilder("<table>");
for (Item item : invoice.items()) {
sb.append("<tr><td>").append(item.name()).append("</td></tr>");
}
return sb.append("</table>").toString();
}
}
public class InvoiceEmailSender {
private final InvoiceHtmlRenderer renderer;
private final EmailClient emailClient;
public void send(Invoice invoice, String recipient) {
String html = renderer.render(invoice);
emailClient.send(recipient, html);
}
}Armadilhas comuns:
- Confundir “responsabilidade” com “método”. Uma classe pode ter 10 métodos e uma responsabilidade só.
- Extrair classes demais — criar
InvoiceCalculatorHelperServicepara um método de 3 linhas é over-engineering. - SRP não significa “uma classe por ação CRUD”.
Como identificar em code review: pergunte “se o time de UI mudar o layout, essa classe vai mudar? E se a regra de cálculo mudar?”. Se ambas as respostas forem “sim”, a classe tem duas responsabilidades.
O — Open/Closed Principle (OCP)
Definição: Entidades de software devem estar abertas para extensão, mas fechadas para modificação. Ao adicionar um novo comportamento, você deve adicionar código novo — não modificar o existente.
Motivação: modificar código existente que já funciona e está testado introduz risco. OCP promove extensão via polimorfismo, herança ou composição.
// ❌ ANTES — cada nova regra de frete exige modificar ShippingCalculator
public class ShippingCalculator {
public Money calculate(Order order, String shippingType) {
if (shippingType.equals("standard")) {
return order.weight().multiply(2.50);
} else if (shippingType.equals("express")) {
return order.weight().multiply(5.00).add(Money.of(10));
} else if (shippingType.equals("same-day")) {
return order.weight().multiply(8.00).add(Money.of(20));
}
// ao adicionar "drone-delivery" precisamos abrir e modificar este método
throw new IllegalArgumentException("Tipo de frete desconhecido: " + shippingType);
}
}
// ✅ DEPOIS — nova modalidade = nova classe, sem tocar no código existente
public interface ShippingStrategy {
Money calculate(Order order);
}
public class StandardShipping implements ShippingStrategy {
@Override
public Money calculate(Order order) {
return order.weight().multiply(2.50);
}
}
public class ExpressShipping implements ShippingStrategy {
@Override
public Money calculate(Order order) {
return order.weight().multiply(5.00).add(Money.of(10));
}
}
public class SameDayShipping implements ShippingStrategy {
@Override
public Money calculate(Order order) {
return order.weight().multiply(8.00).add(Money.of(20));
}
}
// Drone delivery: nova classe, zero modificação nas existentes
public class DroneShipping implements ShippingStrategy {
@Override
public Money calculate(Order order) {
if (order.weight().isGreaterThan(Weight.of(5, KG))) {
throw new ShippingNotAvailableException("Drone suporta até 5kg");
}
return Money.of(15);
}
}
public class ShippingCalculator {
public Money calculate(Order order, ShippingStrategy strategy) {
return strategy.calculate(order);
}
}Armadilhas comuns:
- Tentar aplicar OCP antes de ter o segundo caso concreto — “premature abstraction”.
- OCP não significa “nunca modificar código” — correções de bug e refactorings são sempre válidos.
- Usar herança para OCP quando composição seria mais flexível.
Como identificar em code review: procure por if/else ou switch baseado em tipo — são candidatos para Strategy ou polimorfismo. Pergunte: “se amanhã aparecer mais um caso, o desenvolvedor vai modificar este método?”.
L — Liskov Substitution Principle (LSP)
Definição: Subclasses devem ser substituíveis pelas suas superclasses sem que o comportamento do programa seja alterado. Formalmente: se S é subtipo de T, então objetos do tipo T podem ser substituídos por objetos do tipo S sem alterar as propriedades desejáveis do programa.
Motivação: violações do LSP quebram o polimorfismo — você é forçado a fazer casting ou instanceof para tratar subclasses de forma especial.
// ❌ ANTES — Penguin herda Bird mas viola o contrato de fly()
public class Bird {
public void fly() {
System.out.println("voando...");
}
}
public class Sparrow extends Bird {
@Override
public void fly() {
System.out.println("pardal voando");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
// viola LSP: lança exceção onde o contrato promete comportamento
throw new UnsupportedOperationException("Pinguins não voam!");
}
}
// Código que explode em runtime
public void makeAllBirdsFly(List<Bird> birds) {
birds.forEach(Bird::fly); // lança exceção quando chega no Penguin
}
// ❌ Violação clássica — Rectangle/Square
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int area() { return width * height; }
}
public class Square extends Rectangle {
@Override
public void setWidth(int w) {
this.width = w;
this.height = w; // quebra a invariante de Rectangle
}
@Override
public void setHeight(int h) {
this.height = h;
this.width = h; // idem
}
}
// Este teste passa com Rectangle mas falha com Square
void testArea(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
assert r.area() == 20; // falha com Square: area = 16 (4x4)
}
// ✅ DEPOIS — modele por comportamento, não por hierarquia taxonômica
public interface Flyable {
void fly();
}
public interface Swimmable {
void swim();
}
public class Sparrow implements Flyable {
@Override
public void fly() { System.out.println("pardal voando"); }
}
public class Penguin implements Swimmable {
@Override
public void swim() { System.out.println("pinguim nadando"); }
}
public class Duck implements Flyable, Swimmable {
@Override
public void fly() { System.out.println("pato voando"); }
@Override
public void swim() { System.out.println("pato nadando"); }
}
// Para Rectangle/Square: prefira composição ou tipos distintos sem herança
public record Rectangle(int width, int height) {
public int area() { return width * height; }
public Rectangle withWidth(int w) { return new Rectangle(w, height); }
public Rectangle withHeight(int h) { return new Rectangle(width, h); }
}
public record Square(int side) {
public int area() { return side * side; }
public Square withSide(int s) { return new Square(s); }
}Armadilhas comuns:
- Herança por reutilização de código, não por relação “é um” semântica verdadeira.
- Subclasses que estreitam as pré-condições (exigem mais) ou alargam as pós-condições (garantem menos).
- Ignorar contratos implícitos documentados apenas em comentários.
Como identificar em code review: instanceof checks ou casting para subtipos específicos dentro de métodos polimórficos são sinal vermelho. Pergunte: “posso substituir a classe base por qualquer subtipo sem surpresas?”.
I — Interface Segregation Principle (ISP)
Definição: Clientes não devem ser forçados a depender de interfaces que não usam. Prefira interfaces pequenas e coesas a interfaces “gordas” que agrupam múltiplos contratos.
Motivação: interfaces grandes criam acoplamento desnecessário. Uma classe que implementa uma interface gorda é obrigada a conhecer métodos que nunca vai chamar.
// ❌ ANTES — interface gorda força implementações indesejadas
public interface WorkerContract {
void work();
void eat();
void sleep();
void submitTimesheet();
void attendMeeting();
void writeReport();
}
// Robot é obrigado a implementar eat(), sleep(), etc.
public class Robot implements WorkerContract {
@Override
public void work() { System.out.println("processando..."); }
@Override
public void eat() { throw new UnsupportedOperationException("Robot não come"); }
@Override
public void sleep() { throw new UnsupportedOperationException("Robot não dorme"); }
// obrigado a implementar tudo que não usa...
}
// ❌ Exemplo com repositório — interface de leitura/escrita misturada
public interface OrderRepository {
void save(Order order);
void delete(String orderId);
Order findById(String id);
List<Order> findAll();
List<Order> findByCustomer(String customerId);
void updateStatus(String orderId, OrderStatus status);
Long countByStatus(OrderStatus status);
List<Order> findForReport(LocalDate start, LocalDate end);
}
// Use case de relatório só precisa de leitura — mas depende de save() e delete()
// ✅ DEPOIS — interfaces segregadas por intenção
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public interface Sleepable {
void sleep();
}
public interface Reportable {
void submitTimesheet();
void writeReport();
}
public class HumanEmployee implements Workable, Eatable, Sleepable, Reportable {
@Override public void work() { /* trabalha */ }
@Override public void eat() { /* almoça */ }
@Override public void sleep() { /* descansa */ }
@Override public void submitTimesheet() { /* envia horas */ }
@Override public void writeReport() { /* escreve relatório */ }
}
public class Robot implements Workable {
@Override public void work() { System.out.println("processando..."); }
// sem métodos desnecessários
}
// ✅ Repositório segregado por intenção de uso
public interface OrderWriteRepository {
void save(Order order);
void delete(String orderId);
void updateStatus(String orderId, OrderStatus status);
}
public interface OrderReadRepository {
Order findById(String id);
List<Order> findAll();
List<Order> findByCustomer(String customerId);
}
public interface OrderReportRepository {
Long countByStatus(OrderStatus status);
List<Order> findForReport(LocalDate start, LocalDate end);
}
// Use case de relatório depende apenas de OrderReportRepository
public class OrderReportUseCase {
private final OrderReportRepository reportRepo; // dependência mínima
public ReportData generate(LocalDate start, LocalDate end) {
return new ReportData(
reportRepo.findForReport(start, end),
reportRepo.countByStatus(OrderStatus.COMPLETED)
);
}
}Armadilhas comuns:
- Criar uma interface por método (extremo oposto) — granularidade excessiva dificulta implementações.
- Confundir ISP com “cada interface deve ter um método só”.
- Não aplicar ISP em interfaces de repositório resulta em testes que precisam mockar métodos nunca usados.
Como identificar em code review: implementações com throw new UnsupportedOperationException() ou métodos que retornam null/lista vazia sem lógica real são violações de ISP.
D — Dependency Inversion Principle (DIP)
Definição: Módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender de abstrações. Abstrações não devem depender de detalhes — detalhes devem depender de abstrações.
Motivação: acoplamento direto a implementações concretas (Stripe, JPA, SendGrid) torna o código impossível de testar em isolamento e difícil de trocar. DIP inverte o controle: o domínio define as interfaces que a infraestrutura implementa.
// ❌ ANTES — domínio acoplado diretamente a infraestrutura
public class OrderService {
// acoplamento concreto: cria e usa implementações diretamente
private final JpaOrderRepository jpaRepository = new JpaOrderRepository();
private final StripePaymentClient stripeClient = new StripePaymentClient("sk_live_...");
private final SendGridEmailService sendGrid = new SendGridEmailService("SG.xxx");
public void placeOrder(PlaceOrderCommand cmd) {
Order order = new Order(cmd.customerId(), cmd.items());
// difícil testar sem banco real, Stripe real e SendGrid real
jpaRepository.save(order);
stripeClient.charge(order.total().toBigDecimal(), cmd.cardToken());
sendGrid.sendConfirmation(cmd.email(), order.id());
}
}
// ✅ DEPOIS — domínio depende de abstrações; infraestrutura depende do domínio
// --- ABSTRAÇÕES (domínio define) ---
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
}
public interface PaymentGateway {
PaymentResult charge(Money amount, String customerId);
void refund(String paymentId);
}
public interface NotificationService {
void notifyOrderPlaced(Order order, String email);
}
// --- DOMÍNIO usa abstrações ---
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final NotificationService notificationService;
// injeção via construtor — testável e explícita
public OrderService(
OrderRepository orderRepository,
PaymentGateway paymentGateway,
NotificationService notificationService) {
this.orderRepository = orderRepository;
this.paymentGateway = paymentGateway;
this.notificationService = notificationService;
}
public OrderId placeOrder(PlaceOrderCommand cmd) {
Order order = new Order(cmd.customerId(), cmd.items());
PaymentResult payment = paymentGateway.charge(order.total(), cmd.customerId());
if (payment.failed()) {
throw new PaymentFailedException(payment.errorMessage());
}
orderRepository.save(order);
notificationService.notifyOrderPlaced(order, cmd.email());
return order.id();
}
}
// --- INFRAESTRUTURA implementa as abstrações ---
@Repository
public class JpaOrderRepository implements OrderRepository {
private final SpringDataOrderRepository springRepo;
@Override
public void save(Order order) { springRepo.save(OrderEntity.from(order)); }
@Override
public Optional<Order> findById(OrderId id) {
return springRepo.findById(id.value()).map(OrderEntity::toDomain);
}
}
@Component
public class StripePaymentGateway implements PaymentGateway {
private final StripeClient stripeClient;
@Override
public PaymentResult charge(Money amount, String customerId) {
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amount.toCents())
.setCurrency(amount.currency().code())
.setCustomer(customerId)
.build()
);
return PaymentResult.success(charge.getId());
} catch (StripeException e) {
return PaymentResult.failure(e.getMessage());
}
}
@Override
public void refund(String paymentId) { /* implementação Stripe */ }
}
// --- TESTES com fakes (sem infra real) ---
class OrderServiceTest {
private final InMemoryOrderRepository orderRepository = new InMemoryOrderRepository();
private final FakePaymentGateway paymentGateway = new FakePaymentGateway();
private final FakeNotificationService notificationService = new FakeNotificationService();
private final OrderService orderService = new OrderService(
orderRepository, paymentGateway, notificationService
);
@Test
void shouldPlaceOrderSuccessfully() {
PlaceOrderCommand cmd = new PlaceOrderCommand("cust-1", List.of(item("prod-1", 100)), "cust@email.com");
OrderId orderId = orderService.placeOrder(cmd);
assertThat(orderRepository.findById(orderId)).isPresent();
assertThat(paymentGateway.chargedAmount()).isEqualTo(Money.of(100));
assertThat(notificationService.sentEmails()).containsExactly("cust@email.com");
}
@Test
void shouldThrowWhenPaymentFails() {
paymentGateway.simulateFailure("Cartão recusado");
PlaceOrderCommand cmd = new PlaceOrderCommand("cust-1", List.of(item("prod-1", 100)), "cust@email.com");
assertThatThrownBy(() -> orderService.placeOrder(cmd))
.isInstanceOf(PaymentFailedException.class)
.hasMessage("Cartão recusado");
assertThat(orderRepository.count()).isZero(); // não salva se pagamento falhou
}
}Armadilhas comuns:
- Confundir DIP com “usar framework de injeção de dependência” — DIP é um princípio de design; Spring/Guice são apenas facilitadores.
- Criar interfaces para tudo indiscriminadamente — se uma classe tem apenas uma implementação e nenhuma variação previsível, a interface pode ser prematura.
- Usar
@Autowiredem campo (field injection) dificulta os testes e esconde dependências.
Como identificar em code review: new ConcreteImplementation() dentro de classes de negócio, ou imports de pacotes de infraestrutura (JPA, Stripe, SendGrid) dentro do pacote de domínio são violações diretas. Regra: o pacote domain não deve importar nada de infrastructure.