Destino Dev

Blog

JPA Embeddables: criar e usar Value Objects (Address, Money) passo a passo

Por John Pires · 19 de março de 2026

JPA Embeddables: criar e usar Value Objects (Address, Money) passo a passo

Contexto: quando você vai usar isso

O problema de modelagem que aparece em sistemas reais

Em sistemas orientados a domínio, muitos atributos não carregam identidade própria: endereços, telefones, valores monetários, dimensões. Esses dados representam conceitos do domínio — value objects — e não entidades com ciclo de vida próprio. Programadores iniciantes tentam duas abordagens comuns e problemáticas: espalhar campos primitivos pela entidade (street, city, zip) ou transformar tudo em entidade com tabela própria. Ambas têm custo: duplicação de lógica, consultas complexas, ou acoplamento desnecessário.

Por que usar Embeddables do JPA

JPA fornece @Embeddable e @Embedded para mapear value objects como blocos reutilizáveis incorporados na mesma tabela da entidade. Isso mantém a semântica do domínio (Address é um tipo rico) e evita tabelas extras. Embeddables tornam o código mais legível, encorajam imutabilidade e centralizam validações.

Ao longo deste artigo vamos investigar alternativas, ver código real de Address e Money como Embeddables, e aplicar boas práticas: construtores protegidos, equals/hashCode, AttributeOverride para colunas, e uso com @ElementCollection.

Explicação visual com diagramas mentais

Analogia simples: caixas dentro de uma caixa

Imagine uma planilha com uma coluna "cliente" e várias colunas adicionais: cliente_name, cliente_street, cliente_city, cliente_postal. Em vez de espalhar os campos do endereço em várias colunas, pense no endereço como uma "caixa" dentro da linha do cliente. Essa caixa (Address) contém campos próprios, mas não tem identidade. JPA Embeddable coloca essa caixa dentro da mesma linha/tabela da entidade que a possui.

Dois caminhos alternativos — prós e contras

Alternativa 1: Colunas primitivas — simples, mas repete lógica em várias entidades. Alternativa 2: Entidade separada — separação clara de dados, porém gera JOINs, precisa de identidade, e pode dar margem a atualizações acidentais. Embeddable fica no meio: compõe sem identidade e sem precisar de JOINs.

Dica: valore o contrato do seu domínio. Se o objeto tem identidade própria (p.ex. um fornecedor com ciclo de vida), use entidade. Se é apenas um conceito imutável, prefira embeddable.

Primeiro exemplo simples

Objetivo do exemplo

Vamos criar um Address como @Embeddable e usar dentro de uma entidade Customer. Mostrar como mapear colunas padrão, adicionar equals/hashCode e garantir imutabilidade básica.

Classe Address: o embeddable

O código a seguir define um Address simples com rua, cidade e cep. Observe o construtor protegido para JPA, o construtor público para criação e os métodos de acesso. equals e hashCode seguem os campos de valor.

package com.exemplo.domain;

import javax.persistence.Embeddable;
import java.util.Objects;

@Embeddable
public class Address {

    private String street;
    private String city;
    private String postalCode;

    protected Address() {
        // construtor necessário para JPA
    }

    public Address(String street, String city, String postalCode) {
        this.street = street;
        this.city = city;
        this.postalCode = postalCode;
    }

    public String getStreet() { return street; }
    public String getCity() { return city; }
    public String getPostalCode() { return postalCode; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Address address = (Address) o;
        return Objects.equals(street, address.street) &&
               Objects.equals(city, address.city) &&
               Objects.equals(postalCode, address.postalCode);
    }

    @Override
    public int hashCode() {
        return Objects.hash(street, city, postalCode);
    }

    @Override
    public String toString() {
        return street + ", " + city + " - " + postalCode;
    }
}

Depois desse classe, vamos usar Address dentro de Customer.

Usando Address na entidade Customer

