Você já viu esse filme.
Tem um método que recebe UUID, BigDecimal, String, int. Funciona. Você sobe. Passa um tempo. Aí alguém cria pagamento com amount = 0, moeda vazia, parcelas zero... ou troca a ordem dos parâmetros e ninguém percebe porque "é tudo tipo String/BigDecimal mesmo".
O problema não é falta de validação. É pior: o sistema aceita estados que não deveriam existir.
A mudança de chave é simples: parar de passar valores "crus" e começar a passar conceitos do domínio. Em Java 21, record é a ferramenta perfeita pra isso.
Se você leu meu post sobre arquitetura hexagonal, já sabe que domínio vem primeiro. Aqui vamos aplicar isso na prática com Value Objects.
Value Object vs Entity
Entity: tem identidade. O que define "ser o mesmo" é o ID, mesmo que outros dados mudem.
Value Object: não tem identidade; é comparado pelo valor. Se o valor é igual, é "o mesmo" conceito.
Exemplo mental:
Paymenté Entity (um pagamento específico com um ID)Money(100, BRL)é Value Object (não importa "qual instância", importa o valor)
Por que record encaixa tão bem? Porque ele já nasce com:
- Imutabilidade (campos são
final) equals/hashCodepor valor- Sintaxe curta
Antes: primitivos por todo lado e validação que vaza
O estilo clássico:
class PaymentService {
public UUID createPayment(UUID orderId,
BigDecimal amount,
String currency,
int installments) {
if (orderId == null)
throw new IllegalArgumentException("orderId é obrigatório");
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0)
throw new IllegalArgumentException("amount deve ser > 0");
if (currency == null || currency.isBlank())
throw new IllegalArgumentException("currency é obrigatória");
if (installments < 1 || installments > 12)
throw new IllegalArgumentException("installments deve ser 1..12");
return UUID.randomUUID();
}
}
Parece ok... até você perceber as consequências:
- Você vai repetir essas regras em outros fluxos (API, job, consumer, importação, testes)
currencyéString: tanto faz "BRL" quanto "banana"- O método vira um lugar onde tudo "precisa se defender" o tempo todo
- Nada impede que outra parte do código crie pagamento com dados ruins
O domínio não protege invariantes — ele só confia.
Depois: Value Objects que nascem válidos ou não nascem
Vamos fazer o sistema aceitar menos lixo.
Currency (simples e direto)
public enum Currency {
BRL, USD, EUR
}
Money (valor > 0 e moeda obrigatória)
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount, "amount é obrigatório");
Objects.requireNonNull(currency, "currency é obrigatória");
amount = amount.setScale(2, RoundingMode.HALF_UP);
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("amount deve ser > 0");
}
}
public static Money of(String value, Currency currency) {
return new Money(new BigDecimal(value), currency);
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Moedas diferentes");
}
return new Money(this.amount.add(other.amount), this.currency);
}
}
Installments (1 a 12)
public record Installments(int value) {
public Installments {
if (value < 1 || value > 12) {
throw new IllegalArgumentException("installments deve ser 1..12");
}
}
public static Installments of(int value) {
return new Installments(value);
}
}
Agora vem o pulo do gato:
Se um método recebe Money, ele não precisa verificar se é dinheiro válido. Já é.
Isso não elimina validação de entrada, mas muda a responsabilidade: o domínio não aceita estados inválidos.
Estado do pagamento: transições controladas
Vamos modelar uma regra comum: "capturar só depois de autorizar".
public enum PaymentStatus {
CREATED,
AUTHORIZED,
CAPTURED,
FAILED;
public PaymentStatus authorize() {
return switch (this) {
case CREATED -> AUTHORIZED;
default -> throw new IllegalStateException("Só autoriza a partir de CREATED");
};
}
public PaymentStatus capture() {
return switch (this) {
case AUTHORIZED -> CAPTURED;
default -> throw new IllegalStateException("Só captura a partir de AUTHORIZED");
};
}
public PaymentStatus fail() {
return FAILED;
}
}
Percebe o estilo? A regra não fica "solta" no service. Ela fica colada no estado.
Domínio primeiro: Payment como Entity
Agora sim, Payment é uma Entity: tem ID e comportamento. E não depende de Spring/JPA.
public class Payment {
private final UUID id;
private final UUID orderId;
private final Money amount;
private final Installments installments;
private PaymentStatus status;
private final Instant createdAt;
private Payment(UUID id, UUID orderId, Money amount,
Installments installments, PaymentStatus status) {
this.id = Objects.requireNonNull(id);
this.orderId = Objects.requireNonNull(orderId);
this.amount = Objects.requireNonNull(amount);
this.installments = Objects.requireNonNull(installments);
this.status = Objects.requireNonNull(status);
this.createdAt = Instant.now();
}
public static Payment create(UUID orderId, Money amount, Installments installments) {
return new Payment(UUID.randomUUID(), orderId, amount, installments, PaymentStatus.CREATED);
}
public void authorize() { this.status = this.status.authorize(); }
public void capture() { this.status = this.status.capture(); }
public void fail() { this.status = this.status.fail(); }
// getters...
public UUID id() { return id; }
public Money amount() { return amount; }
public PaymentStatus status() { return status; }
}
Compara mentalmente:
- Antes:
createPayment(UUID, BigDecimal, String, int)+ monte deif - Depois:
Payment.create(orderId, money, installments)— se for inválido, nem existe
É esse o "quase à prova de falhas": você erra menos porque o design deixa errado mais difícil.
Validação em camadas
- Entrada (DTO/API): checagens básicas de formato e presença
- Domínio (VOs/Entity): invariantes e regras de negócio
- Banco: constraints como rede de segurança
O ganho não é "parar de validar no DTO". É parar de depender do DTO como única muralha.
Mini fluxo: criar → autorizar → capturar
Se quiser ver o caso de uso completo:
public interface PaymentRepository {
void save(Payment payment);
Optional<Payment> findById(UUID id);
}
public class PaymentUseCase {
private final PaymentRepository repository;
public PaymentUseCase(PaymentRepository repository) {
this.repository = repository;
}
public UUID create(UUID orderId, Money amount, Installments installments) {
var payment = Payment.create(orderId, amount, installments);
repository.save(payment);
return payment.id();
}
public void authorize(UUID paymentId) {
var payment = repository.findById(paymentId)
.orElseThrow(() -> new IllegalArgumentException("Payment não encontrado"));
payment.authorize();
repository.save(payment);
}
public void capture(UUID paymentId) {
var payment = repository.findById(paymentId)
.orElseThrow(() -> new IllegalArgumentException("Payment não encontrado"));
payment.capture();
repository.save(payment);
}
}
Repara numa coisa: os métodos do caso de uso ficaram chatos (no bom sentido). Eles viram "fluxo" e não "lugar da regra". Regra está no domínio.
Atritos com persistência/serialização
Aqui é onde o pessoal se empolga com domínio puro e depois tropeça.
VO ↔ DTO: alguém precisa converter String amount para Money. Isso fica no adapter:
public record CreatePaymentRequest(String orderId, String amount, String currency, int installments) {
public Money toMoney() {
return Money.of(amount, Currency.valueOf(currency));
}
public Installments toInstallments() {
return Installments.of(installments);
}
}
JPA: você pode persistir VOs com @Embeddable ou @Converter:
@Converter(autoApply = true)
public class InstallmentsConverter implements AttributeConverter<Installments, Integer> {
public Integer convertToDatabaseColumn(Installments i) { return i.value(); }
public Installments convertToEntityAttribute(Integer v) { return new Installments(v); }
}
JSON: expor domínio direto na API pode prender seu contrato ao formato interno. Na dúvida, use DTO de resposta separado.
Tradução: Value Object te dá segurança; o custo é mapear fronteiras com mais intenção.
Onde dá ruim (trade-offs reais)
Compensa quando:
- Dinheiro, status e regras aparecem em vários fluxos
- Bugs de "validação esquecida" já aconteceram
- Você quer assinaturas que expliquem o que é esperado
Vira overengineering quando:
- Você cria VO sem invariante ("só um wrapper de String")
- A aplicação é simples e o mapeamento custa mais que o problema
- O time começa a discutir pureza em vez de entregar
Sinais de exagero:
- Mais tempo criando tipos do que resolvendo domínio
- VOs que não têm regra nenhuma, só "nome bonito"
- Explosão de conversões sem retorno claro
Como começar sem se perder:
- 1–2 VOs que realmente doem (
Money,Installments, IDs semânticos) - Só crie o próximo quando puder dizer: "isso evita X bug"
Checklist: quando usar / quando evitar
Use quando:
- Existe regra objetiva (faixa, formato, invariantes)
- O dado atravessa muitos pontos do sistema
- O custo de errar é alto (dinheiro, estados, segurança)
Evite quando:
- Você não consegue nomear a invariante
- O domínio é trivial e o wrapper vira enfeite
- O custo de conversão está te travando