Design de Software

Object Calisthenics

Nove regras práticas para forçar boas decisões de design orientado a objetos — cada regra com motivação, exemplos e impacto em legibilidade e testabilidade

Object Calisthenics são nove restrições auto-impostas definidas por Jeff Bay no livro “ThoughtWorks Anthology” (2008). O objetivo não é seguir as regras cegamente em produção — é usá-las como exercício de refactoring para internalizar bons hábitos de design.

Em projetos reais, as regras 1, 2, 3, 4, 6 e 7 têm o maior retorno. Use todas as nove em katas e exercícios de treino.


1. Um Nível de Indentação por Método

Motivação: métodos com múltiplos níveis de indentação misturam responsabilidades — iteração, condição, lógica. Extrair métodos força nomes significativos e funções focadas.

// ❌ VIOLAÇÃO — dois níveis de indentação, múltiplos conceitos
public void processOrders(List<Order> orders) {
    for (Order order : orders) {                        // nível 1 — iteração
        if (order.status() == OrderStatus.PENDING) {   // nível 2 — condição
            payment.charge(order.total());             // nível 3 — lógica
            order.confirm();
            orderRepo.save(order);
        }
    }
}

// ✅ CORRETO — um nível, métodos com nomes que revelam intenção
public void processOrders(List<Order> orders) {
    orders.forEach(this::processIfPending);
}

private void processIfPending(Order order) {
    if (order.status() != OrderStatus.PENDING) return;
    chargeAndConfirm(order);
}

private void chargeAndConfirm(Order order) {
    payment.charge(order.total());
    order.confirm();
    orderRepo.save(order);
}

Impacto em testabilidade: cada método extraído pode ser testado independentemente. chargeAndConfirm pode ser testado diretamente sem precisar iterar sobre uma lista.

Impacto em legibilidade: o método de nível mais alto vira uma narrativa: “para cada pedido, processe se pendente”. O leitor entende o fluxo sem mergulhar nos detalhes.


2. Não Use else

Motivação: else encadeado cria caminhos de execução difíceis de raciocinar. Early return e polimorfismo são alternativas mais expressivas.

// ❌ VIOLAÇÃO — else encadeado
public String describeOrderStatus(Order order) {
    if (order.isPaid()) {
        return "Pedido pago e confirmado";
    } else if (order.isPending()) {
        return "Aguardando pagamento";
    } else if (order.isCancelled()) {
        return "Pedido cancelado";
    } else if (order.isShipped()) {
        return "Pedido enviado - " + order.trackingCode();
    } else {
        return "Status desconhecido";
    }
}

// ✅ CORRETO — early return, sem else
public String describeOrderStatus(Order order) {
    if (order.isPaid())      return "Pedido pago e confirmado";
    if (order.isPending())   return "Aguardando pagamento";
    if (order.isCancelled()) return "Pedido cancelado";
    if (order.isShipped())   return "Pedido enviado - " + order.trackingCode();
    return "Status desconhecido";
}

// ✅ MELHOR — polimorfismo elimina o condicional completamente
public enum OrderStatus {
    PAID {
        @Override public String describe(Order o) { return "Pedido pago e confirmado"; }
    },
    PENDING {
        @Override public String describe(Order o) { return "Aguardando pagamento"; }
    },
    CANCELLED {
        @Override public String describe(Order o) { return "Pedido cancelado"; }
    },
    SHIPPED {
        @Override public String describe(Order o) { return "Pedido enviado - " + o.trackingCode(); }
    };

    public abstract String describe(Order order);
}

// No código: order.status().describe(order) — sem if/else

Impacto em testabilidade: com polimorfismo, cada status é testado de forma independente. Com early return, o fluxo é linear e fácil de seguir nos testes.

Impacto em legibilidade: a intenção de cada caminho é clara. Adicionar um novo status = adicionar um novo valor no enum, sem risco de quebrar os existentes.


3. Empacote Todos os Primitivos com Comportamento

Motivação: primitivos como double, String e int não têm semântica de negócio. Um double pode ser preço, percentual, peso ou temperatura — o compilador não distingue. Value Objects adicionam validação, operações e expressividade.

// ❌ VIOLAÇÃO — primitivos sem semântica
public class Order {
    public void applyDiscount(double discountPercentage, double price) {
        // discountPercentage pode ser 0.10 (10%) ou 10 (10%) — qual é?
        // price pode ser negativo? zero? em qual moeda?
        double discounted = price * (1 - discountPercentage);
    }
}

// Erros silenciosos
order.applyDiscount(10, 100.0);  // 10% ou R$10? Compila, mas está errado

// ✅ CORRETO — Value Objects com semântica clara
public final class Money {
    private final BigDecimal amount;
    private final Currency currency;

