Aplicando padrões e boas práticas em Java OOP: projeto incremental orientado a refatoração

Capítulo 17

Tempo estimado de leitura: 15 minutos

+ Exercício

Neste capítulo, vamos consolidar OOP e padrões básicos construindo um projeto pequeno, porém completo, com evolução incremental e refatorações guiadas. A ideia é começar com requisitos simples, implementar um modelo de domínio consistente e, conforme surgirem variações, introduzir pontos de extensão com Factory e Strategy. Ao final, revisaremos organização por pacotes e validaremos com testes e cenários de uso.

Projeto de referência: "Mini Checkout" (carrinho e cálculo de total)

Vamos implementar um fluxo de compra com: itens, carrinho, cálculo de total, descontos e frete. O foco é exercitar decisões de design e refatorar quando o código começar a “pedir” mudanças.

Requisitos funcionais (RF)

  • RF1: Adicionar itens ao carrinho (produto + quantidade).
  • RF2: Calcular subtotal (soma de preço unitário × quantidade).
  • RF3: Aplicar desconto (ex.: cupom percentual, cupom valor fixo, ou nenhum).
  • RF4: Calcular frete (ex.: grátis acima de um valor, ou por faixa).
  • RF5: Calcular total final = subtotal − desconto + frete.

Requisitos não funcionais (RNF)

  • RNF1: Modelo de domínio com invariantes claras (quantidade positiva, dinheiro não negativo etc.).
  • RNF2: Value Objects imutáveis para dinheiro e identificadores.
  • RNF3: Uso de Generics onde houver reuso real (ex.: repositório em memória).
  • RNF4: Igualdade correta para objetos que serão usados em coleções (ex.: Produto por SKU).
  • RNF5: Código organizado por pacotes e testável com testes simples.

Estrutura inicial de pacotes

Uma organização prática para um projeto pequeno:

com.seuprojeto.checkout.domain        // entidades e value objects do domínio (sem dependências externas) com.seuprojeto.checkout.pricing       // políticas de preço: desconto e frete (Strategies) com.seuprojeto.checkout.factory       // criação de estratégias e objetos compostos (Factories) com.seuprojeto.checkout.repository    // abstrações e implementações simples (ex.: memória) com.seuprojeto.checkout.app           // casos de uso / orquestração (serviços) com.seuprojeto.checkout.tests         // testes

Passo 1 — Modelar o domínio mínimo (sem padrões ainda)

Comece pequeno: Produto, ItemCarrinho e Carrinho. O objetivo é ter um fluxo funcionando antes de sofisticar.

Value Objects imutáveis

Vamos usar Sku e Money como Value Objects. Eles ajudam a manter invariantes e evitam “primitivos soltos” no código.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

