Design de Software

DRY — Don't Repeat Yourself

Cada pedaço de conhecimento deve ter uma única representação — DRY vs WET, Rule of Three, abstrações prematuras e quando não aplicar

“Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.” — The Pragmatic Programmer, Andrew Hunt e David Thomas.

DRY não é sobre código duplicado — é sobre conhecimento duplicado. Dois trechos de código que fazem coisas diferentes podem ter a mesma estrutura. Dois trechos que fazem a mesma coisa por razões diferentes podem evoluir em direções opostas.


DRY vs WET — A Regra dos Três

WET significa “Write Everything Twice” (ou “We Enjoy Typing”). Escrever algo uma segunda vez é aceitável. Escrever pela terceira vez é o sinal para abstrair.

// PRIMEIRA VEZ — escreva normalmente
public class OrderService {
    public BigDecimal calculateDiscount(Order order) {
        if (order.customer().isVip()) {
            return order.subtotal().multiply(BigDecimal.valueOf(0.10));
        }
        return BigDecimal.ZERO;
    }
}

// SEGUNDA VEZ — pode ser coincidência, YAGNI, aguarde
public class QuoteService {
    public BigDecimal estimateDiscount(Cart cart) {
        if (cart.customer().isVip()) {
            return cart.subtotal().multiply(BigDecimal.valueOf(0.10)); // mesma lógica
        }
        return BigDecimal.ZERO;
    }
}

// TERCEIRA VEZ — hora de extrair
public class InvoiceService {
    public BigDecimal invoiceDiscount(Invoice invoice) {
        if (invoice.customer().isVip()) {
            return invoice.subtotal().multiply(BigDecimal.valueOf(0.10)); // terceira vez!
        }
        return BigDecimal.ZERO;
    }
}

// ✅ Rule of Three: na terceira, extraia a abstração
public class VipDiscountPolicy {
    private static final BigDecimal VIP_RATE = BigDecimal.valueOf(0.10);

    public BigDecimal calculate(Customer customer, BigDecimal subtotal) {
        if (customer.isVip()) {
            return subtotal.multiply(VIP_RATE);
        }
        return BigDecimal.ZERO;
    }
}

// Agora OrderService, QuoteService e InvoiceService usam VipDiscountPolicy
// Se a taxa mudar de 10% para 15%, altera em um lugar só

Tipos de Duplicação

Duplicação não é só código — existem vários tipos, cada um com tratamento diferente.

Duplicação de lógica: a mesma regra de negócio implementada em vários lugares.

// ❌ Regra "pedido é elegível para frete grátis acima de R$200"
// implementada em 3 lugares

public class CartService {
    public boolean hasFreShipping(Cart cart) {
        return cart.total().compareTo(BigDecimal.valueOf(200)) >= 0;  // lugar 1
    }
}

public class CheckoutController {
    public boolean showFreeShippingBanner(BigDecimal total) {
        return total.compareTo(BigDecimal.valueOf(200)) >= 0;  // lugar 2
    }
}

public class OrderEntity {
    public boolean isEligibleForFreeShipping() {
        return getTotal().compareTo(BigDecimal.valueOf(200)) >= 0;  // lugar 3
    }
}

// ✅ Extraia a regra com nome de negócio
public class FreeShippingPolicy {
    private static final BigDecimal THRESHOLD = BigDecimal.valueOf(200);

    public boolean isEligible(BigDecimal orderTotal) {
        return orderTotal.compareTo(THRESHOLD) >= 0;
    }
}

Duplicação de dados: a mesma informação em múltiplas estruturas sem fonte única de verdade.

// ❌ Preço do produto duplicado em Product e CartItem
public class Product {
    private String id;
    private String name;
    private BigDecimal price;  // fonte de verdade
}

public class CartItem {
    private String productId;
    private String productName;  // duplicado de Product.name
    private BigDecimal price;    // duplicado de Product.price — qual é o certo?
    private int quantity;
}

// ✅ CartItem referencia o produto, não duplica seus dados
// (snapshot de preço é legítimo se o preço pode mudar — mas documente essa intenção)
public class CartItem {
    private final String productId;
    private final BigDecimal priceAtTimeOfAdd;  // nome explicita que é snapshot intencional
    private int quantity;

    public static CartItem from(Product product, int quantity) {
        return new CartItem(product.id(), product.currentPrice(), quantity);
    }
}

Duplicação de representação: mesma estrutura de dados em camadas diferentes sem motivo.