    public Money(BigDecimal amount, Currency currency) {
        Objects.requireNonNull(amount, "Valor não pode ser nulo");
        Objects.requireNonNull(currency, "Moeda não pode ser nula");
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Valor monetário não pode ser negativo: " + amount);
        }
        this.amount = amount.setScale(2, RoundingMode.HALF_UP);
        this.currency = currency;
    }

    public static Money brl(double amount) {
        return new Money(BigDecimal.valueOf(amount), Currency.BRL);
    }

    public Money subtract(Money other) {
        assertSameCurrency(other);
        return new Money(this.amount.subtract(other.amount), this.currency);
    }

    public Money applyDiscount(Percentage discount) {
        BigDecimal factor = BigDecimal.ONE.subtract(discount.asBigDecimal());
        return new Money(this.amount.multiply(factor), this.currency);
    }

    private void assertSameCurrency(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new CurrencyMismatchException(this.currency, other.currency);
        }
    }

    @Override
    public boolean equals(Object o) { /* por valor */ }
    @Override
    public int hashCode() { /* por valor */ }
    @Override
    public String toString() { return currency.symbol() + " " + amount; }
}

public final class Percentage {
    private final BigDecimal value; // 0.10 = 10%

    public Percentage(BigDecimal value) {
        if (value.compareTo(BigDecimal.ZERO) < 0 || value.compareTo(BigDecimal.ONE) > 0) {
            throw new IllegalArgumentException("Percentual deve ser entre 0 e 1: " + value);
        }
        this.value = value;
    }

    public static Percentage of(int percent) {
        return new Percentage(BigDecimal.valueOf(percent).divide(BigDecimal.valueOf(100)));
    }

    public BigDecimal asBigDecimal() { return value; }
}

// Uso: semântica clara, compilador valida
Money price = Money.brl(100.00);
Percentage discount = Percentage.of(10); // 10%
Money discounted = price.applyDiscount(discount); // R$ 90,00

Impacto em testabilidade: Money, Percentage e outros Value Objects são puro Java — testados sem nenhum mock.

Impacto em legibilidade: applyDiscount(Percentage.of(10)) vs applyDiscount(0.10, 100.0) — o primeiro se explica, o segundo precisa de comentário.


4. Collections de Primeiro Nível

Motivação: uma classe que tem uma coleção e outros atributos mistura a responsabilidade de “gerenciar itens” com outros conceitos. Encapsular a coleção permite adicionar comportamentos relacionados à coleção sem poluir a classe pai.

// ❌ VIOLAÇÃO — Order mistura coleção + status + outros dados
public class Order {
    private List<Item> items;       // coleção
    private String status;          // dado de estado
    private String customerId;      // dado da entidade
    private BigDecimal total;       // derivado da coleção, mas calculado fora

    public BigDecimal calculateTotal() {
        return items.stream()
            .mapToDouble(i -> i.price() * i.quantity())
            .sum(); // lógica da coleção vazando para Order
    }
}

// ✅ CORRETO — coleção encapsulada com suas responsabilidades
public class OrderItems {
    private final List<Item> items;

    public OrderItems(List<Item> items) {
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("Pedido deve ter ao menos um item");
        }
        this.items = new ArrayList<>(items);
    }

    public OrderItems add(Item item) {
        List<Item> updated = new ArrayList<>(items);
        Optional<Item> existing = findByProduct(item.productId());
        if (existing.isPresent()) {
            updated.remove(existing.get());
            updated.add(existing.get().withQuantity(existing.get().quantity() + item.quantity()));
        } else {
            updated.add(item);
        }
        return new OrderItems(updated);
    }

    public OrderItems remove(String productId) {
        return new OrderItems(items.stream()
            .filter(i -> !i.productId().equals(productId))
            .toList());
    }

    public Money total() {
        return items.stream()
            .map(Item::subtotal)
            .reduce(Money.ZERO, Money::add);
    }

    public int count() { return items.size(); }

    public boolean contains(String productId) {
        return items.stream().anyMatch(i -> i.productId().equals(productId));
    }

    private Optional<Item> findByProduct(String productId) {
        return items.stream().filter(i -> i.productId().equals(productId)).findFirst();
    }

    public List<Item> toList() { return Collections.unmodifiableList(items); }
}

public class Order {
    private final OrderId id;
    private final CustomerId customerId;
    private OrderItems items;          // tipo rico, não List<Item>
    private OrderStatus status;

    public Money total() { return items.total(); }  // delega
    public int itemCount() { return items.count(); }
}

Impacto em testabilidade: OrderItems é testada independentemente — operações de coleção testadas sem carregar a entidade Order completa.