package com.seuprojeto.checkout.domain; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Objects; public final class Money {     private final BigDecimal amount;     public Money(BigDecimal amount) {         Objects.requireNonNull(amount, "amount");         if (amount.scale() > 2) {             amount = amount.setScale(2, RoundingMode.HALF_UP);         }         if (amount.compareTo(BigDecimal.ZERO) < 0) {             throw new IllegalArgumentException("Money cannot be negative");         }         this.amount = amount;     }     public static Money of(String value) {         return new Money(new BigDecimal(value));     }     public BigDecimal amount() {         return amount;     }     public Money plus(Money other) {         return new Money(this.amount.add(other.amount));     }     public Money minus(Money other) {         BigDecimal result = this.amount.subtract(other.amount);         if (result.compareTo(BigDecimal.ZERO) < 0) {             return new Money(BigDecimal.ZERO); // regra simples: não deixar negativo         }         return new Money(result);     }     public Money times(int multiplier) {         if (multiplier < 0) throw new IllegalArgumentException("multiplier must be >= 0");         return new Money(this.amount.multiply(BigDecimal.valueOf(multiplier)));     }     @Override public boolean equals(Object o) {         if (this == o) return true;         if (!(o instanceof Money money)) return false;         return amount.compareTo(money.amount) == 0; // ignora escala 10.0 vs 10.00     }     @Override public int hashCode() {         return amount.stripTrailingZeros().hashCode();     }     @Override public String toString() {         return amount.toPlainString();     } }
package com.seuprojeto.checkout.domain; import java.util.Objects; public final class Sku {     private final String value;     public Sku(String value) {         Objects.requireNonNull(value, "value");         String normalized = value.trim();         if (normalized.isEmpty()) throw new IllegalArgumentException("SKU cannot be blank");         this.value = normalized;     }     public String value() {         return value;     }     @Override public boolean equals(Object o) {         if (this == o) return true;         if (!(o instanceof Sku sku)) return false;         return value.equalsIgnoreCase(sku.value);     }     @Override public int hashCode() {         return value.toLowerCase().hashCode();     }     @Override public String toString() {         return value;     } }

Entidades e composição

Carrinho compõe ItemCarrinho. Produto será identificado por SKU (igualdade por identidade de domínio).

package com.seuprojeto.checkout.domain; import java.util.Objects; public final class Product {     private final Sku sku;     private final String name;     private final Money unitPrice;     public Product(Sku sku, String name, Money unitPrice) {         this.sku = Objects.requireNonNull(sku);         this.name = Objects.requireNonNull(name);         this.unitPrice = Objects.requireNonNull(unitPrice);     }     public Sku sku() { return sku; }     public String name() { return name; }     public Money unitPrice() { return unitPrice; }     @Override public boolean equals(Object o) {         if (this == o) return true;         if (!(o instanceof Product product)) return false;         return sku.equals(product.sku);     }     @Override public int hashCode() {         return sku.hashCode();     } }
package com.seuprojeto.checkout.domain; import java.util.Objects; public final class CartItem {     private final Product product;     private final int quantity;     public CartItem(Product product, int quantity) {         this.product = Objects.requireNonNull(product);         if (quantity <= 0) throw new IllegalArgumentException("quantity must be > 0");         this.quantity = quantity;     }     public Product product() { return product; }     public int quantity() { return quantity; }     public Money subtotal() {         return product.unitPrice().times(quantity);     } }
package com.seuprojeto.checkout.domain; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; public final class Cart {     private final List<CartItem> items = new ArrayList<>();     public void add(Product product, int quantity) {         Objects.requireNonNull(product);         items.add(new CartItem(product, quantity));     }     public List<CartItem> items() {         return Collections.unmodifiableList(items);     }     public Money subtotal() {         Money total = Money.of("0");         for (CartItem item : items) {             total = total.plus(item.subtotal());         }         return total;     } }

Passo 2 — Introduzir pontos de variação: desconto e frete como Strategies

Agora surgem variações naturais: diferentes regras de desconto e frete. Em vez de if/else espalhados, vamos encapsular cada regra em um objeto de estratégia.

Contrato de desconto

package com.seuprojeto.checkout.pricing; import com.seuprojeto.checkout.domain.Cart; import com.seuprojeto.checkout.domain.Money; public interface DiscountPolicy {     Money discountFor(Cart cart); }

Implementações de desconto

package com.seuprojeto.checkout.pricing; import com.seuprojeto.checkout.domain.Cart; import com.seuprojeto.checkout.domain.Money; public final class NoDiscount implements DiscountPolicy {     @Override public Money discountFor(Cart cart) {         return Money.of("0");     } }
package com.seuprojeto.checkout.pricing; import com.seuprojeto.checkout.domain.Cart; import com.seuprojeto.checkout.domain.Money; import java.math.BigDecimal; import java.util.Objects; public final class PercentageDiscount implements DiscountPolicy {     private final BigDecimal percent; // ex.: 10 = 10%     public PercentageDiscount(BigDecimal percent) {         Objects.requireNonNull(percent);         if (percent.compareTo(BigDecimal.ZERO) < 0 || percent.compareTo(new BigDecimal("100")) > 0) {             throw new IllegalArgumentException("percent must be between 0 and 100");         }         this.percent = percent;     }     @Override public Money discountFor(Cart cart) {         BigDecimal subtotal = cart.subtotal().amount();         BigDecimal factor = percent.divide(new BigDecimal("100"));         return new Money(subtotal.multiply(factor));     } }
package com.seuprojeto.checkout.pricing; import com.seuprojeto.checkout.domain.Cart; import com.seuprojeto.checkout.domain.Money; import java.util.Objects; public final class FixedDiscount implements DiscountPolicy {     private final Money value;     public FixedDiscount(Money value) {         this.value = Objects.requireNonNull(value);     }     @Override public Money discountFor(Cart cart) {         return value;     } }

Contrato de frete

package com.seuprojeto.checkout.pricing; import com.seuprojeto.checkout.domain.Cart; import com.seuprojeto.checkout.domain.Money; public interface ShippingPolicy {     Money shippingFor(Cart cart); }

Implementações de frete

package com.seuprojeto.checkout.pricing; import com.seuprojeto.checkout.domain.Cart; import com.seuprojeto.checkout.domain.Money; import java.util.Objects; public final class FreeShippingAbove implements ShippingPolicy {     private final Money threshold;     private final Money shippingCost;     public FreeShippingAbove(Money threshold, Money shippingCost) {         this.threshold = Objects.requireNonNull(threshold);         this.shippingCost = Objects.requireNonNull(shippingCost);     }     @Override public Money shippingFor(Cart cart) {         return cart.subtotal().amount().compareTo(threshold.amount()) >= 0 ? Money.of("0") : shippingCost;     } }
package com.seuprojeto.checkout.pricing; import com.seuprojeto.checkout.domain.Cart; import com.seuprojeto.checkout.domain.Money; public final class FlatShipping implements ShippingPolicy {     private final Money value;     public FlatShipping(Money value) {         this.value = value;     }     @Override public Money shippingFor(Cart cart) {         return value;     } }

Passo 3 — Orquestrar o caso de uso: CheckoutService

Em vez de colocar regras no Cart, crie um serviço de aplicação que coordena políticas e calcula o total. Isso facilita testes e mantém o domínio mais focado.

package com.seuprojeto.checkout.app; import com.seuprojeto.checkout.domain.Cart; import com.seuprojeto.checkout.domain.Money; import com.seuprojeto.checkout.pricing.DiscountPolicy; import com.seuprojeto.checkout.pricing.ShippingPolicy; import java.util.Objects; public final class CheckoutService {     private final DiscountPolicy discountPolicy;     private final ShippingPolicy shippingPolicy;     public CheckoutService(DiscountPolicy discountPolicy, ShippingPolicy shippingPolicy) {         this.discountPolicy = Objects.requireNonNull(discountPolicy);         this.shippingPolicy = Objects.requireNonNull(shippingPolicy);     }     public Money totalFor(Cart cart) {         Money subtotal = cart.subtotal();         Money discount = discountPolicy.discountFor(cart);         Money shipping = shippingPolicy.shippingFor(cart);         return subtotal.minus(discount).plus(shipping);     } }

Passo 4 — Factory para centralizar variações (cupom e regras de frete)

Quando a escolha de estratégia depende de um “input” (ex.: código de cupom), uma Factory ajuda a centralizar a criação e evita condicionais espalhadas em controladores/serviços.

Factory de desconto por cupom

package com.seuprojeto.checkout.factory; import com.seuprojeto.checkout.domain.Money; import com.seuprojeto.checkout.pricing.*; import java.math.BigDecimal; import java.util.Locale; public final class DiscountPolicyFactory {     public DiscountPolicy fromCoupon(String couponCode) {         if (couponCode == null || couponCode.isBlank()) {             return new NoDiscount();         }         String code = couponCode.trim().toUpperCase(Locale.ROOT);         return switch (code) {             case "OFF10" -> new PercentageDiscount(new BigDecimal("10"));             case "OFF20" -> new PercentageDiscount(new BigDecimal("20"));             case "LESS5" -> new FixedDiscount(Money.of("5"));             default -> new NoDiscount();         };     } }

Factory de políticas de frete

package com.seuprojeto.checkout.factory; import com.seuprojeto.checkout.domain.Money; import com.seuprojeto.checkout.pricing.*; public final class ShippingPolicyFactory {     public ShippingPolicy defaultPolicy() {         return new FreeShippingAbove(Money.of("100"), Money.of("15"));     } }

Note que a Factory aqui é simples e direta. Em projetos maiores, ela pode consultar configuração, banco, feature flags, ou compor estratégias.

Passo 5 — Generics com propósito: repositório em memória reutilizável

Para simular busca de produto por SKU, crie um repositório em memória. Aqui, Generics fazem sentido para reuso real: uma estrutura genérica para armazenar entidades por chave.

Contrato genérico

package com.seuprojeto.checkout.repository; import java.util.Optional; public interface Repository<K, V> {     void put(K key, V value);     Optional<V> findById(K key); }

Implementação em memória

package com.seuprojeto.checkout.repository; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; public final class InMemoryRepository<K, V> implements Repository<K, V> {     private final Map<K, V> storage = new ConcurrentHashMap<>();     @Override public void put(K key, V value) {         storage.put(key, value);     }     @Override public Optional<V> findById(K key) {         return Optional.ofNullable(storage.get(key));     } }

Uso com Produto

package com.seuprojeto.checkout.app; import com.seuprojeto.checkout.domain.Product; import com.seuprojeto.checkout.domain.Sku; import com.seuprojeto.checkout.repository.Repository; import java.util.Objects; public final class ProductCatalog {     private final Repository<Sku, Product> repo;     public ProductCatalog(Repository<Sku, Product> repo) {         this.repo = Objects.requireNonNull(repo);     }     public Product requireBySku(Sku sku) {         return repo.findById(sku)                 .orElseThrow(() -> new IllegalArgumentException("Product not found: " + sku));     } }

Passo 6 — Discussão prática: Singleton é necessário aqui?

Um impulso comum é transformar Factories, repositórios ou serviços em Singleton “para ter uma instância só”. Neste projeto, isso raramente é necessário:

  • Factories (DiscountPolicyFactory, ShippingPolicyFactory) são stateless: podem ser instanciadas normalmente ou injetadas por composição.
  • Repositório em memória pode ser criado no “composition root” (ex.: classe Main ou configuração) e passado para quem precisa.
  • Serviços (CheckoutService) também são stateless e fáceis de instanciar.

Quando você realmente precisa de uma instância global? Em geral, quando há um recurso único e compartilhado (ex.: pool, cache global com política única) e mesmo assim costuma ser melhor usar injeção de dependências e controle explícito do ciclo de vida. Aqui, manter tudo por composição deixa o design mais testável e previsível.

Passo 7 — Refatorações guiadas (do “funciona” para “sustentável”)

Agora que o fluxo existe, vamos aplicar refatorações típicas que aparecem conforme requisitos mudam.

Refatoração A: consolidar itens repetidos no carrinho

Problema: Cart.add sempre adiciona um novo CartItem, mesmo se o produto já existe. Isso pode quebrar regras de negócio e complicar subtotal.

Refatoração: trocar List por Map<Sku, CartItem> ou manter lista e mesclar. Exemplo com Map:

package com.seuprojeto.checkout.domain; import java.util.*; public final class Cart {     private final Map<Sku, CartItem> itemsBySku = new LinkedHashMap<>();     public void add(Product product, int quantity) {         Objects.requireNonNull(product);         if (quantity <= 0) throw new IllegalArgumentException("quantity must be > 0");         Sku sku = product.sku();         CartItem existing = itemsBySku.get(sku);         if (existing == null) {             itemsBySku.put(sku, new CartItem(product, quantity));         } else {             itemsBySku.put(sku, new CartItem(product, existing.quantity() + quantity));         }     }     public List<CartItem> items() {         return List.copyOf(itemsBySku.values());     }     public Money subtotal() {         Money total = Money.of("0");         for (CartItem item : itemsBySku.values()) {             total = total.plus(item.subtotal());         }         return total;     } }

Note como equals/hashCode de Sku e Product tornam essa mudança segura e previsível.

Refatoração B: evitar “desconto maior que subtotal”

Se um cupom fixo for maior que o subtotal, o total não deve ficar negativo. Já tratamos isso em Money.minus com um clamp simples para zero. Alternativamente, você pode mover essa regra para o serviço de checkout para deixar Money mais “matemático”. A decisão depende do que Money representa no seu domínio.

Refatoração C: substituir Factory com switch por registro configurável

Se os cupons crescerem, o switch vira um ponto de manutenção. Uma alternativa incremental é mapear códigos para estratégias:

package com.seuprojeto.checkout.factory; import com.seuprojeto.checkout.pricing.DiscountPolicy; import com.seuprojeto.checkout.pricing.NoDiscount; import java.util.Locale; import java.util.Map; import java.util.Objects; public final class DiscountPolicyFactory {     private final Map<String, DiscountPolicy> policiesByCode;     public DiscountPolicyFactory(Map<String, DiscountPolicy> policiesByCode) {         this.policiesByCode = Map.copyOf(Objects.requireNonNull(policiesByCode));     }     public DiscountPolicy fromCoupon(String couponCode) {         if (couponCode == null || couponCode.isBlank()) return new NoDiscount();         String code = couponCode.trim().toUpperCase(Locale.ROOT);         return policiesByCode.getOrDefault(code, new NoDiscount());     } }

Essa refatoração reduz condicionais e facilita testes (basta passar um mapa no teste).

Passo 8 — Cenários de uso e testes simples

Vamos validar o comportamento com testes de cenário. O objetivo não é cobrir tudo, mas garantir que os fluxos principais estão corretos e que as refatorações não quebraram regras.

Teste de total com cupom percentual e frete grátis acima do limite

package com.seuprojeto.checkout.tests; import com.seuprojeto.checkout.app.CheckoutService; import com.seuprojeto.checkout.domain.*; import com.seuprojeto.checkout.pricing.*; import org.junit.jupiter.api.Test; import java.math.BigDecimal; import static org.junit.jupiter.api.Assertions.assertEquals; public class CheckoutServiceTest {     @Test     void total_with_percentage_discount_and_free_shipping() {         Product p1 = new Product(new Sku("A1"), "Mouse", Money.of("60"));         Product p2 = new Product(new Sku("B2"), "Teclado", Money.of("50"));         Cart cart = new Cart();         cart.add(p1, 1);         cart.add(p2, 1); // subtotal = 110         DiscountPolicy discount = new PercentageDiscount(new BigDecimal("10")); // 11         ShippingPolicy shipping = new FreeShippingAbove(Money.of("100"), Money.of("15")); // 0         CheckoutService service = new CheckoutService(discount, shipping);         Money total = service.totalFor(cart);         assertEquals(Money.of("99"), total);     } }

Teste de cupom fixo maior que subtotal (não negativo)

package com.seuprojeto.checkout.tests; import com.seuprojeto.checkout.app.CheckoutService; import com.seuprojeto.checkout.domain.*; import com.seuprojeto.checkout.pricing.*; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; public class DiscountEdgeCaseTest {     @Test     void fixed_discount_cannot_make_total_negative() {         Product p = new Product(new Sku("X"), "Cabo", Money.of("10"));         Cart cart = new Cart();         cart.add(p, 1); // subtotal 10         CheckoutService service = new CheckoutService(new FixedDiscount(Money.of("50")), new FlatShipping(Money.of("0")));         assertEquals(Money.of("0"), service.totalFor(cart));     } }

Teste de mesclagem de itens no carrinho

package com.seuprojeto.checkout.tests; import com.seuprojeto.checkout.domain.*; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; public class CartTest {     @Test     void adding_same_product_merges_quantities() {         Product p = new Product(new Sku("A1"), "Mouse", Money.of("60"));         Cart cart = new Cart();         cart.add(p, 1);         cart.add(p, 2);         assertEquals(1, cart.items().size());         assertEquals(3, cart.items().get(0).quantity());         assertEquals(Money.of("180"), cart.subtotal());     } }

Checklist de revisão (refatoração e qualidade)

ItemO que verificarSinal de problema
InvariantesQuantidade > 0, dinheiro não negativo, SKU não vazioValidações duplicadas e inconsistentes
ImutabilidadeValue Objects sem setters e com campos finaisObjetos de valor mutáveis usados como chave
equals/hashCodeIdentidade de domínio (SKU) e consistênciaItens duplicados no Map/Set sem explicação
VariaçõesDesconto e frete como estratégiasCondicionais espalhadas em vários lugares
FactoriesCriação centralizada quando depende de input/configServiços cheios de lógica de criação
SingletonEvitar global state; preferir composiçãoTestes difíceis por dependências ocultas
PacotesDomínio isolado, app orquestra, pricing com políticasDependências circulares e “util” genérico
TestesCenários principais e casos de bordaRefatorar quebra comportamento sem aviso

Agora responda o exercício sobre o conteúdo:

Ao surgirem variações nas regras de desconto e frete, qual abordagem melhora a extensibilidade e reduz if/else espalhados no código do checkout?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Desconto e frete são pontos naturais de variação. Ao usar Strategy, cada regra fica isolada em uma implementação, e o CheckoutService coordena o cálculo do total, evitando condicionais espalhadas e facilitando testes e evolução.

Capa do Ebook gratuito Java Orientado a Objetos: Do Conceito ao Código com Padrões de Projeto Básicos
100%

Java Orientado a Objetos: Do Conceito ao Código com Padrões de Projeto Básicos

Novo curso

17 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.