Ir para o conteúdo principal
Background Image

Duas maneiras de implementar a mesma feature: a escolha que define a saúde do seu código

·7 minutos·
Rafael Issao
Autor
Rafael Issao
Apaixonado por tecnologia, programação e inovação. Adoro compartilhar conhecimento e aprender coisas novas todos os dias.
Tabela de conteúdos

Segunda-feira de manhã. Chega a demanda urgente:

“Precisamos de uma feature nova! Clientes VIP agora têm frete grátis em compras acima de R$ 50,00. Para os demais, continua sendo R$ 100,00.”

Vamos analisar a Mentalidade de Tarefa (focada em apenas “fazer funcionar” para fechar o ticket) e a Mentalidade de Design (focada em como essa nova regra impacta a saúde do sistema a longo prazo) para resolver esse mesmo problema.

O Cenário Atual
#

Lembrando:

“Precisamos de uma feature nova! Clientes VIP agora têm frete grátis em compras acima de R$ 50,00. Para os demais, continua sendo R$ 100,00.”

Temos um OrderService com a regra de frete grátis misturada com outras regras de negócio ou regras de infra, tudo dentro de um método transacional complexo:

@Service
@AllArgsConstructor
@Transactional
public class OrderService {

    private static final Logger log =
            LoggerFactory.getLogger(OrderService.class);

    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;
    private final InventoryEventPublisher eventPublisher;

    public OrderResponse placeOrder(OrderRequest request) {
        Order order = new Order();
        order.setOrderNumber(UUID.randomUUID()
                                 .toString());
        order.setCustomerEmail(request.customerEmail());
        order.setOrderDate(LocalDateTime.now());
        order.setStatus(OrderStatus.PENDING);

        BigDecimal totalAmount = BigDecimal.ZERO;

        for (var itemRequest : request.items()) {
            Product product =
                    productRepository
                            .findById(itemRequest.productId())
                            .orElseThrow(
                                    () ->
                                            new BusinessRuleException(
                                                    "Product not found " + "with id: " + itemRequest.productId()));

            product.validateStockQuantity(itemRequest.quantity());

            int quantity = itemRequest.quantity();
            BigDecimal unitPrice = product.getPrice();

            OrderItem orderItem = new OrderItem();
            orderItem.setQuantity(quantity);
            orderItem.setUnitPrice(unitPrice);
            order.addItem(orderItem);

            totalAmount =
                    totalAmount.add(unitPrice.multiply(BigDecimal.valueOf(quantity)));

            int newStock = product.getStockQuantity() - quantity;
            product.setStockQuantity(newStock);
            productRepository.save(product);
            eventPublisher.publishStockUpdated(new StockUpdate(product.getId(), newStock));
        }

        // Free shipping
        if (totalAmount.compareTo(new BigDecimal("100")) > 0) {
            order.setFreeShipping(true);
            log.info("Order {} qualifies for free shipping.",
                    order.getOrderNumber());
        }

        // Manual review
        if (totalAmount.compareTo(new BigDecimal("500")) > 0) {
            order.setManualReview(true);
            log.warn("Order {} requires manual review.",
                    order.getOrderNumber());
        }

        order.setTotalAmount(totalAmount);
        Order savedOrder = orderRepository.save(order);
        log.info("Order {} placed successfully.",
                savedOrder.getOrderNumber());
        eventPublisher.publishOrderPlaced(savedOrder);

        return savedOrder.toOrderResponse();
    }
}
Desafio: Conseguiu encontrar a regra de negócio que precisamos alterar no meio desse código?

E o OrderRequest é um record simples:

public record OrderRequest(
        @NotEmpty @Email String customerEmail,
        @NotEmpty @Valid List<OrderItemRequest> items) {
}

⚡ A Mentalidade de Tarefa
#

Nessa abordagem, o foco é um só: remover o ticket da frente. É o caminho da menor resistência, onde o desenvolvedor age de forma reativa ao requisito. O “meu eu do passado” (ou qualquer um de nós sob pressão) pensaria: “Isso é fácil. Só preciso passar um boolean isVip e mudar o if.”

A Mudança no Request
#

