1. Когда плоская модель становится неудобной
Начнём с ощущения, которое почти все ловят на реальных проектах (обычно через 2–3 недели после старта): сначала кажется, что «поля — это просто поля», и можно спокойно добавлять их прямо в entity. Но затем у вас появляется сущность, где половина полей относится к одному смысловому блоку, другая — к другому, и читать такой код становится сложнее, чем инструкцию к микроволновке на японском, найденную в интернете в виде скана.
Представьте заказ. У заказа есть адрес доставки: город, улица, дом, индекс. Если мы сделаем модель плоской, то в CustomerOrder появятся deliveryCity, deliveryStreet, deliveryHouse, deliveryPostalCode. Технически это работает. Но по смыслу мы теряем идею «адрес — это одно понятие», и вместо понятия получаем россыпь строк. Чуть позже вы начнёте повторять эти поля в других местах, переименовывать их, забывать одно из четырёх — и у вас появится «адрес без дома», как «SQL без WHERE» (иногда допустимо, но чаще это аварийная кнопка).
Посмотрим на минимальный «плоский» вариант:
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class CustomerOrder {
@Id
private Long id; // Идентификатор заказа (в этом примере без генерации)
// Адрес доставки "размазан" по сущности отдельными полями
private String deliveryCity;
private String deliveryStreet;
private String deliveryHouse;
private String deliveryPostalCode;
}
Даже в таком коротком фрагменте видно, что адрес «размазан». А теперь представьте, что к заказу добавятся ещё «контакты получателя», «комментарий курьеру», «время доставки», и всё это опять будет строками и числами в одном месте. Получится entity-комбайн: вроде бы всё в одном классе, зато мозг при чтении кода работает как вентилятор на максималках.
Встраиваемые типы в JPA — это способ сказать: «Стоп. Этот набор полей — один смысловой объект». В Java мы хотим видеть order.getDeliveryAddress().getCity(), а не order.getDeliveryCity(). Не потому что «так моднее», а потому что так модель начинает говорить человеческим языком.
2. Value-like объект и @Id
Перед тем как писать аннотации, важно разобраться с идеей. Value-like объект (его часто называют value object, хотя мы сейчас без фанатизма) — это часть модели, которая не живёт отдельно и не имеет собственной самостоятельной идентичности. У неё нет «своего жизненного пути», как у Product или Category. Она осмысленна только как часть владельца: адрес доставки осмыслен как часть заказа, но в нашем учебном домене мы не хотим обращаться к адресу «сам по себе», сохранять его отдельной командой или иметь на него отдельные связи.
Это очень похоже на реальную жизнь. Адрес, записанный в заказе, — это «снимок» адреса на момент оформления. Он не обязан быть тем же объектом, что «адрес пользователя в профиле» (которого у нас вообще нет, потому что проект сознательно без security и аккаунтов). И уж точно мы не хотим, чтобы адрес жил своей самостоятельной сущностью, на которую можно «сослаться» по id, как на товар. У адреса нет смысла «быть тем самым адресом №42».
В терминах JPA это означает простое правило: если объект не должен иметь собственной таблицы и собственного @Id, а должен храниться внутри таблицы владельца, то он отлично подходит под @Embeddable.
Важно не перепутать критерии. Если вы чувствуете, что объект должен жить отдельно, иметь независимый жизненный цикл, отдельные изменения, отдельные связи и отдельную уникальность, то это уже кандидат в @Entity (и это будет тема следующих дней, когда мы дойдём до связей). Но сегодня мы сознательно остаёмся в зоне «часть модели, а не отдельная сущность».
Чтобы не держать это абстракцией, давайте сразу привяжемся к нашему mini-shop. В домене проекта у CustomerOrder есть deliveryAddress. Он идеально ложится в модель «value-like объект внутри заказа». Мы хотим сделать отдельный класс DeliveryAddress, но не хотим отдельную таблицу delivery_address.
3. @Embeddable: как объявить встраиваемый тип
Теперь перейдём к тому, как JPA это видит. @Embeddable говорит фреймворку: «Этот класс — часть persistence-модели, но он не entity. Его поля будут храниться как колонки в таблице владельца». С точки зрения новичка это звучит почти как «магия», но на самом деле это очень честная сделка: вы даёте JPA структуру, а JPA раскладывает её по колонкам.
Плюс @Embeddable очень хорошо дисциплинирует мышление. Если вы ставите @Embeddable, вы как бы подписываете контракт: «Я не ожидаю, что этот объект будет сохраняться отдельно. Я не ожидаю, что у него будет id. Я не ожидаю, что он будет существовать без владельца». И это сразу снимает половину архитектурных метаний.
Минимальные требования к @Embeddable
С практической стороны @Embeddable — это почти такой же «JPA-объект», как и entity: ему нужен конструктор без параметров (обычно protected), поля должны быть доступными для Hibernate (через reflection), и типы полей должны быть маппируемыми (строки, числа, даты и т. п.). Мы не делаем встраиваемый объект «тяжёлым», не пихаем туда лишнюю инфраструктуру и не превращаем его в DTO.
Обратите внимание на одну важную мысль, которую легко пропустить: @Embeddable — это не просто «класс без аннотаций». Он часть persistence-модели. То есть его поля тоже попадают в SQL и в схему. Разница только в том, что он не имеет @Id и не является отдельной сущностью в базе.
DeliveryAddress: первый embeddable в нашем mini-shop
Сделаем DeliveryAddress в пакете ordering. Мы пока не строим полный модуль заказов с позициями — нам нужна только форма адреса как «кусочек модели», чтобы продемонстрировать embedded-подход.
package com.example.shopdatajpa.ordering.entity;
import jakarta.persistence.Embeddable;
@Embeddable // Говорим JPA: это встраиваемый тип, а не отдельная entity
public class DeliveryAddress {
// Эти поля станут колонками в таблице владельца (например, customer_order)
private String city;
private String street;
private String house;
private String postalCode;
protected DeliveryAddress() {
// Конструктор без параметров нужен JPA/Hibernate для создания объекта через reflection
}
}
Сейчас класс выглядит максимально просто: только поля и protected-конструктор для JPA. На практике вы добавите конструктор «для людей» (с параметрами) и, скорее всего, геттеры. Но методически нам важно сначала увидеть базовую механику: класс отмечен как встраиваемый и не имеет @Id.
Если вы хотите сделать его более удобным (и в рамках учебного проекта это нормально), можно добавить «человеческий» конструктор. Он сильно повышает читаемость кода, когда вы создаёте заказ:
import java.util.Objects;
public DeliveryAddress(String city, String street, String house, String postalCode) {
// Минимальная защита от "случайных null" прямо на входе
this.city = Objects.requireNonNull(city);
this.street = Objects.requireNonNull(street);
this.house = Objects.requireNonNull(house);
this.postalCode = Objects.requireNonNull(postalCode);
}
Здесь мы сразу делаем маленькую полезную вещь: защищаемся от случайного null. Это не «валидация всего мира», но уже минимальная дисциплина. Адрес без города и улицы — обычно не адрес, а загадка.
4. @Embedded в entity
Когда у нас есть @Embeddable, нужно сказать JPA: «Вот это поле в entity — встраиваемое, разложи его на колонки». Для этого используется @Embedded. Это аннотация на поле (или геттер), где хранится ваш value-like объект.
Очень важно понимать, что @Embedded не делает «связь между таблицами». Это не JOIN. Это именно встраивание. То есть в таблице владельца будут колонки для каждого поля DeliveryAddress. В Java же у вас будет один объект DeliveryAddress. Это как аккуратно упаковать четыре колонки в один смысловой блок.
Ниже возьмём минимальный CustomerOrder, только чтобы увидеть сам факт embedding. Это не попытка зафиксировать окончательный код заказа в проекте; здесь нам важен именно принцип.
Мини-entity CustomerOrder с embedded-адресом
Сделаем упрощённый CustomerOrder. Без позиций заказа, без связей и без усложнений: нам сейчас важна форма модели.
package com.example.shopdatajpa.ordering.entity;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class CustomerOrder {
@Id
private Long id; // Для схематичного примера id оставляем без генерации: здесь важен сам факт @Embedded
@Embedded // Встраиваем DeliveryAddress: его поля станут колонками в таблице заказов
private DeliveryAddress deliveryAddress;
}
Да, пока id без генерации — это нормально для учебного кусочка, который иллюстрирует embedding. В реальном проекте вы используете ту же стратегию генерации, что и в других сущностях (вы уже разбирали это на Дне 5). Важно другое: теперь CustomerOrder читабельнее как модель. У заказа есть адрес доставки как единый объект.
При желании @Embedded можно воспринимать ещё и как «аннотацию для читателя». Даже если JPA и без неё поймёт, что тип — embeddable, аннотация делает код очевиднее. Для учебного проекта это плюс: вы читаете класс и сразу видите, что это встраиваемый кусок модели.
5. Embedded и колонки в таблице
Сейчас будет тот момент, где полезно включить «SQL-мышление», которое мы в начале курса специально освежали. На уровне Java всё красиво: CustomerOrder содержит DeliveryAddress. Но в реляционной базе нет вложенных объектов. Там есть таблица и колонки. Значит, JPA должна сделать «распаковку» embedded-объекта в набор колонок.
Представьте это так: в Java у вас есть коробка DeliveryAddress, в которой лежат четыре значения. В БД коробки хранить нельзя, поэтому коробку разбирают на детали и кладут на полочки (колонки). При чтении — собирают обратно.
Вот схематично (и да, это легальный повод для мини-диаграммы):
flowchart TD
CO["CustomerOrder (entity)"]
DA["deliveryAddress: DeliveryAddress (@Embeddable)"]
T["customer_order (table)"]
CO --> DA
DA --> C["delivery_address_city (column)"]
DA --> S["delivery_address_street (column)"]
DA --> H["delivery_address_house (column)"]
DA --> P["delivery_address_postal_code (column)"]
C --> T
S --> T
H --> T
P --> T
И вот здесь вы можете почувствовать главный плюс embedded-подхода: никаких дополнительных таблиц и join-ов для адреса. Адрес — часть заказа, и он лежит в той же строке таблицы customer_order.
На практике это означает, что при вставке заказа будет один INSERT, и в нём будут все адресные колонки. Примерно так (очень примерно, потому что точные имена колонок зависят от naming strategy):
-- Один INSERT: адресные поля лежат в той же строке, что и заказ
insert into customer_order
(delivery_address_city, delivery_address_street, delivery_address_house, delivery_address_postal_code, id)
values
(?, ?, ?, ?, ?);
6. Имена колонок и @AttributeOverride
Есть один нюанс, который вы заметите быстро: дефолтные имена колонок для embedded-полей иногда получаются… своеобразными. Обычно это комбинация имени поля и имени свойства внутри embeddable. С точки зрения машины — нормально. С точки зрения человека, который открыл таблицу в psql и пытается не плакать — иногда хочется попроще.
И вот здесь появляется полезный инструмент: @AttributeOverride (или @AttributeOverrides, если их несколько). Он позволяет сказать: «Поле city внутри deliveryAddress хранить в колонке delivery_city», и т. д.
Это не про «красоту ради красоты». Это про то, что схема БД — часть проекта, и её тоже читают люди. Особенно когда в логах или в данных нужно разобраться быстро. И тут важно помнить: @AttributeOverride — один из вариантов именования, а не обязательный baseline проекта.
Вот пример, как можно сделать более человеческие имена колонок (да, кода больше, но зато понятнее):
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
@Embedded
// Переопределяем имена колонок для полей внутри embedded-объекта
@AttributeOverride(name = "city", column = @Column(name = "delivery_city"))
@AttributeOverride(name = "street", column = @Column(name = "delivery_street"))
@AttributeOverride(name = "house", column = @Column(name = "delivery_house"))
@AttributeOverride(name = "postalCode", column = @Column(name = "delivery_postal_code"))
private DeliveryAddress deliveryAddress; // В Java это один объект, в БД — несколько колонок
Смысл простой: вы переименовали колонки для embedded-полей прямо на стороне владельца. Так JPA знает, как их класть в таблицу, и вы получаете схему, которую проще воспринимать глазами.
Ту же задачу можно решить и через явные @Column(name = ...) внутри самого DeliveryAddress; здесь @AttributeOverride важен именно как инструмент, а не как единственный обязательный стиль.
Здесь важно держать методическую границу. Да, можно настраивать embedded-маппинг глубже, можно переиспользовать один и тот же embeddable несколько раз в одной entity, можно делать сложные overrides — но это всё легко превращается в «курс по JPA-аннотациям». Мы сейчас делаем базовый, одинарный кейс и фиксируем принцип.
7. Мини-сценарий: заказ с адресом
Чтобы идея стала осязаемой, возьмём нарочно схематичный сценарий. Никаких репозиториев здесь не нужно: нам достаточно увидеть, что сохраняется владелец, а embedded-объект едет вместе с ним.
Код создания будет выглядеть примерно так:
// 1) Создаём value-like объект: сам по себе он не сохраняется
DeliveryAddress address =
new DeliveryAddress("Moscow", "Tverskaya", "1", "125009");
// 2) Создаём владельца (entity) и "вкладываем" в него адрес
CustomerOrder order = new CustomerOrder();
order.setId(1L); // В схематичном примере задаём id вручную: здесь важен не способ генерации, а сам факт embedding
order.setDeliveryAddress(address); // Embedded-объект живёт внутри заказа
Здесь ключевое не то, откуда взялся id, а то, что отдельного persist(address) не нужно. Так и должно быть. Адрес сохраняется внутри заказа. Адрес — это часть данных заказа.
Если в этот момент мозг пытается спросить: «А как же мне потом найти адрес отдельно?» — это отличный вопрос… и отличный индикатор того, что, возможно, адрес в вашей предметной области не должен быть embedded. Но в нашем проекте адрес внутри заказа — именно снимок для доставки, и отдельно нам его искать не нужно. Мы ищем заказ, и внутри него читаем адрес.
Если включить SQL-логи и сохранить заказ, вы увидите один INSERT в таблицу заказов, в котором будут колонки адреса. То есть embedded-объект «развернулся» в набор колонок, но модель в Java осталась красивой и смысловой.
8. Когда подходит @Embeddable
Сейчас хочется дать вам «правило на все случаи жизни», но честнее дать понятные ориентиры. @Embeddable идеально работает, когда вы хотите улучшить читаемость модели и собрать «группу полей в одно понятие», при этом эти поля должны жить в той же таблице, что и владелец, и не требуют отдельной жизни.
Чтобы не превращать это в список заповедей, давайте сравним @Entity и @Embeddable через короткую таблицу. Она полезна именно как «проверка здравого смысла», а не как формальная теория.
| Признак | @Entity | @Embeddable |
|---|---|---|
| Есть ли собственная идентичность (@Id)? | Да | Нет |
| Можно ли сохранять отдельно? | Да | Нет, только вместе с владельцем |
| Обычно отдельная таблица? | Да | Нет, колонки в таблице владельца |
| Самостоятельный жизненный цикл? | Да: можно создать/удалить отдельно | Нет: живёт как часть владельца |
| Хороший пример в проекте | Product, Category | DeliveryAddress внутри CustomerOrder |
И теперь «человеческое» объяснение выбора. Если вы видите, что объект — это скорее «структура данных», которая всегда идёт рядом с владельцем, меняется вместе с ним и в базе логично хранится в одной строке — embedded отлично подходит. Если же объект начинает просить отдельные операции, отдельные права на изменение, отдельные связи или отдельные запросы «покажи мне все адреса» — вы почти наверняка уже смотрите в сторону @Entity (но это мы будем делать позже и аккуратно, без перескоков).
Отдельно отмечу один частый junior-паттерн: «поля стало много — сделаем новый класс». Это иногда полезно, но иногда вы просто прячете проблему, а не решаете её. Если вы не можете одним словом назвать, что это за класс, и зачем он существует как единое понятие, то @Embeddable превращается в «склад случайных полей» — только в отдельной коробке.
9. Типичные ошибки с embedded-типами
Ошибка №1: путать @Embeddable с отдельной сущностью и пытаться “сохранить адрес отдельно”.
Если рука тянется написать что-то вроде entityManager.persist(address) или «создать репозиторий для адресов», это почти всегда означает, что вы в голове сделали DeliveryAddress entity. Но @Embeddable специально нужен для противоположного: адрес не живёт отдельно, он сохраняется внутри владельца, и отдельного id у него быть не должно.
Ошибка №2: делать embedded-объект “мешком из всего подряд” без смысла.
Иногда кажется, что @Embeddable — это способ спрятать лишние поля, чтобы entity выглядела короче. Но цель не в длине класса, а в смысле. Если вы выделили DeliveryAddress, но внутри него оказались «адрес + email + комментарий курьеру + два флага», это уже не value-объект, а коробка с проводами. Такой код сначала кажется аккуратным, а потом становится загадочным.
Ошибка №3: оставлять колонки с непредсказуемыми или неудобными именами и удивляться, что SQL “не читается”.
По умолчанию embedded-поля превращаются в колонки с составными именами, и это нормально. Но если вы уже видите, что схема будет жить и в логах, и в данных, лучше один раз потратить время на @AttributeOverride, чем потом часами разгадывать, что такое delivery_address_postal_code и почему оно написано именно так. База данных — тоже читаемый артефакт.
Ошибка №4: считать, что @Embeddable — это DTO.
Очень хочется сказать: «Ну это же просто класс с полями, значит DTO». Но embedded-тип — часть persistence-модели. Он участвует в маппинге, влияет на структуру таблицы и должен жить по правилам JPA (например, иметь no-args constructor). DTO — это объект для передачи данных между слоями. В следующей лекции мы отдельно закрепим эту границу, но уже сейчас важно не смешивать роли.
Ошибка №5: пытаться обсуждать сложные кейсы (повторное встраивание, сложные overrides, вложенные embeddables) раньше времени.
Встраиваемые типы могут быть достаточно мощными, и там есть много нюансов. Но если вы пытаетесь решить всё сразу, вы рискуете забыть главную идею: embedded нужен, чтобы модель стала понятнее и ближе к доменным понятиям. Начните с простого и осмысленного DeliveryAddress внутри CustomerOrder. Когда этот паттерн станет для вас естественным, дальше будет куда легче разбираться с более сложными вариантами.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