5. Um Ponto por Linha (Lei de Demeter)

Motivação: order.customer().address().city().zipCode() encadeia responsabilidades e expõe estrutura interna. Cada ponto é um nível de acoplamento.

// ❌ VIOLAÇÃO — encadeamento expõe estrutura interna
public class ShippingService {
    public BigDecimal calculateShipping(Order order) {
        String zipCode = order.customer().address().city().zipCode();  // viola!
        return shippingTable.getRate(zipCode);
    }
}

// Se Customer mudar como endereço é representado, ShippingService quebra

// ✅ CORRETO — peça o que precisa, não navegue pela estrutura
public class Order {
    public String shippingZipCode() {
        return customer.shippingZipCode();  // delega, não expõe estrutura
    }
}

public class Customer {
    public String shippingZipCode() {
        return address.zipCode();  // idem
    }
}

public class ShippingService {
    public BigDecimal calculateShipping(Order order) {
        String zipCode = order.shippingZipCode();  // um ponto, sem navegar
        return shippingTable.getRate(zipCode);
    }
}

Impacto em testabilidade: ao usar order.shippingZipCode(), o teste de ShippingService precisa apenas de um Order com shippingZipCode() funcionando — sem montar hierarquia Customer → Address → City.


6. Não Abrevie

Motivação: mgr, ctrl, svc, impl são abreviações que exigem decifração. Nomes completos tornam o código autodocumentado e pesquisável.

// ❌ VIOLAÇÃO — abreviações que exigem contexto
public class OrdSvc {
    private OrdRepo repo;
    private PmtGateway pmtGw;
    private NtfSvc ntfSvc;

    public OrdId plcOrd(OrdCmd cmd) {
        Ord ord = new Ord(cmd.custId(), cmd.itms());
        repo.sv(ord);
        pmtGw.chrg(ord.tot(), cmd.custId());
        ntfSvc.ntfy(ord);
        return ord.id();
    }
}

// ✅ CORRETO — nomes completos e expressivos
public class OrderService {
    private final OrderRepository orderRepository;
    private final PaymentGateway paymentGateway;
    private final NotificationService notificationService;

    public OrderId placeOrder(PlaceOrderCommand command) {
        Order order = new Order(command.customerId(), command.items());
        orderRepository.save(order);
        paymentGateway.charge(order.total(), command.customerId());
        notificationService.notify(order);
        return order.id();
    }
}

Impacto em legibilidade: o código é lido muitas mais vezes do que escrito. O tempo economizado digitando Svc é gasto 10x tentando decifrar o que NtfSvc significa.


7. Mantenha Entidades Pequenas

Motivação: classes grandes têm múltiplas responsabilidades. Métodos longos misturam abstrações. Limites de 50 linhas por classe e 10 por método não são regras absolutas — são alertas para decompor.

// ❌ VIOLAÇÃO — método de 50 linhas misturando validação, cálculo, persistência e notificação
public OrderResult processCheckout(Cart cart, PaymentInfo payment) {
    // validação de estoque (10 linhas)
    for (CartItem item : cart.items()) {
        if (!inventory.isAvailable(item.productId(), item.quantity())) {
            throw new OutOfStockException(item.productId());
        }
    }
    // cálculo de preços (10 linhas)
    BigDecimal subtotal = cart.items().stream()...
    BigDecimal discount = calculateDiscount(cart.customer())...
    BigDecimal total = subtotal.subtract(discount);
    // processamento de pagamento (10 linhas)
    ChargeResult charge = paymentGateway.charge(total, payment.token())...
    if (charge.failed()) { ... }
    // persistência (5 linhas)
    Order order = new Order(cart.customerId(), cart.items(), total, charge.paymentId());
    orderRepository.save(order);
    // notificação (10 linhas)
    emailService.sendConfirmation(...)
    pushService.notify(...)
    return new OrderResult(order.id(), total);
}

// ✅ CORRETO — método principal orquestra, cada etapa é um método com nome expressivo
public OrderResult processCheckout(Cart cart, PaymentInfo payment) {
    validateStock(cart.items());
    Money total = calculateTotal(cart);
    String paymentId = chargeCustomer(total, payment);
    Order order = createAndPersistOrder(cart, total, paymentId);
    notifyCustomer(order);
    return OrderResult.from(order);
}

private void validateStock(List<CartItem> items) {
    items.forEach(item -> {
        if (!inventory.isAvailable(item.productId(), item.quantity())) {
            throw new OutOfStockException(item.productId());
        }
    });
}

private Money calculateTotal(Cart cart) {
    Money subtotal = cart.subtotal();
    Money discount = discountPolicy.calculate(cart.customer(), subtotal);
    return subtotal.subtract(discount);
}

