1. @Embeddable: доменное значение вместо примитивов
Мы уже отделили объекты с собственной жизнью от значений, которые живут внутри владельца. Теперь нужно понять, как такие значения хранить так, чтобы не плодить отдельные таблицы, id и связи там, где они вообще не нужны.
Если вы когда‑нибудь видели в коде что-то вроде priceAmount и priceCurrency, разбросанное по пяти сущностям, то вы уже знакомы с этим ощущением: вроде всё “работает”, но модель выглядит так, будто её собирали из LEGO в темноте. Сегодня мы как раз обсуждаем способ сделать модель понятнее: не добавлять “ещё одну entity”, а выделить одно доменное значение и аккуратно замаппить его.
Проблема примитивных полей редко бывает “в количестве колонок”. Обычно она в том, что в коде исчезает смысл. BigDecimal сам по себе — это просто число. String currency сам по себе — просто строка. А вот “деньги” — это число в конкретной валюте, и эти два поля почти всегда должны жить вместе и изменяться как единое целое. Если они живут отдельно, то в реальном проекте неизбежно появятся ситуации, когда сумма обновилась, а валюта — нет (или наоборот). И это не “ошибка Hibernate”, это естественная цена модели, где смысл размазан по примитивам.
У адреса история похожая, только длиннее. Адрес — не просто набор строк “город/улица/дом”. Это одно значение, которое удобно передавать целиком, хранить целиком и менять целиком. И иногда адрес — это “текущий адрес клиента”, а иногда — “снимок адреса доставки заказа в момент оформления”. С точки зрения домена это разные смыслы, но технически их удобно описывать одним типом Address.
И тут появляется хороший вопрос: если Money и Address — value object, как их хранить в БД так, чтобы не превращать их в отдельные таблицы с id, @OneToMany, каскадами и вечной болью “почему опять лишний join”? Ответ — @Embeddable.
2. @Embeddable и @Embedded: flattening в таблицу
Когда вы впервые видите @Embeddable, есть риск подумать: “Ага, это типа entity, только маленькая”. Нет. Это скорее “кусок сущности”, который Hibernate умеет хранить вместе с владельцем в той же таблице. И это ключевая мысль: embeddable по умолчанию не имеет своей строки в БД и не имеет своего lifecycle.
В JPA‑терминах мы описываем класс‑значение аннотацией @Embeddable, а потом встраиваем его в сущность аннотацией @Embedded. Примерно так:
import jakarta.persistence.Embeddable;
@Embeddable
public class Money {
// поля, которые будут храниться в таблице владельца
}
А затем в entity:
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
@Entity
public class Product {
@Embedded
private Money price;
}
Полезно держать в голове “расплющивание” (flattening) как простую картинку:
Product (entity)
└── price: Money (embeddable)
├── amount
└── currency
↓ в таблице product это становится просто колонками
product
├── id
├── ...
├── price_amount
└── price_currency
Здесь нет money‑таблицы, нет money_id, нет JOIN. Hibernate просто читает и пишет колонки владельца.
Чтобы не спутать @Embeddable с entity, удобно сравнить в таблице:
| Что сравниваем | |
|
|---|---|---|
| Есть @Id | Да | Нет |
| Есть отдельная таблица | Да | Нет (по умолчанию) |
| Можно загрузить отдельно (find) | Да | Нет |
| Есть собственный lifecycle | Да | Нет, живёт внутри владельца |
| Влияет на SQL как отдельный JOIN | Часто да | Нет (всё в одной строке) |
И ещё один важный момент: если entity — это “персонаж”, который может жить отдельно, то embeddable — это “характеристика персонажа”. У неё нет смысла без владельца. Деньги без товара — это просто “какие-то деньги”. Адрес без “чего именно это адрес” — тоже не очень полезен.
3. Money как @Embeddable в проекте
Сейчас мы сделаем Money частью общего модуля проекта, чтобы он мог использоваться в каталоге и заказах. В структуре сквозного проекта для таких вещей у нас есть пакет com.example.commerce.common.jpa.embeddable. Туда и положим Money.
Начнём с очень простого варианта: сумма (BigDecimal) и валюта (String). Сразу добавим защищённый конструктор без аргументов — он нужен Hibernate для создания объекта при загрузке из БД. Да, мы снова пишем “странный” конструктор, который никогда не вызываем руками. Добро пожаловать в ORM: иногда он просит сделать что-то “для него”, как кот просит открыть дверь, чтобы сразу передумать.
package com.example.commerce.common.jpa.embeddable;
import jakarta.persistence.Embeddable;
import java.math.BigDecimal;
@Embeddable
public class Money {
// Эти поля будут "расплющены" в колонки таблицы владельца (например, product.price_amount и product.price_currency)
private BigDecimal amount;
private String currency;
// Нужен Hibernate/JPA для создания объекта при чтении из БД (через reflection)
protected Money() { }
public Money(BigDecimal amount, String currency) {
// Здесь можно добавлять инварианты домена (например, amount >= 0, валюта по ISO и т.п.)
this.amount = amount;
this.currency = currency;
}
}
Если вы сейчас спросите: “А где getAmount()?” — он будет. Я просто не хочу превращать лекцию в шоу “1000 строк геттеров”. Но смысл такой: embeddable — обычный Java‑класс, поэтому API у него может быть нормальным и выразительным. И да, для value object равенство обычно по значениям полей, а не по ссылке. Это мы обсудим уже по пути, но даже на интуитивном уровне это логично: 10 USD и 10 USD — одно и то же значение, даже если это два разных объекта в памяти.
Важное ограничение, которое стоит проговорить вслух: у Money не будет @Id. Его нельзя “искать в репозитории”. Его нельзя “удалять отдельно”. Он существует только внутри владельца. Поэтому мы делаем Money простым, компактным и очень ясным.
4. Product.price: Money в сущности и схеме
Теперь давайте возьмём центральную сущность каталога — Product — и сделаем цену нормальным доменным значением, а не парой примитивов. В наших примерах будем показывать только нужные поля, чтобы не утонуть в “и ещё 15 колонок, которые у вас уже есть”.
В Product добавляем поле price с аннотацией @Embedded:
package com.example.commerce.catalog.entity;
import com.example.commerce.common.jpa.embeddable.Money;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class Product {
@Id
private Long id;
// Встраиваем value object Money в таблицу Product (никаких JOIN — всё лежит в строке product)
@Embedded
private Money price;
// Hibernate требуется конструктор без аргументов для создания сущности при загрузке
protected Product() { }
}
С точки зрения Java‑кода это выглядит почти слишком просто: добавили поле — и всё. Но у схемы есть практический момент: точные имена колонок зависят от naming strategy и явных override'ов. Ниже я беру обычный схематичный вариант price_amount/price_currency; если эти имена важны для миграций и SQL-лога, их лучше фиксировать явно, а не оставлять на implicit naming.
Представим, что раньше у вас были price_amount и price_currency как отдельные колонки, или вы только сейчас их вводите. В миграции (условно Vx__product_money.sql) это будет выглядеть примерно так:
ALTER TABLE product
-- Колонки под поля embeddable Money
ADD COLUMN price_amount numeric(19, 2),
ADD COLUMN price_currency varchar(3);
Если у вас уже есть данные и вы хотите сделать NOT NULL, то миграция должна быть чуть аккуратнее (с дефолтом, обновлением существующих строк и т.д.). Но идея на этом этапе не в миграционном искусстве, а в том, чтобы понять: embeddable не создаёт таблицу, он просто требует, чтобы в таблице владельца были колонки под его поля.
Чтобы связать “Java‑модель” и “БД‑модель” в голове, полезна маленькая табличка соответствий:
| Java | Таблица product |
|---|---|
| product.price.amount | price_amount |
| product.price.currency | price_currency |
И самое приятное: когда вы грузите Product, Hibernate делает один SELECT из product и получает все поля Money вместе с остальными колонками. Никаких дополнительных запросов и join’ов. С точки зрения SQL это схематично выглядит так:
select
p.id,
p.price_amount,
p.price_currency
from product p
where p.id = ?
То есть Money не “подгружается”. Он не “ленивый”. Он просто часть строки товара, как name или sku. В этом месте ORM действительно делает то, что от него ожидают: помогает работать с моделью, не создавая лишней сложности в базе.
5. Address как @Embeddable: один класс, много смыслов
С адресом немного страшнее из‑за количества полей. Когда полей много, у новичка появляется соблазн либо сделать “адресную таблицу” и всё связать, либо махнуть рукой и оставить в entity 8 строковых колонок. Мы попробуем третий путь: выделить адрес как value object, но хранить его в таблице владельца — ровно так же, как Money.
Сделаем Address тоже в com.example.commerce.common.jpa.embeddable. В проектном ТЗ у адреса есть страна, город, индекс, улица, дом, квартира. В примере покажу компактно, но идея сохраняется:
package com.example.commerce.common.jpa.embeddable;
import jakarta.persistence.Embeddable;
@Embeddable
public class Address {
// Эти поля "встраиваются" в таблицу владельца (customer_address или purchase_order)
private String country;
private String city;
private String street;
// Нужен JPA для реконструкции объекта из данных строки таблицы
protected Address() { }
public Address(String country, String city, String street) {
// Здесь обычно живут доменные проверки: пустые строки, формат и т.п.
this.country = country;
this.city = city;
this.street = street;
}
}
Почему это value object, а не entity? Потому что адрес сам по себе не имеет “отдельной судьбы” в нашей модели. Он либо описывает “куда доставить заказ”, либо описывает “где живёт клиент”. Мы не ищем адрес “по id”, не делаем “CRUD адресов” как самостоятельный продукт, не хотим отдельной транзакции “обновить адрес как сущность”. Адрес — часть состояния владельца.
Здесь важен ещё один нюанс: один и тот же Address‑класс может использоваться в двух местах с разным смыслом. У CustomerAddress это “текущий адрес клиента”, а у PurchaseOrder — это “снимок адреса доставки”. Технически тип один, а семантика определяется полем владельца. И это нормально: тип — это форма, а поле — это роль.
6. Address в CustomerAddress и PurchaseOrder
Давайте встроим Address в две сущности, которые уже есть в нашем проекте: CustomerAddress и PurchaseOrder. Это как раз то место, где студент часто “слетает” в путаницу: “Если класс один, значит это одно и то же значение?”. Нет. Это два разных значения, просто одинакового типа.
CustomerAddress
package com.example.commerce.customer.entity;
import com.example.commerce.common.jpa.embeddable.Address;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class CustomerAddress {
@Id
private Long id;
// Текущий адрес клиента: хранится в той же строке customer_address
@Embedded
private Address address;
// JPA-конструктор
protected CustomerAddress() { }
}
Теперь в таблице customer_address появятся колонки под поля адреса. По naming strategy это часто выглядит как address_country, address_city, address_street; если exact names важны, их лучше зафиксировать явно через @AttributeOverride. Миграция будет выглядеть примерно так:
ALTER TABLE customer_address
-- Колонки под embedded Address (префикс address_)
ADD COLUMN address_country varchar(100),
ADD COLUMN address_city varchar(100),
ADD COLUMN address_street varchar(200);
PurchaseOrder
package com.example.commerce.orders.entity;
import com.example.commerce.common.jpa.embeddable.Address;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class PurchaseOrder {
@Id
private Long id;
// Адрес доставки как "снимок" на момент оформления заказа (тоже embedded)
@Embedded
private Address deliveryAddress;
// JPA-конструктор
protected PurchaseOrder() { }
}
В таблице purchase_order появятся колонки вида delivery_address_country, delivery_address_city, delivery_address_street. Если exact naming важен для схемы и SQL-лога, это тоже лучше фиксировать явно, а не надеяться только на имплиситные правила. Схематичная миграция:
ALTER TABLE purchase_order
-- Колонки под embedded Address (префикс delivery_address_)
ADD COLUMN delivery_address_country varchar(100),
ADD COLUMN delivery_address_city varchar(100),
ADD COLUMN delivery_address_street varchar(200);
На уровне SQL всё выглядит предельно честно: таблицы расширились колонками, но не появилось новых таблиц, внешних ключей и join’ов. По скорости и предсказуемости чтения это обычно очень выгодно, особенно когда адрес нужен “всегда вместе с заказом”, а не отдельным запросом.
Чтобы закрепить “один тип — разные роли”, представьте схему так:
CustomerAddress (entity)
└── address: Address // текущий адрес клиента
PurchaseOrder (entity)
└── deliveryAddress: Address // снимок адреса доставки для заказа
Это разные поля и разные наборы колонок в разных таблицах, даже если Java‑класс один. В ORM это абсолютно нормальная ситуация: один embeddable‑тип может участвовать в разных сущностях.
7. Поведение embeddable в коде и запросах
На этом этапе важно закрепить пару “жёстких” свойств embeddable, чтобы дальше не строить ожиданий, которые Hibernate не обязан выполнять.
Во‑первых, embeddable — это часть состояния entity. Это означает, что он участвует в загрузке и сохранении владельца. Если вы сохранили Product, вы сохранили и price. Если вы загрузили PurchaseOrder, вы загрузили и deliveryAddress (потому что это те же колонки той же строки). Никаких отдельных persist() для Money и Address не существует — и это хорошо: меньше способов случайно сделать “полуправильный” код.
Во‑вторых, embeddable нельзя лениво подгрузить так же, как связь @ManyToOne. Если колонка в той же таблице — она в любом случае в этом же SELECT. Иногда это воспринимают как минус (“не могу lazy!”), но на практике это плюс: адрес и деньги — это обычно небольшие данные, и их выгоднее иметь сразу, чем устраивать дополнительные round‑trip’ы в БД. @Embeddable — это почти всегда про “данные маленькие и всегда нужны вместе”.
В‑третьих, по полям embeddable можно писать запросы. Это удобно и читаемо. Например, если вы хотите найти все товары в конкретной валюте, вы можете обратиться к price.currency прямо в JPQL:
package com.example.commerce.catalog.repository;
import com.example.commerce.catalog.entity.Product;
import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
public interface ProductRepository extends Repository<Product, Long> {
// JPQL обращается к вложенному полю embeddable через точку (price.currency)
// В SQL это превратится в условие по колонке price_currency
@Query("select p from Product p where p.price.currency = :currency")
List<Product> findByCurrency(String currency);
}
И это будет транслировано в SQL‑условие по колонке price_currency. Никакой магии: просто нормальный путь “через точку”.
И последнее, но важное: embeddable не должен превращаться в “свалку полей”. Если вы видите желание добавить в Address ещё “всё подряд” (например, phone, deliveryComment, geoLat, geoLng, “а ещё код домофона”), остановитесь и спросите себя: это точно одно значение? Или вы склеиваете разные смыслы в одну коробку ради удобства? Embeddable хорош ровно до тех пор, пока он остаётся цельным доменным значением и не превращается в свободно мутируемую коробку случайных деталей.
8. Типичные ошибки при маппинге @Embeddable
Ошибка №1: ожидать, что @Embeddable создаст отдельную таблицу или будет жить как сущность.
Это частая “переноска привычек”: раз есть аннотация, значит “ORM сам всё сделает”. Embeddable не создаёт отдельной сущности и отдельной таблицы. Он расплющивается в колонки владельца. Если вам нужен отдельный lifecycle, отдельные запросы и отдельное “существование” — это уже entity, а не embeddable.
Ошибка №2: забыть про конструктор без аргументов и начать воевать с Hibernate.
Hibernate создаёт объект через reflection. Если у Money или Address нет protected/package-private конструктора без аргументов, загрузка из БД может закончиться очень некрасивой ошибкой в рантайме. Ирония в том, что вы “всё правильно” сделали с точки зрения чистого OOP, но ORM‑инструменту всё равно нужно место, куда он поставит ногу.
Ошибка №3: превращать embeddable в «помойку всего, что жалко выкинуть».
Embeddable должен быть цельным значением. Если в него складывать несвязанные поля “для красоты модели”, вы получите value object без value‑смысла. Обычно это заканчивается тем, что вам потом сложно писать запросы, сложно понимать, почему обновилось именно это поле, и вообще непонятно, что такое этот объект в домене.
Ошибка №4: пытаться “отдельно сохранять” или “отдельно удалять” Money/Address.
Если рука тянется сделать MoneyRepository или вызвать entityManager.persist(money), это сигнал, что в голове value object перепутался с entity. Embeddable сохраняется только как часть владельца. Отдельной операции сохранения для него нет, и это правильно: иначе value object внезапно получил бы независимый lifecycle, а значит перестал бы быть value object.
Ошибка №5: запутаться в смысле одного и того же embeddable‑класса в разных местах.
Address в CustomerAddress и Address в PurchaseOrder — это один класс, но разные роли. Если вы начнёте думать, что это “одно и то же значение” и попытаетесь переиспользовать один объект адреса между сущностями, модель станет хрупкой и неочевидной. Один тип — не означает один смысл; смысл задаёт поле владельца и его бизнес‑контекст.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