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 // testesPasso 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.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
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
Mainou 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)
| Item | O que verificar | Sinal de problema |
|---|---|---|
| Invariantes | Quantidade > 0, dinheiro não negativo, SKU não vazio | Validações duplicadas e inconsistentes |
| Imutabilidade | Value Objects sem setters e com campos finais | Objetos de valor mutáveis usados como chave |
| equals/hashCode | Identidade de domínio (SKU) e consistência | Itens duplicados no Map/Set sem explicação |
| Variações | Desconto e frete como estratégias | Condicionais espalhadas em vários lugares |
| Factories | Criação centralizada quando depende de input/config | Serviços cheios de lógica de criação |
| Singleton | Evitar global state; preferir composição | Testes difíceis por dependências ocultas |
| Pacotes | Domínio isolado, app orquestra, pricing com políticas | Dependências circulares e “util” genérico |
| Testes | Cenários principais e casos de borda | Refatorar quebra comportamento sem aviso |