// ❌ ProductDTO = Product com os mesmos campos — por quê existem dois?
public class Product {
    private String id;
    private String name;
    private BigDecimal price;
    private String category;
}

public class ProductDTO {
    private String id;
    private String name;
    private BigDecimal price;
    private String category;
    // 100% idêntico — nenhuma razão para existir separado
}

// ✅ Use o domínio diretamente na API se não há transformação necessária
// OU garanta que o DTO serve a um propósito distinto (versioning, projeção parcial)
public record ProductSummaryResponse(String id, String name, BigDecimal price) {
    // Projeção: apenas 3 campos dos 10 de Product — justificativa clara
    public static ProductSummaryResponse from(Product p) {
        return new ProductSummaryResponse(p.id(), p.name(), p.price());
    }
}

Quando NÃO Seguir DRY — Coupling Acidental

Este é o erro mais comum: abstrair código que parece igual mas representa conceitos diferentes.

// Dois métodos com a mesma implementação hoje
public class UserRegistrationService {
    public boolean isValidEmail(String email) {
        return email != null && email.contains("@") && email.contains(".");
    }
}

public class MarketingEmailService {
    public boolean isValidEmail(String email) {
        return email != null && email.contains("@") && email.contains(".");
    }
}

// ❌ ERRADO — abstrair "porque são iguais"
public class EmailValidator {
    public static boolean isValid(String email) {
        return email != null && email.contains("@") && email.contains(".");
    }
}
// Agora os dois serviços compartilham a mesma validação
// Mas eles têm preocupações DIFERENTES:

// UserRegistrationService: valida para criar conta segura
//   amanhã pode precisar verificar domínios banidos, LGPD, etc.

// MarketingEmailService: valida para entregabilidade de campanha
//   amanhã pode verificar DNS MX record, bounce rate do domínio, etc.

// Ao compartilhar, qualquer mudança em uma afeta a outra — coupling acidental

// ✅ CORRETO — deixar duplicado com nomes que explicam a intenção diferente
public class UserRegistrationService {
    private boolean isEmailValidForRegistration(String email) {
        return email != null && email.contains("@") && email.contains(".");
        // evolui com regras de segurança e compliance
    }
}

public class MarketingEmailService {
    private boolean isEmailDeliverable(String email) {
        return email != null && email.contains("@") && email.contains(".");
        // evolui com regras de entregabilidade
    }
}

Regra: antes de abstrair, responda: “se um dos usos mudar, o outro também deve mudar?”. Se não, deixe duplicado.


Abstrações Prematuras — Exemplos de Over-Engineering

// ❌ Over-engineering — criando "flexibilidade" sem necessidade
// Classe genérica que tenta resolver todos os casos futuros imagináveis
public class GenericEntityProcessor<T, R, V extends Validator<T>, M extends Mapper<T, R>> {
    private final V validator;
    private final M mapper;
    private final Repository<T> repository;
    private final EventPublisher eventPublisher;

    public R process(T input, ProcessingContext ctx, ProcessingOptions options) {
        if (options.shouldValidate()) validator.validate(input);
        if (options.shouldPersist()) repository.save(input);
        if (options.shouldPublishEvent()) eventPublisher.publish(buildEvent(input, ctx));
        return mapper.map(input);
    }
}

// ✅ Simples e direto — fácil de entender e modificar
public class CreateProductUseCase {
    public ProductId execute(CreateProductCommand cmd) {
        Product product = new Product(cmd.name(), cmd.price(), cmd.category());
        productRepository.save(product);
        eventPublisher.publish(new ProductCreated(product.id(), product.name()));
        return product.id();
    }
}

// Extrai abstração QUANDO surgir o segundo use case com padrão similar
// Não antes

DRY com Herança vs Composição

Herança é uma forma poderosa de compartilhar código, mas gera acoplamento forte. Prefira composição quando o objetivo é apenas reutilização.

// ❌ Herança para reutilizar — cria acoplamento desnecessário
public abstract class BaseService {
    protected final Logger log = LoggerFactory.getLogger(getClass());
    protected final ApplicationEventPublisher events;

    protected void publishEvent(Object event) {
        log.info("Publicando evento: {}", event.getClass().getSimpleName());
        events.publishEvent(event);
    }

    protected <T> T findOrThrow(Optional<T> optional, String message) {
        return optional.orElseThrow(() -> new NotFoundException(message));
    }
}

