Quando um pedido deixa de caber em uma classe
Analisando a aplicação de boas práticas. Como a inversão de dependência e a separação de responsabilidades pode ajudar a melhorar um código?
Fazer uma CLI de pedidos funcionar parece, à primeira vista, uma tarefa direta: ler os dados no terminal, criar um pedido, guardar alguma coisa e mostrar uma mensagem. O problema começa quando essa frase vira uma única classe.
Este artigo acompanha uma aplicação Java 21 de gestão de pedidos. Ela cadastra clientes e produtos, cria pedidos, inclui itens, simula pagamentos e persiste dados em JSON. Não há Spring, ORM, banco de dados ou contêiner de injeção de dependências. Isso é intencional: o projeto existe para tornar as decisões de composição visíveis antes de delegá-las a um framework.
objetivo aqui não é declarar que a aplicação “evoluiu historicamente” de um código ruim para um bom. O repositório não registra essa narrativa. Vou usar uma versão ingênua hipotética como ponto de partida e, em seguida, mostrar como o código real responde aos problemas que ela expõe. O fio condutor será um pedido: criá-lo para um cliente, adicionar um produto e aprová-lo.
Quando fazer funcionar deixa de ser a única preocupação
Imagine uma opção de menu que faça tudo: interpreta UUIDs, verifica se o cliente existe, cria o pedido, aplica regras de pagamento, escreve um arquivo e imprime o resultado. Para poucas linhas, isso pode até ser a escolha mais econômica. Mas o que acontece quando precisamos trocar o arquivo por outra forma de armazenamento? Ou testar a regra “um pedido vazio não pode ser pago” sem montar entrada de terminal e arquivos?
Um esboço hipotético ajuda a enxergar a tensão:
// Exemplo hipotético: não pertence ao repositório.
void pay(String orderIdText) throws IOException {
Order order = readOrderFromJson(UUID.fromString(orderIdText));
if (order.getItems().isEmpty()) {
throw new IllegalStateException("Pedido vazio");
}
order.setStatus(PAID);
writeOrderToJson(order);
System.out.println("Pagamento aprovado");
}O problema não é haver vários comandos no método. O problema é ele conhecer decisões de naturezas diferentes: formato de entrada, consulta, regra comercial, mutação de estado, serialização e saída. Uma mudança em qualquer uma dessas áreas mexe no mesmo lugar. Esse é um sinal de baixa coesão: há responsabilidades pouco relacionadas convivendo no mesmo componente.
Também há alto acoplamento: a regra de pagamento depende diretamente de JSON e do terminal. Coesão pergunta se as responsabilidades de uma unidade pertencem juntas; acoplamento pergunta quanto uma unidade precisa saber sobre outra. Separar um arquivo em cinco sem mudar essas relações não resolve nada. A separação útil muda quem conhece o quê.
Cada componente precisa saber o quê?
No código real, a estrutura torna essa pergunta concreta. domain contém o que um pedido é e quais estados são válidos. application coordena os casos de uso. infrastructure conhece persistência em memória ou JSON. cli lê, valida formato e apresenta respostas. A divisão também é organizada por contextos como customer, product, order e payment; isso aproxima arquivos que tratam do mesmo assunto sem exigir uma adoção formal de DDD.
Quando um produto é incluído, não é a CLI que calcula subtotal ou decide se o pedido ainda pode mudar. Essas decisões pertencem ao objeto que protege o estado do pedido:
// Order.java
public void addProduct(Product product, int quantity) {
ensureDraft();
Product requiredProduct = requireValue(product, "Product is required");
items.compute(requiredProduct.getId(), (productId, existingItem) ->
existingItem == null
? OrderItem.from(requiredProduct, quantity)
: existingItem.addQuantity(quantity));
}
public PaymentResult pay(PaymentMethod method) {
PaymentMethod requiredMethod = requireValue(method, "Payment method is required");
ensureDraft();
if (items.isEmpty()) {
throw new DomainException("An empty order cannot be paid");
}
PaymentResult result = requiredMethod.process(getSubtotal());
if (result == PaymentResult.APPROVED) {
status = OrderStatus.PAID;
}
return result;
}
Order mantém os itens em um mapa por ProductId, impede edição fora de DRAFT e delega o processamento a PaymentMethod. O OrderItem captura nome e preço do produto quando entra no pedido; assim, uma alteração posterior no catálogo não reescreve a história comercial daquele item. Isso é encapsulamento em sentido prático: não esconder campos por ritual, mas garantir que as invariantes sejam aplicadas junto do estado que elas protegem.
O terminal continua importante, só não decide a regra. TerminalSession converte texto em identificadores e quantidades, chama os serviços e traduz exceções esperadas em mensagens ao usuário. Ela é uma fronteira de interação, não uma segunda implementação do domínio.
Coordenar não é ser dono de todas as regras
Depois de colocar regras em Order, ainda resta uma pergunta: quem organiza a operação que atravessa cliente, pedido e armazenamento?
Criar um pedido exige confirmar que o cliente existe, gerar ID e instante, criar a entidade e persistir o resultado. Esse é um caso de uso; no projeto, ele vive no OrderApplicationService:
// OrderApplicationService.java
public Order createOrder(CustomerId customerId) {
CustomerId requiredCustomerId = requireCustomerId(customerId);
customerRepository.findById(requiredCustomerId)
.orElseThrow(() -> new CustomerNotFoundException(requiredCustomerId));
Order order = new Order(OrderId.newId(), requiredCustomerId, clock.instant());
orderRepository.save(order);
return order;
}
public Order addProductToOrder(OrderId orderId, Product product, int quantity) {
Order order = findOrderById(orderId);
order.addProduct(product, quantity);
orderRepository.save(order);
return order;
}Aqui aparece a Service Layer: uma camada que define operações da aplicação e coordena colaboradores. Ela não substitui o domínio. O serviço verifica existência, escolhe quando salvar e encadeia passos; Order continua dizendo se pode receber produto e como calcula seu subtotal. Se o serviço reimplementasse ensureDraft ou manipulasse a coleção interna, teríamos deslocado — e provavelmente duplicado — a regra, não a organizado.
Clock merece atenção. O serviço recebe o relógio em vez de chamar diretamente Instant.now(). Em produção, a composição fornece Clock.systemUTC(); no teste, um Clock.fixed. Não é uma abstração decorativa: tempo é uma dependência externa variável, e o contrato da operação inclui registrar o instante de criação de maneira verificável.
Separar responsabilidades é suficiente?
Não necessariamente. Um serviço pode estar separado em seu arquivo e ainda depender diretamente de uma classe técnica:
// Exemplo hipotético: acoplamento concreto.
final class OrderApplicationService {
private final JsonOrderRepository orders =
new JsonOrderRepository(Path.of("data/orders.json"));
}Agora o caso de uso sabe que os pedidos vivem em JSON, conhece um caminho de arquivo e escolhe sua própria infraestrutura. Trocar para memória em um teste exigiria alterar o serviço; trocar JSON por outro armazenamento também. Criar uma dependência dentro da classe é confortável no começo, mas transforma o detalhe escolhido em parte fixa da política da aplicação.
O projeto responde com um contrato pequeno:
// OrderRepository.java
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
List<Order> findAll();
}O contrato não diz “execute SQL”, “abra arquivo” ou “serialize esta entidade”. Ele expressa o que os casos de uso de pedido precisam: salvar, buscar por ID e listar. Essa é a ideia relevante do Repository Pattern aqui: oferecer uma fronteira de coleção/persistência orientada ao domínio, e não uma classe genérica de acesso a banco.
E se dependêssemos de contratos?
O serviço real recebe o contrato, não uma implementação concreta:
// OrderApplicationService.java
private final OrderRepository orderRepository;
private final CustomerRepository customerRepository;
private final Clock clock;
public OrderApplicationService(OrderRepository orderRepository,
CustomerRepository customerRepository,
Clock clock) {
this.orderRepository = orderRepository;
this.customerRepository = customerRepository;
this.clock = clock;
}Do outro lado, duas classes concretas cumprem esse contrato. A de memória usa LinkedHashMap; a de JSON lê documentos, converte explicitamente para objetos de domínio e grava um arquivo temporário antes da substituição atômica:
// JsonOrderRepository.java
public final class JsonOrderRepository implements OrderRepository {
@Override
public Optional<Order> findById(OrderId id) { /* lê e reconstrói */ }
@Override
public void save(Order order) { /* valida, mapeia e publica JSON */ }
}Isso inverte uma direção comum e problemática: a regra de alto nível não aponta para Path, Files ou Jackson. A infraestrutura é que aponta para o contrato definido junto da aplicação. Em termos de Dependency Inversion Principle (DIP), a política depende de uma abstração próxima do seu problema, enquanto o detalhe a implementa.
Interface, porém, não é sinônimo automático de bom design. Uma interface que apenas repete detalhes de uma biblioteca continua acoplando a aplicação àquela biblioteca. Tampouco cada classe precisa ganhar sua própria interface: Order não tem uma, porque o problema atual não pede múltiplas implementações de pedido. Os contratos existem onde há uma colaboração que precisa variar ou ser isolada de forma significativa.
Quem cria as dependências?
Depois dessa separação sobra uma questão inevitável: se o serviço não deve criar o repositório, quem o fornece?
A resposta está na raiz de composição, Main:
// Main.java
JsonCustomerRepository customerRepository = new JsonCustomerRepository(Path.of("data/customers.json"));
JsonOrderRepository orderRepository = new JsonOrderRepository(Path.of("data/orders.json"));
OrderApplicationService orders = new OrderApplicationService(orderRepository, customerRepository, Clock.systemUTC());
PaymentApplicationService payments = new PaymentApplicationService(orderRepository);Essa é injeção de dependências por construtor: a classe declara o que necessita, recebe as referências de fora e não decide como construí-las. Main pode conhecer detalhes concretos justamente porque sua responsabilidade é montar a aplicação. Repare também que o mesmo orderRepository é entregue a pedidos e pagamentos; portanto, ambos trabalham sobre a mesma fonte de dados durante a sessão.
DI e DIP se reforçam, mas não são a mesma coisa. DIP trata da forma e da direção da dependência: OrderApplicationService conversa com OrderRepository, não com Jackson. DI trata da obtenção da referência: alguém passa a implementação ao construtor. Seria possível injetar uma dependência concreta e continuar fortemente acoplado; também seria possível ter um bom contrato, mas instanciá-lo internamente. As duas decisões resolvem problemas diferentes.
Nada disso exige um contêiner. Um framework pode automatizar a composição quando ela cresce, mas não é o mecanismo que torna esta DI válida. Neste projeto, a composição manual deixa visíveis as relações e permite escolher JSON na aplicação executável ou memória em uma composição alternativa.
Essa escolha afeta diretamente os testes. OrderApplicationServiceTest fornece um repositório gravador local e relógio fixo; PaymentApplicationServiceTest fornece um RecordingOrderRepository e um PaymentMethod de teste. Assim, cada teste verifica a coordenação — por exemplo, que uma regra de domínio rejeitada não chama save — sem depender de terminal ou arquivos. Há também testes específicos para a infraestrutura JSON. Não é uma promessa abstrata de “código testável”: são limites que permitem testar perguntas diferentes no lugar adequado.
A arquitetura funcionando no pagamento
Quando o usuário pede para aprovar um pedido, o caminho completo fica assim:
Cada etapa possui uma pergunta diferente: a CLI valida formato; o serviço encontra e coordena; o domínio protege o estado; o repositório preserva dados. Se o pagamento é recusado, Order permanece em DRAFT e o serviço não salva. Se é aprovado, o status muda para PAID e a implementação JSON persiste essa mudança. A separação não elimina as relações entre partes; ela torna explícito onde cada relação deve morar.
O que essa organização resolve — e o que ela custa?
O benefício mais imediato é localizar mudança. Regras de pedido não precisam conhecer a estrutura JSON. A troca documentada de repositórios em memória por JSON ocorreu sem mudar os contratos ou os serviços. A infraestrutura não precisou deformar o domínio com setters ou anotações Jackson: ela usa representações próprias e operações de restauração validadas.
Há custos reais. Temos mais tipos, mais construtores e uma raiz de composição que precisa acompanhar novas dependências. Repositórios específicos repetem uma pequena forma comum, porque criar um GenericRepository seria uma abstração prematura para três contextos com regras próprias. E JSON não resolve concorrência, transações entre arquivos, recuperação automática nem persistência de escala maior.
Para um script curto, talvez tudo isso fosse excesso. Para um projeto didático que precisa exercitar regras, persistência intercambiável e testes em Java puro, o custo é justificado pelo aprendizado e pela clareza. Em outro contexto, a decisão deve nascer das pressões reais — número de casos de uso, volatilidade da infraestrutura, necessidade de testes e tamanho da composição —, não de uma lista de padrões.
Arquitetura como consequência de boas perguntas
A arquitetura desta CLI não começa com a decisão de “usar Repository” ou “aplicar DI”. Ela pode ser entendida como uma sequência de perguntas: quem protege um pedido válido? Quem coordena cliente, pedido e persistência? Como guardar pedidos sem contaminar a regra com arquivos? Quem conecta os objetos?
As respostas levam ao domínio encapsulado, a serviços de aplicação, a contratos pequenos e à composição manual. O resultado não é uma arquitetura universal. É uma aplicação em que as dependências mais voláteis — terminal, JSON, relógio — ficam nas bordas, enquanto a regra de um pedido continua legível e testável no centro.
Esse é o ponto mais útil das abstrações: não adicionar camadas para parecer sofisticado, mas criar limites quando eles nos ajudam a mudar, compreender e testar o software com menos surpresa.
Conteúdo Gerado Por AI, revisão humana.
Referências
https://github.com/ManoelReisDev/order-management-cli-
Martin Fowler, Inversion of Control Containers and the Dependency Injection pattern
Brett Schuchert, DIP in the Wild