O trecho abaixo demonstra um entity simples onde o Address é mapeado como parte da mesma tabela customer. Não há @Id no Address: ele não tem identidade própria.

package com.exemplo.domain;

import javax.persistence.*;

@Entity
@Table(name = "customers")
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @Embedded
    private Address address;

    protected Customer() { }

    public Customer(String name, Address address) {
        this.name = name;
        this.address = address;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public Address getAddress() { return address; }

    public void changeAddress(Address newAddress) {
        this.address = newAddress;
    }
}

O que acontece: JPA cria colunas customers.street, customers.city, customers.postal_code (os nomes exatos dependem do provedor/strategy). Endereço é carregado e salvo junto com o cliente.

Segundo exemplo mais realista

Requisito: Money como value object

Valores monetários exigem cuidado: precisão, escala e moeda. Em vez de um BigDecimal solto, modelamos um Money com amount e currency como embeddable. Isso facilita validações e evita perda de semântica quando o domínio opera em múltiplas moedas.

Classe Money: precisão e immutabilidade

Implementamos Money com BigDecimal para amount e String para currency. Mantemos construtor protegido para JPA e métodos de fábrica para validação. Importante: evite double para valores monetários.

package com.exemplo.domain;

import javax.persistence.Embeddable;
import java.math.BigDecimal;
import java.util.Objects;

@Embeddable
public class Money {

    private BigDecimal amount;
    private String currency; // ISO 4217, ex: "BRL", "USD"

    protected Money() { }

    public Money(BigDecimal amount, String currency) {
        if (amount == null || currency == null) throw new IllegalArgumentException("amount and currency required");
        this.amount = amount.setScale(2, BigDecimal.ROUND_HALF_EVEN);
        this.currency = currency;
    }

    public BigDecimal getAmount() { return amount; }
    public String getCurrency() { return currency; }

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) throw new IllegalArgumentException("Currencies must match");
        return new Money(this.amount.add(other.amount), currency);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Money money = (Money) o;
        return Objects.equals(amount, money.amount) && Objects.equals(currency, money.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount, currency);
    }
}

Após definir Money, vamos mapear em uma entidade realista: Order com total, e um histórico de endereços de entrega usando @ElementCollection.

Order com Money e lista de Address

O exemplo abaixo mostra:

  • uso de @Embedded para Money
  • @ElementCollection para lista de Address (endereços antigos)
  • @AttributeOverride para renomear colunas do embeddable
package com.exemplo.domain;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Embedded
    private Money total;

    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "street", column = @Column(name = "shipping_street")),
        @AttributeOverride(name = "city", column = @Column(name = "shipping_city")),
        @AttributeOverride(name = "postalCode", column = @Column(name = "shipping_postal_code"))
    })
    private Address shippingAddress;

    @ElementCollection
    @CollectionTable(name = "order_old_addresses", joinColumns = @JoinColumn(name = "order_id"))
    private List<Address> previousAddresses = new ArrayList<>();

    protected Order() { }

    public Order(Money total, Address shippingAddress) {
        this.total = total;
        this.shippingAddress = shippingAddress;
    }

    public void addPreviousAddress(Address address) {
        previousAddresses.add(address);
    }
}

O que acontece: columns shipping_street, shipping_city, shipping_postal_code são criadas na tabela orders; a coleção de endereços gera uma tabela separada order_old_addresses com colunas para os campos de Address. Isso é útil para histórico sem transformar Address em entidade.

Erros comuns de iniciante e como evitar

Achar que Embeddable precisa de @Id

Um erro comum é tentar dar identidade a um embeddable. Embeddables não têm @Id — se você precisa de identidade e lifecycle, use entidade. Forçar @Id dentro de um embeddable indica escolha de modelagem errada.

Problemas com mutabilidade e comparação