// Todos os services herdam de BaseService — herança por conveniência, não por relação "é um"
public class OrderService extends BaseService { ... }
public class CustomerService extends BaseService { ... }
public class ProductService extends BaseService { ... }

// ✅ Composição — injete as dependências que precisar
public class OrderService {
    private final Logger log = LoggerFactory.getLogger(OrderService.class);
    private final ApplicationEventPublisher events;
    private final EntityFinder finder;  // helper injetado onde necessário

    // Só usa o que precisa, sem carregar herança pesada
}

// Helper simples para busca com throw
@Component
public class EntityFinder {
    public <T> T findOrThrow(Optional<T> optional, String entity, Object id) {
        return optional.orElseThrow(() ->
            new EntityNotFoundException(entity + " não encontrado: " + id)
        );
    }
}

DRY em SQL — Views e Funções

-- ❌ Mesma lógica de cálculo repetida em múltiplas queries
SELECT
    o.id,
    SUM(oi.quantity * oi.unit_price) AS subtotal,
    CASE WHEN c.type = 'VIP' THEN SUM(oi.quantity * oi.unit_price) * 0.90
         ELSE SUM(oi.quantity * oi.unit_price)
    END AS total_with_discount
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN customers c ON c.id = o.customer_id
GROUP BY o.id, c.type;

-- (essa mesma query aparece em relatório mensal, dashboard e faturamento)

-- ✅ View como única representação
CREATE OR REPLACE VIEW order_totals AS
SELECT
    o.id AS order_id,
    o.customer_id,
    c.type AS customer_type,
    SUM(oi.quantity * oi.unit_price) AS subtotal,
    CASE WHEN c.type = 'VIP'
         THEN SUM(oi.quantity * oi.unit_price) * 0.90
         ELSE SUM(oi.quantity * oi.unit_price)
    END AS total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN customers c ON c.id = o.customer_id
GROUP BY o.id, o.customer_id, c.type;

-- Agora todas as queries usam a view
SELECT * FROM order_totals WHERE order_id = $1;
SELECT customer_id, SUM(total) FROM order_totals GROUP BY customer_id;

DRY em Configuração e Infraestrutura

# ❌ Repetição de configuração em docker-compose
services:
  api:
    environment:
      - SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/ecommerce
      - SPRING_DATASOURCE_USERNAME=dev
      - SPRING_DATASOURCE_PASSWORD=dev
      - SPRING_KAFKA_BOOTSTRAP_SERVERS=kafka:9092
  
  worker:
    environment:
      - SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/ecommerce  # repetido
      - SPRING_DATASOURCE_USERNAME=dev                                    # repetido
      - SPRING_DATASOURCE_PASSWORD=dev                                    # repetido
      - SPRING_KAFKA_BOOTSTRAP_SERVERS=kafka:9092                         # repetido

# ✅ Usar arquivo .env ou extension fields
# .env
POSTGRES_URL=jdbc:postgresql://postgres:5432/ecommerce
POSTGRES_USER=dev
POSTGRES_PASS=dev
KAFKA_URL=kafka:9092

# docker-compose.yml
services:
  api:
    env_file: .env
  worker:
    env_file: .env  # mesmo arquivo de configuração

DRY em TypeScript — Tipos e Validações

// ❌ Tipo duplicado entre domínio e API
// domain/types.ts
interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
  category: string;
}

// api/types.ts
interface ProductResponse {
  id: string;       // duplicado
  name: string;     // duplicado
  price: number;    // duplicado
  stock: number;    // duplicado
  category: string; // duplicado
}

// ✅ Derive tipos a partir da fonte de verdade
// domain/types.ts — fonte de verdade
interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
  category: string;
  internalNotes: string; // campo interno, não deve ir para API
}

// api/types.ts — derivado, sem duplicação
type ProductResponse = Omit<Product, 'internalNotes'>;

// View específica para listagem
type ProductSummary = Pick<Product, 'id' | 'name' | 'price'>;

// ✅ Validação com Zod — esquema é a fonte de verdade para tipo E validação
import { z } from 'zod';

// Define uma vez — gera tipo TypeScript E validação runtime
const CreateProductSchema = z.object({
  name: z.string().min(1).max(200),
  price: z.number().positive(),
  category: z.string(),
});

// Tipo derivado do schema — nunca fica dessincronizado
type CreateProductRequest = z.infer<typeof CreateProductSchema>;

// Usa em qualquer lugar que precise validar
function validateCreateProduct(data: unknown): CreateProductRequest {
  return CreateProductSchema.parse(data); // lança se inválido
}