JavaRush /Курсы /Spring Data JPA /Owning side: кто пиш...

Owning side: кто пишет связь в БД

Spring Data JPA
8 уровень , 2 лекция
Открыта

1. Термин owning side: смысл в коде

Если вы только что добавили @OneToMany и увидели коллекцию products внутри Category, мозг естественно делает вывод: «Ага, значит категория “владеет” товарами». И на бизнес-языке это правда. Но ORM живёт в двух мирах сразу — Java-объекты и SQL-таблицы — и там слово owning внезапно означает более скучную вещь: кто записывает внешний ключ. В этой лекции удобно переводить owning side как управляющая сторона связи. Это разговор не про жизненный цикл объектов и не про то, кто “главнее” в домене, а только про то, по какому полю JPA пишет FK в БД. Если это не понять сейчас, то дальше вы будете писать код, который выглядит правильно, компилируется, но в базе ведёт себя как кот: делает вид, что вас не существует.

Термин owning side (управляющая сторона связи) существует, чтобы ответить на один простой технический вопрос: какая сторона связи “официальная” для базы данных? То есть на изменения какой стороны JPA/Hibernate действительно ориентируется, когда решает, отправлять ли UPDATE ... SET fk = ... в БД.

Важно почувствовать разницу: двусторонняя связь в Java — это две ссылки (ссылка и коллекция), но в SQL чаще всего это один внешний ключ. Значит, и «владелец» на уровне БД чаще всего один. Кто удаляет кого вместе с кем — отдельный вопрос; здесь нас интересует только запись FK.

2. FK в одной таблице: SQL-модель owning side

Чтобы owning side перестал быть мистикой, давайте на минуту выдохнем и вернёмся к таблицам. Да, это тот самый момент, где ORM-программисту полезно вспомнить SQL и не делать вид, что “аннотация всё решит”. Внешний ключ — это обычная колонка в одной таблице. Он не размазан по двум таблицам «по справедливости». Он лежит там, где его положили.

Например, связь Category (1) -> Product (many) на SQL-уровне обычно выглядит так: в таблице product есть колонка category_id, которая ссылается на category.id. То есть внешний ключ хранится на стороне товара. Для заказов аналогично: в таблице order_item хранится customer_order_id, который указывает на customer_order.id.

Можно нарисовать себе простую схему (прямо как на салфетке в кафешке, где вы вдруг решили обсудить архитектуру — такое тоже бывает):