A alteração no OrderRequest é feita adicionando o campo da forma mais direta:

public record OrderRequestFastImpl(
        @NotEmpty @Email String customerEmail,
        @NotEmpty @Valid List<OrderItemRequest> items,
        boolean isVip) {
}

A Lógica no Serviço
#

E no serviço OrderService, a mudança é injetada no meio do método transacional:

        var freeShippingLimit = request.isVip() ? new BigDecimal("50") :
                new BigDecimal("100");

        // Free shipping
        if (totalAmount.compareTo(freeShippingLimit) > 0) {
            order.setFreeShipping(true);
            log.info("Order {} qualifies for free shipping.",
                    order.getOrderNumber());
        }
Desafio: Consegue imaginar as consequências ocultas dessa mudança?

As Consequências Ocultas
#

A feature é entregue em 5 minutos. Mas a que custo?

  1. Quebra de Contrato: Ao adicionar boolean isVip no construtor do record, quebramos todos os testes e classes que instanciavam OrderRequest. É o famoso “efeito cascata” que obriga a corrigir arquivos que nem deveriam ser tocados.

  2. Adeus, Testes Unitários: Para testar se essa regra do VIP funciona, precisaríamos instanciar o OrderService. Mas como ele depende de repositórios e publishers, teríamos que “mocar” o universo inteiro só para validar um if. O resultado é o OrderServiceTest, um teste de “alto custo” para escrever e manter:

    @Test
    void tooExpensiveToWrite() {
        // Criando os mocks...
        OrderRepository orderRepository = Mockito.mock(OrderRepository.class);
        ProductRepository productRepository = Mockito.mock(ProductRepository.class);
        InventoryEventPublisher eventPublisher = Mockito.mock(InventoryEventPublisher.class);

        // ...Configurando os mocks

        OrderServiceFastImpl orderService = new OrderServiceFastImpl(productRepository, orderRepository, eventPublisher);
        OrderRequestFastImpl orderRequest = new OrderRequestFastImpl("teste@gmail.com", null, false);

        // Para finalmente começar a usar o método...
        orderService.placeOrder(orderRequest);
    }
}
  1. Armadilha do Primitivo: Usei boolean primitivo. Se o front-end mandar um JSON sem o campo isVip, ele assume false automaticamente. Nunca saberei se o cliente não é VIP ou se houve um erro de integração. Imagine o cenário abaixo, onde o App foi atualizado mas a Web não:

graph TD

    A[Smartphone App v2.0] -- "Envia {..., isVip: true}" --> B(Backend)

    C[Web Client v1.5] -- "Envia {...} (isVip ausente)" --> B

    B -- "isVip assume false (Primitivo)" --> D{Regra de VIP não aplicada}

    style A fill:#d4edda,stroke:#28a745

    style C fill:#f8d7da,stroke:#dc3545

  1. Complexidade: Adicionei mais lógica condicional dentro de um método que já fazia muita coisa.

🏗️ A Mentalidade de Design
#

Aqui, aplicamos o Lema do Escoteiro: deixar a área mais limpa do que a encontramos. O foco não é apenas “fazer funcionar”, mas garantir que o sistema continue evoluindo sem medo. No artigo anterior vimos como identificar responsabilidades, e aqui aplicamos isso na prática.

1. Analisando o Legado (Onde está a regra?)
#

Antes de sair codando, analisamos o código original para identificar se conseguimos extrair algum conceito de negócio que está “escondido” ou misturado com outras regras de negócio ou regras de infra. No nosso caso:

        // Free shipping
        if (totalAmount.compareTo(new BigDecimal("100")) > 0) {
            order.setFreeShipping(true);
            log.info("Order {} qualifies for free shipping.",
                    order.getOrderNumber());
        }

Nesta mentalidade, percebemos que é perfeitamente possível extrair essa lógica! Ela depende unicamente do valor total e do booleano isVip.

Portanto, antes de modificar qualquer coisa no serviço, criamos uma classe que representa essa Regra de Negócio isolada.

2. Começando pelo Teste (O Contrato da Regra)
#