private String chargeCustomer(Money total, PaymentInfo payment) {
    ChargeResult result = paymentGateway.charge(total, payment.token());
    if (result.failed()) {
        throw new PaymentFailedException(result.errorMessage());
    }
    return result.paymentId();
}

private Order createAndPersistOrder(Cart cart, Money total, String paymentId) {
    Order order = Order.from(cart, total, paymentId);
    orderRepository.save(order);
    return order;
}

private void notifyCustomer(Order order) {
    emailService.sendOrderConfirmation(order);
    pushService.sendOrderUpdate(order.customerId(), "Pedido recebido!");
}

Impacto em testabilidade: cada método privado extraído pode ser testado por seu efeito observável. calculateTotal pode ser testada isoladamente com um cart de teste.


8. Sem Classes com Mais de Dois Atributos de Instância

Motivação: classes com muitos campos frequentemente representam múltiplos conceitos. Esta é a regra mais radical — na prática, use-a como exercício para perceber onde agrupar campos relacionados.

// ❌ VIOLAÇÃO — Customer com 8 atributos (dados de identidade + endereço + contato misturados)
public class Customer {
    private String id;
    private String firstName;
    private String lastName;
    private String email;
    private String phone;
    private String street;
    private String city;
    private String zipCode;
}

// ✅ CORRETO — agrupar campos relacionados em Value Objects
public class Customer {
    private final CustomerId id;
    private final PersonName name;        // agrupa firstName + lastName
    private final ContactInfo contact;    // agrupa email + phone
    private final Address address;        // agrupa street + city + zipCode
}

public record PersonName(String firstName, String lastName) {
    public PersonName {
        Objects.requireNonNull(firstName);
        Objects.requireNonNull(lastName);
    }
    public String fullName() { return firstName + " " + lastName; }
}

public record ContactInfo(String email, String phone) {
    public ContactInfo {
        if (email == null || !email.contains("@")) throw new IllegalArgumentException("Email inválido");
    }
}

public record Address(String street, String city, String zipCode) {
    public String formatted() { return street + ", " + city + " - " + zipCode; }
}

Impacto em legibilidade: cada Value Object agrupa dados coesos com seus próprios comportamentos. customer.name().fullName() é mais expressivo que customer.firstName() + " " + customer.lastName().


9. Sem Getters/Setters/Properties

Motivação: getters e setters expõem estado interno e violam o encapsulamento. “Diga, não pergunte” — em vez de pegar dados e decidir o que fazer, delegue a decisão ao objeto.

// ❌ VIOLAÇÃO — Tell, don't ask violado
public class OrderProcessor {
    public void applyLoyaltyDiscount(Customer customer, Order order) {
        // pegando dados do customer para tomar decisão externamente
        int loyaltyPoints = customer.getLoyaltyPoints();  // getter
        String tier = customer.getTier();                  // getter

        if (loyaltyPoints > 1000 && tier.equals("GOLD")) {
            BigDecimal currentTotal = order.getTotal();    // getter
            order.setTotal(currentTotal.multiply(BigDecimal.valueOf(0.95))); // setter
        }
    }
}

// ✅ CORRETO — objetos tomam suas próprias decisões
public class Customer {
    private final int loyaltyPoints;
    private final CustomerTier tier;

    // Não expõe dados brutos — expõe comportamento
    public boolean isEligibleForLoyaltyDiscount() {
        return loyaltyPoints > 1000 && tier == CustomerTier.GOLD;
    }

    public Percentage loyaltyDiscountRate() {
        if (tier == CustomerTier.PLATINUM) return Percentage.of(10);
        if (tier == CustomerTier.GOLD)     return Percentage.of(5);
        return Percentage.ZERO;
    }
}

public class Order {
    private Money total;

    // Sem setter genérico — comportamento com nome de negócio
    public void applyDiscount(Percentage discount) {
        this.total = total.applyDiscount(discount);
    }
}

public class OrderProcessor {
    public void applyLoyaltyDiscount(Customer customer, Order order) {
        if (customer.isEligibleForLoyaltyDiscount()) {
            order.applyDiscount(customer.loyaltyDiscountRate());
        }
    }
}

Nota prática: em projetos reais, getters para leitura são aceitáveis — o problema são os setters públicos que permitem mutação arbitrária do estado. O princípio “Tell, Don’t Ask” é o mais valioso aqui: em vez de buscar dados de um objeto para tomar decisões, mova a decisão para dentro do objeto.

Impacto em testabilidade: customer.isEligibleForLoyaltyDiscount() é testado diretamente sem depender de OrderProcessor. A lógica de elegibilidade fica no lugar certo.