erDiagram
    CATEGORY ||--o{ PRODUCT : "product.category_id -> category.id"
    CUSTOMER_ORDER ||--o{ ORDER_ITEM : "order_item.customer_order_id -> customer_order.id"

Если выразить ту же мысль табличкой (чтобы мозг новичка не страдал), получится так:

Связь в домене FK-колонка в БД Где живёт FK Кто пишет FK в JPA
Product -> Category product.category_id в product поле Product.category
OrderItem -> CustomerOrder order_item.customer_order_id в order_item поле OrderItem.customerOrder

И вот это и есть фундамент: управляющая сторона связи — это сторона, которая соответствует месту, где в таблице реально лежит FK.

3. Правило для @ManyToOne / @OneToMany

Сейчас будет приятный момент: в нашем сегодняшнем наборе отношений правило очень простое. Мы не рассматриваем OneToOne и ManyToMany (это будет позже), поэтому вы можете держать в голове одну рабочую формулу:

В паре @ManyToOne / @OneToMany управляющая сторона связи — всегда там, где @ManyToOne.

Почему так? Потому что @ManyToOne сидит на стороне, где по смыслу есть FK-колонка: у товара есть category_id, у позиции заказа есть customer_order_id. А коллекция @OneToMany(mappedBy = "...") — это зеркало, удобная навигация, но не место, где записывается «официальная правда» про связь.

Посмотрите на минимальный каркас (в стиле нашего проекта shop-data-jpa).

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;

@Entity
class Product {

    @Id
    private Long id;

    // Управляющая сторона связи: именно это поле соответствует FK-колонке product.category_id
    @ManyToOne
    private Category category;
}

И на «зеркало» на стороне категории:

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;

import java.util.ArrayList;
import java.util.List;

@Entity
class Category {

    @Id
    private Long id;

    // Обратная сторона: коллекция отражает связь, но не управляет FK в БД
    @OneToMany(mappedBy = "category")
    private List<Product> products = new ArrayList<>(); // инициализируем, чтобы не ловить NPE
}

С точки зрения JPA фраза mappedBy = "category" означает: «Вот эта коллекция productsне владелец. Владелец там, где поле Product.category».

Это и есть главный смысл owning side: на какой стороне вы должны менять объектную ссылку, чтобы это превратилось в SQL-изменение FK.

4. Баг: меняем только inverse side — и связь “не сохраняется”

Давайте посмотрим на ситуацию, которую почти все проходят. Вы добавили Category.products, обрадовались коллекции и написали примерно такое (не в entity, а где-нибудь в сервисе или тесте):

// Меняем только inverse side (mappedBy) — FK в БД от этого не обязан меняться
category.getProducts().add(product);
categoryRepository.save(category);

На уровне человеческой логики кажется, что вы сделали всё: товар добавлен в категорию, значит категория теперь содержит товар. Но на уровне базы данных вы… возможно, не сделали ничего, потому что вы не меняли управляющую сторону связи, то есть product.category.

Hibernate мыслит примерно так: «Окей, у тебя есть Category.products, но она mappedBy. Ты мне просто показал коллекцию, которая отражает Product.category. А Product.category ты не трогал. Значит, category_id в таблице product менять не надо».

Чтобы почувствовать это на уровне поведения, полезно специально сделать мини-демо. Представьте, что товар уже лежит в одной категории, а вы хотите перенести его в другую.

Вот упрощённый фрагмент кода (например, в тесте или в CommandLineRunner для учебного эксперимента):

import java.math.BigDecimal;

// Категория, в которой товар уже находится
Category oldCategory = new Category();
oldCategory.setName("Books");
categoryRepository.save(oldCategory);

// Категория, в которую хотим перенести товар
Category newCategory = new Category();
newCategory.setName("Gadgets");
categoryRepository.save(newCategory);

// Товар уже сохранён и честно ссылается на oldCategory
Product p = new Product();
p.setName("Java 25 для смелых");
p.setPrice(new BigDecimal("19.99"));
p.setCategory(oldCategory);
productRepository.save(p);

// Пытаемся "перенести" товар только через inverse side новой категории
newCategory.getProducts().add(p);
categoryRepository.save(newCategory);

В памяти JVM у вас действительно newCategory.getProducts() теперь содержит p. Но в базе данных FK в таблице product может по-прежнему указывать на oldCategory, потому что вы не поменяли управляющую сторону связи, то есть p.setCategory(newCategory).

Правильное изменение, которое JPA считает “официальным”, выглядит вот так:

// Меняем управляющую сторону связи: именно это выражается в UPDATE по FK-колонке
p.setCategory(newCategory);
productRepository.save(p); // это про запись нового category_id в таблицу product

Если у вас включены SQL-логи (а мы их включали раньше), вы увидите, что во втором варианте появляется понятный SQL вроде:

-- FK обновляется в таблице product, потому что управляющая сторона связи — Product.category
update product set category_id = ? where id = ?

Если модель двусторонняя и обе коллекции уже живут в памяти, старую и новую стороны тоже придётся синхронизировать отдельно. И да, это немного обидно. Но зато честно: Hibernate не умеет читать ваши намерения. Он видит только правило владения связью и следует ему буквально.

5. Согласованность в памяти: вторая сторона не синхронизируется сама

После предыдущего раздела можно сделать поспешный вывод: «Отлично! Значит, я буду менять только owning side, и всё будет хорошо». На уровне базы данных — действительно да: если вы всегда выставляете product.setCategory(category), FK будет корректным. Но появляется другая, более “тихая” проблема: ваша объектная модель в памяти становится несогласованной.

Представьте, что у вас есть двусторонняя навигация, и вы в рамках одного use case делаете что-то вроде: «добавить товар в категорию, а потом посчитать, сколько товаров в категории». Если вы меняете только управляющую сторону связи, то Product.category уже указывает на категорию, но Category.products может не содержать этот товар — потому что JPA не обязан синхронизировать вторую сторону за вас. И тогда вы получаете веселое “почему размер списка не тот” прямо посреди сервиса.

Поймать это можно даже без базы, чисто в Java-логике:

// До связи коллекция пустая
System.out.println(c.getProducts().size()); // 0

// Меняем управляющую сторону: теперь товар "знает" про категорию
p.setCategory(c);

// Но inverse side сам не обновится, пока вы не сделаете это руками
System.out.println(c.getProducts().size()); // всё ещё 0
System.out.println(p.getCategory() == c);   // true

Комментарий к происходящему очень человеческий: «Вроде товар в категории, но категория про это не знает». И это нормально с точки зрения механики: управляющая сторона связи управляет БД, но двусторонняя модель требует дисциплины синхронизации в памяти.

И вот тут мы подходим к спасательному кругу — helper-методам.

6. Helper-методы для синхронизации обеих сторон

В идеальном мире вы бы каждый раз, когда связываете Category и Product, не забывали обновлять обе стороны. В реальном мире вы один раз забудете, второй раз забудете, а третий раз скажете: «Да ну его, давайте сделаем EAGER и JSON сериализацию…» — и где-то на горизонте уже маячит грустный архитектор.

Helper-метод — это маленький метод внутри сущности, который делает одно простое дело: синхронизирует обе стороны связи. Причём делает это в одном месте, чтобы вы не размазывали “ритуал” по сервисам, контроллерам и тестам.

Для Category -> Product логичный helper-метод обычно живёт в Category, потому что бизнес-смысл такой: «добавить товар в категорию». Но внутри он обязан обновить управляющую сторону связи, то есть product.category.

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;

import java.util.ArrayList;
import java.util.List;

@Entity
class Category {

    @Id
    private Long id;

    @OneToMany(mappedBy = "category")
    private List<Product> products = new ArrayList<>();

    public void addProduct(Product product) {
        // 1) Обновляем обратную сторону: чтобы в памяти категория "видела" товар
        products.add(product);

        // 2) Обновляем управляющую сторону связи: именно это отразится в FK-колонке в БД
        product.setCategory(this);
    }
}

Для Category -> Product в текущем mini-shop этого helper-метода достаточно, чтобы показать принцип. Мы держим Product.category обязательной, поэтому типичный сценарий здесь — добавить товар в категорию или переназначить его в другую, а не делать товар “без категории”. Разрыв связи через null можно встретить как общий JPA-приём, но для нашего текущего baseline это не основной path. Если товар переносится между двумя категориями и обе коллекции уже загружены в память, старую коллекцию тоже нужно обновить, иначе на время останутся две “правды”.

То же самое мы будем делать в CustomerOrder, когда добавляем позицию заказа. Да, бизнес-смысл — «заказ содержит позиции», но FK живёт на OrderItem, значит управляющая сторона связи — OrderItem.customerOrder.

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;

import java.util.ArrayList;
import java.util.List;

@Entity
class CustomerOrder {

    @Id
    private Long id;

    @OneToMany(mappedBy = "customerOrder")
    private List<OrderItem> items = new ArrayList<>();

    public void addItem(OrderItem item) {
        // В памяти: заказ "видит" позицию в своей коллекции
        items.add(item);

        // В БД: FK обновится только если мы поменяли управляющую сторону на OrderItem
        item.setCustomerOrder(this);
    }
}

Важно уловить методическую мысль: helper-методы — это не “красота” и не “DDD-ритуал”. Это способ сделать так, чтобы ваши сущности не могли попасть в полусвязанное состояние из-за забывчивости программиста (то есть нас с вами).

7. Owning side в заказах: связь и внешний ключ

С заказами у новичков часто возникает ещё более коварная путаница. На бизнес-языке CustomerOrder — явно “главный”, а OrderItem — “строчка”, “дочь”, “подчинённая часть”. И кажется логичным ожидать, что “главный” и будет управляющим. Но owning side — это не про власть в компании и не про то, кто кого уважает. Это про внешний ключ.

В SQL внешний ключ живёт в order_item.customer_order_id. Значит, управляющая сторона связи — OrderItem.customerOrder. Даже если вы проектируете заказ как единое целое, JPA будет обновлять FK именно когда меняется поле на OrderItem.

Хорошо видно это в коде сущности позиции:

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;

@Entity
class OrderItem {

    @Id
    private Long id;

    // Управляющая сторона связи: это поле соответствует FK order_item.customer_order_id
    @ManyToOne
    private CustomerOrder customerOrder;
}

И вот теперь становится понятно, почему «добавить item в order.getItems()» недостаточно: это изменение inverse side. Для базы данных это не команда «обнови FK». Команда «обнови FK» — это item.setCustomerOrder(order).

Поэтому helper-метод CustomerOrder.addItem() — не просто удобство, а реальная защита от расхождения модели.

И ещё один практический момент: если вы в какой-то момент захотите проверять инварианты заказа в памяти (например, «в заказе должна быть хотя бы одна позиция»), то несинхронизированная коллекция может привести к тому, что заказ кажется пустым, хотя по управляющей стороне связи позиция уже “прикреплена”. Это тот редкий случай, когда баг может быть не в базе, а в вашей голове: вы смотрите на объект, доверяете ему, а он “частично правдив”.

8. Типичные ошибки при понимании owning side

Ошибка №1: считать, что owning side — это “родитель” по бизнес-смыслу.
На бизнес-языке категория “содержит” товары, а заказ “содержит” позиции, и хочется объявить владельцем именно родителя. Но JPA определяет owning side по месту, где реально живёт внешний ключ. В ManyToOne/OneToMany управляющая сторона связи почти всегда на стороне @ManyToOne, то есть у “ребёнка”. Родитель может быть главным в домене, но это не делает его управляющим FK.

Ошибка №2: обновлять только коллекцию на inverse side и ожидать, что БД поменяется.
Добавить объект в category.getProducts() или order.getItems() приятно и читаемо, но если это сторона с mappedBy, то она лишь отражает связь. Hibernate не будет “угадывать”, что вы хотели обновить FK. Для базы данных значимым является изменение поля управляющей стороны: product.setCategory(category) или item.setCustomerOrder(order).

Ошибка №3: обновлять только owning side и потом удивляться, что коллекция “не видит” изменения в той же транзакции.
Если вы сделали product.setCategory(category), FK в БД будет корректным. Но category.getProducts() может остаться пустым прямо в памяти JVM до повторной загрузки. В результате бизнес-логика, которая опирается на коллекцию, начинает вести себя странно. Если у вас двусторонняя связь, синхронизация обеих сторон — обязанность вашего кода.

Ошибка №4: разносить синхронизацию связи по сервисам и тестам вместо helper-метода.
Когда “ритуал” из двух строк (products.add(p) и p.setCategory(this)) размазан по 12 местам, вы гарантированно где-то забудете одну из сторон. Helper-метод в entity — это способ сделать правильное поведение “по умолчанию”, а не надеяться на дисциплину и хорошее настроение всей команды.

Ошибка №5: путать “удаление из коллекции” и изменение FK на управляющей стороне.
products.remove(product) или items.remove(item) — это только операция над Java-коллекцией. Для базы данных важна управляющая сторона: у товара это product.setCategory(...), у позиции заказа — item.setCustomerOrder(...). В текущем mini-shop товар обычно не делают “без категории”, а переназначают другой категории; для заказа же связь нередко реально разрывают. Общая мысль одна: сама коллекция FK не переписывает.

1
Задача
Spring Data JPA, 8 уровень, 2 лекция
Недоступна
`addProduct()` синхронизирует обе стороны связи
`addProduct()` синхронизирует обе стороны связи
1
Задача
Spring Data JPA, 8 уровень, 2 лекция
Недоступна
`addItem()` обновляет управляющую сторону у позиции заказа
`addItem()` обновляет управляющую сторону у позиции заказа
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