Com o conceito identificado, utilizamos o TDD (Test Driven Development). Antes de existir a lógica, definimos como ela deve se comportar. O ShippingPolicyTest valida os cenários de VIP, cliente padrão e retrocompatibilidade (nulos):

class ShippingPolicyTest {

    @Test
    @DisplayName("VIPs should get free shipping for orders above 50.00")
    void vipShouldHaveLowerLimit() {
        // Given
        var isVip = true;

        // When / Then
        assertThat(applyFreeShipping(amount("50.01"), isVip)).isTrue();
        assertThat(applyFreeShipping(amount("50.00"), isVip)).isFalse();
    }

    @Test
    @DisplayName("Standard customers should get free shipping for orders above 100.00")
    void standardShouldHaveHigherLimit() {
        // Given
        var isVip = false;

        // When / Then
        assertThat(applyFreeShipping(amount("100.01"), isVip)).isTrue();
        assertThat(applyFreeShipping(amount("100.00"), isVip)).isFalse();
    }

    @Test
    @DisplayName("Null VIP status is treated as Standard customer")
    void nullVipShouldBeTreatedAsStandard() {
        // Given
        Boolean isVip = null;

        // When / Then
        assertThat(applyFreeShipping(amount("100.01"), isVip)).isTrue();
        assertThat(applyFreeShipping(amount("100.00"), isVip)).isFalse();
    }

    private BigDecimal amount(String value) {
        return new BigDecimal(value);
    }
}

3. Implementando e Refatorando (ShippingPolicy)
#

Com os testes falhando, implementamos a ShippingPolicy. O fato de ter um teste unitário nos dá segurança para refatorar e isolar a regra em uma classe pura:

public class ShippingPolicy {
    // Valores nomeados conforme a regra de negócio
    private static final BigDecimal STANDARD_LIMIT = new BigDecimal("100" +
            ".00");
    private static final BigDecimal VIP_LIMIT = new BigDecimal("50.00");

    // Recebe Boolean (wrapper) para suportar nulos de clientes legados
    public static boolean applyFreeShipping(BigDecimal amount,
                                     Boolean isVip) {

        // Design Defensivo: Trata null como false (Retrocompatibilidade)
        BigDecimal limit = Boolean.TRUE.equals(isVip) ? VIP_LIMIT :
                STANDARD_LIMIT;

        return amount.compareTo(limit) > 0;
    }
}

4. Protegendo o Contrato (OrderRequest)
#

No OrderRequest, usamos Boolean (objeto) e mantemos um construtor compatível com a versão anterior. Ninguém quebra!

public record OrderRequestWellThought(
        @NotEmpty @Email String customerEmail,
        @NotEmpty @Valid List<OrderItemRequest> items,
        Boolean isVip) {

    // Mantendo API antiga
    public OrderRequestWellThought(@NotEmpty @Email String customerEmail,
                                   @NotEmpty @Valid List<OrderItemRequest> items) {
        this(customerEmail, items, false);
    }
}

5. Integração Limpa
#

No OrderService, a mudança final é sutil, declarativa e não polui o método principal:

        if (ShippingPolicy.applyFreeShipping(totalAmount, request.isVip())) {
            order.setFreeShipping(true);
            log.info("Order {} qualifies for free shipping.",
                    order.getOrderNumber());

Conclusão
#

A Mentalidade de Tarefa resolve o problema em 5 minutos, mas cria dívida técnica, quebra clientes existentes e desencoraja testes. É uma vitória de curto prazo que pode gerar um custo desnecessário de manutenção.

A Mentalidade de Design:

  1. Isola a complexidade (ShippingPolicy).
  2. Permite testes baratos e rápidos (ShippingPolicyTest).
  3. Mantém compatibilidade (Construtores e Wrappers).
  4. Documenta o código (Nomes explícitos de constantes e classes).

Seu “Eu do Futuro” (e toda a sua equipe) vai agradecer!

E você? Tem aprendido e conseguido praticar as boas práticas de programação mesmo em código legado?

Tomara que tenha encorajado a você tentar escrever um código melhor independente da situação!

📚 Referências
#

🥒🥒 Até o próximo artigo! 🥒🥒