Muitos iniciantes deixam os value objects mutáveis ou não implementam equals/hashCode. Como embeddables são usados em coleções, a falta de equals/hashCode consistente causa problemas ao armazenar em Sets ou ao comparar objetos carregados do banco. Recomendo objetos imutáveis ou imitar imutabilidade (sem setters públicos) e implementar equals/hashCode com todos os campos relevantes.

Exemplos de armadilhas práticas

  1. Colunas com nomes idênticos em dois embeddables que colidem: use @AttributeOverride.
  2. Usar double para dinheiro: leva a erros de precisão.
  3. Esquecer o construtor sem-args: JPA lança exceção no runtime.
Prática: escreva testes de integração com H2/LocalStack para garantir que o mapeamento gera as colunas e tabelas esperadas.

Exemplo de erro típico: adicionar lógica mutável no embeddable (p.ex. setStreet) e depois descobrir que múltiplas entidades compartilham referênciasa ao mesmo objeto em memória. Em vez disso, trate embeddables como valores: crie novos objetos para representar mudanças.

Como evitar problemas de migração de esquema

Quando você converte campos primitivos em embeddable, a migração de DB pode precisar renomear colunas. Use @AttributeOverride para mapear para nomes legados e escreva scripts de migração controlados (Flyway/Liquibase).

Testes e uso prático (service + repository)

Exemplo de repository Spring Data

Um repositório simples para Customer usando Spring Data JPA. O repositório permite salvar e buscar Customer com Address embutido.

package com.exemplo.repository;

import com.exemplo.domain.Customer;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CustomerRepository extends JpaRepository<Customer, Long> {
}

Depois de configurar o Spring Boot (application.properties) você pode injetar CustomerRepository em um service ou controller e salvar clientes com Address.

Exemplo de uso em um service

Este serviço demonstra persistir e atualizar um Customer trocando o Address por um novo objeto (princípio do value object: tratar como imutável).

package com.exemplo.service;

import com.exemplo.domain.Address;
import com.exemplo.domain.Customer;
import com.exemplo.repository.CustomerRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CustomerService {

    private final CustomerRepository repository;

    public CustomerService(CustomerRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public Customer createCustomer(String name, Address address) {
        Customer c = new Customer(name, address);
        return repository.save(c);
    }

    @Transactional
    public void changeAddress(Long customerId, Address newAddress) {
        Customer c = repository.findById(customerId).orElseThrow();
        c.changeAddress(newAddress); // substitui o objeto Address
        repository.save(c);
    }
}

O comportamento esperado: quando changeAddress é chamado, um UPDATE na tabela customers atualiza as colunas do endereço. Como criamos um novo Address, mantemos o princípio do value object.

Conclusão prática

Resumo investigativo

Modelar Address e Money como embeddables alinha o banco de dados com a semântica do domínio: valores compostos, sem identidade, fáceis de validar e reutilizar. Embeddables evitam JOINs desnecessários, centralizam lógica de validação e tornam o código mais expressivo.

Boas práticas rápidas

  • Use @Embeddable para objetos sem identidade.
  • Implemente equals/hashCode com todos os campos relevantes.
  • Prefira BigDecimal para Money e valide currency (ISO 4217).
  • Use @AttributeOverride para evitar colisão de nomes de coluna.
  • Trate embeddables como valores imutáveis: crie novos objetos para alterações.

Com esses passos você terá um modelo de domínio mais robusto, menos acoplado ao banco e mais alinhado aos princípios DDD básicos. Experimente transformar um conjunto de campos repetidos em um Embeddable e veja a manutenção ficar mais simples.

Se você estiver começando: implemente os exemplos acima, rode testes de integração contra H2 e revise os SQLs gerados pelo Hibernate para ganhar confiança sobre como o mapeamento se reflete no banco.

Resultado prático: mais clareza no domínio, menos código repetido, e mapeamentos previsíveis — tudo isso sem complicar seu schema relacional.

Assuntos: Java, spring-boot, jpa, embeddable, value-object

Leia também