JavaRush /Курсы /Hibernate deep-dive /Рефакторинг цены и адреса в value objects

Рефакторинг цены и адреса в value objects

Hibernate deep-dive
14 уровень , 4 лекция
Открыта

1. Что рефакторим и в чём польза

Когда проект растёт, примитивные поля начинают вести себя как носки в стиральной машине: вроде бы вы их туда положили парой, а на выходе они почему-то живут раздельно. Цена товара превращается в priceAmount и priceCurrency в одной сущности, потом в itemPriceAmount и itemPriceCurrency в другой, а затем ещё и в totalAmount и totalCurrency в третьей — и внезапно вы замечаете, что полпроекта занимается тем, что «передаёт две переменные рядом и надеется, что их не перепутают».

В нашем Commerce Persistence Lab это уже видно по двум группам полей: деньги и адрес. Решение про value object для них уже принято. Ниже нас интересует не повтор общей теории, а сам маршрут рефакторинга: какие поля меняются, как меняется API сущностей и когда нужна миграция.

Чтобы было совсем наглядно, сравним «как было» и «как станет» в одном маленьком табличном снимке:

Где Было (примитивы) Станет (value object)
Product priceAmount + priceCurrency Money price
OrderItem priceAtPurchaseAmount + priceAtPurchaseCurrency Money priceAtPurchase
PurchaseOrder totalAmount + totalCurrency Money totalAmount
CustomerAddress city + street + house + ... Address address
PurchaseOrder deliveryCity + deliveryStreet + ... Address deliveryAddress

У этого маршрута две технические ветки. Multi-column значения, вроде Money и Address, уходят в @Embeddable. One-column типы, вроде статуса или SKU, живут через @Enumerated и AttributeConverter. Ниже подробно собираем первую ветку, потому что именно в ней сейчас лежат цена и адрес.

2. Где хранить Money и Address

Перед тем как писать код, важно договориться об аккуратной географии. Если Money лежит в catalog, а Address — в orders, то завтра вы захотите использовать Money в promotion, и начнутся грустные импорты, зависимости «фича на фичу» и ощущение, что проект собирается на честном слове.

В нашем проекте уже есть место для общих JPA-штук: пакет com.example.commerce.common.jpa. Логично сделать там подпакеты для embeddables и converters. Например, так:

com.example.commerce
└── common
    └── jpa
        ├── embeddable
        │   ├── Money.java
        │   └── Address.java
        └── converter
            └── ProductStatusConverter.java

Тогда Product (каталог), PurchaseOrder (заказы) и CustomerAddress (клиенты) будут использовать один и тот же тип Money и один и тот же Address, не создавая архитектурных «перекрёстных ссылок» между фичами.

И да, это звучит как скучная организационная рутина — пока вы не поймаете ситуацию, когда «изменили Money в одном месте, а в другом у вас внезапно другой Money с таким же именем». Поверьте, ORM и так любит сюрпризы; не надо ему помогать.

3. Рефакторим Product: из priceAmount + priceCurrency

Когда мы рефакторим модель, особенно важно начать с «самого центрального». В Commerce Persistence Lab такой центр — Product. Это сущность, вокруг которой строится много сценариев (fetching, soft delete, репрайсинг, аудит и так далее), поэтому если мы научимся аккуратно улучшать Product, дальше будет проще повторить тот же подход на OrderItem и PurchaseOrder. Здесь важен не повтор устройства Money, а сам переход Product на этот тип.

Как Product выглядит «до»

Ниже упрощённый пример того, что обычно появляется в проектах, если цена живёт примитивами. Вроде бы всё «работает», но модель не говорит нам: «это деньги», она говорит: «это какие-то два поля, не перепутай».

package com.example.commerce.catalog.entity;

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

import java.math.BigDecimal;

@Entity
public class Product {
    @Id
    private Long id;

    // Сумма и валюта разъехались по разным полям: дальше их легко перепутать в методах/DTO.
    private BigDecimal priceAmount;

    // Обычно это ISO-код валюты (например, "USD", "EUR"), но для компилятора это просто String.
    private String priceCurrency;
}

В такой модели почти неизбежно появляются методы и сервисы, которые принимают два параметра рядом. И рано или поздно кто-то передаст валюту не туда. Это не вопрос «если», это вопрос «когда» (обычно после релиза в пятницу вечером).

Берём уже выделенный Money как общий тип

Сам Money уже лежит в com.example.commerce.common.jpa.embeddable. Для этого рефакторинга важен один факт: Product больше не должен хранить amount и currency как два независимых поля.

Маппим Money внутрь Product

Ниже только фрагмент сущности, который реально меняется. Колонки фиксируем явно, чтобы схема и SQL-лог не зависели от implicit naming.

package com.example.commerce.catalog.entity;

import com.example.commerce.common.jpa.embeddable.Money;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;

public class Product {

    // Встраиваем value object: отдельной таблицы не будет, поля "расплющатся" в таблицу Product.
    @Embedded
    @AttributeOverrides({
        // Переименовываем колонки, чтобы по схеме было понятно, что это именно "price_...".
        @AttributeOverride(name = "amount", column = @Column(name = "price_amount")),
        @AttributeOverride(name = "currency", column = @Column(name = "price_currency"))
    })
    private Money price;
}

Здесь важно поймать идею: Money не создаёт отдельную таблицу и не живёт отдельно. Его поля просто «расплющиваются» в строку владельца. С точки зрения SQL это всё тот же product, просто теперь в Java это не два примитива, а один объект.

Чтобы закрепить, вот маленькая схема «в голове»:

flowchart LR
  Product["Product (entity)"] --> Price["price: Money (embeddable)"]
  Price --> PA["product.price_amount"]
  Price --> PC["product.price_currency"]

Делаем API сущности выразительным: reprice(Money newPrice)

Рефакторинг считается завершённым не тогда, когда вы поставили аннотации, а когда вы перестали думать примитивами в бизнес-коде. Поэтому мы меняем не только поля, но и методы. Вместо пары сеттеров на сумму и валюту вводим один доменный метод.

package com.example.commerce.catalog.entity;

import com.example.commerce.common.jpa.embeddable.Money;

public class Product {

    private Money price;

    // Меняем цену целиком, как одно доменное значение (whole-value replacement).
    public void reprice(Money newPrice) {
        this.price = newPrice;
    }
}

Теперь изменение цены выглядит как изменение одного значения, а не как два независимых движения руками. Dirty checking тоже становится легче «читать глазами»: поменяли price → при flush Hibernate обновит колонки price_amount и price_currency.

4. Рефакторим PurchaseOrder: адрес доставки как snapshot Address

У заказа нас интересует один конкретный refactor: россыпь delivery... полей превращается в одно значение deliveryAddress. Это хорошо совпадает с доменным смыслом заказа: адрес доставки хранится как snapshot на момент оформления.

Как выглядит «адрес примитивами» и почему это раздражает

Обычно это выглядит примерно так: набор полей deliveryCity, deliveryStreet, deliveryHouse и ещё десяток подобных. Сервисные методы становятся похожи на телефонную книгу, а шанс перепутать порядок аргументов растёт вместе с количеством полей.

package com.example.commerce.orders.entity;

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

@Entity
public class PurchaseOrder {
    @Id
    private Long id;

    // Адрес распилен на куски: дальше почти неизбежны методы с 5–10 String-параметрами подряд.
    private String deliveryCity;
    private String deliveryStreet;
    private String deliveryHouse;
}

Проблема не в том, что «три поля — это много». Проблема в том, что это одно доменное значение (адрес), но модель заставляет нас думать о нём как о трёх (или десяти) отдельных значениях, которые надо переносить синхронно.

Берём уже выделенный Address как embeddable

Сам Address уже лежит в com.example.commerce.common.jpa.embeddable. Ниже важен не его внутренний состав, а переход PurchaseOrder с россыпи delivery... полей на одно значение.

Встраиваем адрес в PurchaseOrder и даём ему понятные колонки

В PurchaseOrder поле называется deliveryAddress, а колонки мы называем с префиксом delivery_address_..., чтобы даже глазами по SQL-логу было понятно: это адрес доставки заказа, а не что-то ещё. Ниже снова только рефакторинговый фрагмент сущности.

package com.example.commerce.orders.entity;

import com.example.commerce.common.jpa.embeddable.Address;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;

public class PurchaseOrder {

    // Адрес — snapshot в заказе: храним его прямо в строке заказа, без отдельной таблицы адресов.
    @Embedded
    @AttributeOverrides({
        // Префикс delivery_address_... помогает отличать адрес доставки от любых других адресов в схеме.
        @AttributeOverride(name = "city", column = @Column(name = "delivery_address_city")),
        @AttributeOverride(name = "street", column = @Column(name = "delivery_address_street")),
        @AttributeOverride(name = "house", column = @Column(name = "delivery_address_house"))
    })
    private Address deliveryAddress;
}

Да, это выглядит чуть длиннее, чем три примитивных поля. Но это «длина один раз в маппинге», а дальше по коду вы используете один понятный объект deliveryAddress, а не три-четыре-пять строк, которые нужно таскать парой.

Добавляем доменный метод замены адреса целиком

Это и есть whole-value replacement на практике: мы не мутируем поля адреса «по кусочкам», мы заменяем адрес как значение. Код становится проще, а точка изменения видна сразу.

package com.example.commerce.orders.entity;

import com.example.commerce.common.jpa.embeddable.Address;

public class PurchaseOrder {

    private Address deliveryAddress;

    // Меняем адрес целиком: меньше шансов получить "частично обновлённый" адрес.
    public void changeDeliveryAddress(Address newAddress) {
        this.deliveryAddress = newAddress;
    }
}

5. CustomerAddress: entity с Address внутри

Тот же тип Address не делает CustomerAddress лишним. У клиента адреса могут быть множественными, у них есть тип (например, shipping/billing), признак default и жизненный цикл (добавили, сделали дефолтным, удалили). Поэтому CustomerAddress в нашем проекте — это entity, и это правильно. Но внутри этой сущности конкретные поля адреса всё равно остаются одним значением Address.

Почему это «entity с value object внутри» — нормальная комбинация

Иногда начинающие думают, что «если у нас есть Address, то CustomerAddress не нужен». Но Address — это что написано на конверте. А CustomerAddress — это адресная запись клиента со своим смыслом: тип адреса, дефолтность, связь с клиентом, правила удаления. Поэтому мы спокойно держим оба: entity задаёт lifecycle, embeddable задаёт содержимое.

Это выглядит как матрёшка, но полезная: большая матрёшка (entity) хранит маленькую матрёшку (value object), и никто никому не мешает.

Маппинг CustomerAddress.address через @Embedded

Ниже снова только кусок сущности, который меняется.

package com.example.commerce.customer.entity;

import com.example.commerce.common.jpa.embeddable.Address;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;

public class CustomerAddress {

    // Здесь Address хранит "что написано на конверте", а entity CustomerAddress — про lifecycle записи.
    @Embedded
    @AttributeOverrides({
        // В таблице адресов клиента префикс address_... читается естественно.
        @AttributeOverride(name = "city", column = @Column(name = "address_city")),
        @AttributeOverride(name = "street", column = @Column(name = "address_street")),
        @AttributeOverride(name = "house", column = @Column(name = "address_house"))
    })
    private Address address;
}

Обратите внимание на «приятный эффект»: Address теперь переиспользуется и в заказах, и в клиентах, но смысл задаётся именем поля владельца (deliveryAddress vs address) и именами колонок. Один Java-тип, два контекста — и это нормально.

6. SQL и dirty checking после рефакторинга

Embeddable не живёт отдельно, поэтому обновляется строка владельца

После такого рефакторинга Hibernate не начинает делать отдельные INSERT или UPDATE для Money и Address. Он по-прежнему обновляет строку владельца, просто теперь эти колонки собраны в один объект на уровне Java-модели.

Схематично в SQL это выглядит так:

-- exact table names зависят от вашей схемы; здесь важен сам тип UPDATE
update product
set price_amount = ?, price_currency = ?
where id = ?
update purchase_order
set delivery_address_city = ?, delivery_address_street = ?, delivery_address_house = ?
where id = ?

То есть refactor меняет прежде всего смысл и API модели, а не саму природу owner-row updates. Money и Address не получают собственного lifecycle и не требуют дополнительных join’ов.

Почему whole-value replacement делает accidental update менее вероятным

Когда цена и адрес живут примитивами, их легко обновить наполовину: поменять сумму и забыть валюту, поменять город и не тронуть остальной адрес. Когда они собраны в Money и Address, код хотя бы подталкивает вас менять значение целиком. Hibernate на flush всё равно смотрит на финальные колонки unit of work, но для человека точка изменения становится намного заметнее.

Именно это нам и нужно от рефакторинга: не новая магия ORM, а более честная модель.

7. Flyway: когда нужна миграция

Обычно самый неприятный страх при таких рефакторингах звучит так: «Окей, Java-класс поменяем, но таблицы-то как? Сейчас придётся переписывать миграции, а потом всё взорвётся». Хорошая новость: часто менять БД вообще не нужно. Вы можете оставить те же самые колонки price_amount и price_currency и просто сказать Hibernate через @AttributeOverride, что теперь это поля embeddable.

Сценарий «миграция не нужна»

Если у вас уже есть колонки price_amount и price_currency, то переход с примитивов на @Embedded Money price — это чисто Java-рефакторинг. SQL-схема не меняется, данные остаются на месте. Вы просто начинаете читать и писать их через более выразительную модель.

Это один из самых приятных видов рефакторинга: вы улучшаете код и снижаете риск ошибок, не трогая таблицы.

Сценарий «миграция нужна» (например, колонок ещё нет)

Если вдруг у вас был только price_amount, а price_currency вы «держали в голове» (что тоже бывает), тогда да — Flyway нужен. Миграция должна быть минимальной и понятной.

-- Vx__add_product_price_currency.sql
alter table product
add column price_currency varchar(3) not null default 'USD';

-- После заполнения можно убрать default в следующей миграции, если нужно.

Здесь важная дисциплина курса остаётся прежней: никакого ddl-auto=update как «магии». Мы хотим, чтобы схема была контролируемой и читаемой. Embeddable не отменяет этого правила, он просто делает маппинг в Java более удобным.

8. Сервисный код после рефакторинга

Последний штрих — это не Hibernate и не аннотации. Это то, как ваш код пользуется моделью. Если сервисы продолжают принимать BigDecimal amount, String currency, то вы как бы признаёте: «да, деньги — это два примитива, просто в entity мы их спрятали». Иногда это допустимо на границе (например, если вход приходит из внешнего DTO), но внутри доменного и сервисного кода мы хотим говорить на языке модели.

До: сервис принимает примитивы и сам «собирает деньги»

Такой метод легко вызвать неправильно, особенно если параметров становится больше.

package com.example.commerce.catalog.service;

import java.math.BigDecimal;

public class ProductService {
    // На уровне API сервиса всё ещё "два параметра рядом" — риск перепутать остаётся.
    public void reprice(long productId, BigDecimal amount, String currency) {
        // ... найти Product и обновить цену
    }
}

Это не «плохой код», но он не помогает нам думать доменными типами.

После: сервис принимает Money и обновляет сущность через доменный метод

Теперь сигнатура сама говорит, что ожидается.

package com.example.commerce.catalog.service;

import com.example.commerce.common.jpa.embeddable.Money;

public class ProductService {
    // Сервис говорит на языке модели: цена — это Money, а не пара примитивов.
    public void reprice(long productId, Money newPrice) {
        // ... find product
        // product.reprice(newPrice);
    }
}

То же самое работает и для адреса. Вместо методов вида changeDeliveryAddress(orderId, city, street, house, ...) вы получаете changeDeliveryAddress(orderId, Address). Такой код почти невозможно вызвать «перепутав улицу с домом», потому что у вас нет десяти строковых аргументов подряд.

И это, кстати, одна из самых недооценённых выгод value objects: они уменьшают число «одинаковых по типу, но разных по смыслу» параметров. А именно такие параметры чаще всего и приводят к багам, которые компилятор не ловит.

9. Типичные ошибки при рефакторинге в embeddables и value objects

Ошибка №1: оставляют старые примитивные поля рядом с embeddable.
Часто рефакторинг делают «осторожно»: добавили Money price, но не удалили priceAmount и priceCurrency, потому что «вдруг пригодится». В итоге в модели появляется два источника истины, и рано или поздно они разъедутся. Если вы решили, что цена — это Money, то цена должна жить в одном месте, а старые поля должны исчезнуть из entity.

Ошибка №2: делают Money и Address открыто mutable (публичные setters).
Тогда в код тихо возвращается старая проблема: точка изменения расползается по мапперам и утилитам, а dirty checking честно шлёт UPDATE. Для этого рефакторинга рабочее правило простое: value object меняем целиком, а не через внутренние сеттеры.

Ошибка №3: забывают про нормальные имена колонок и получают в таблице загадочные amount и currency.
Технически Hibernate так работать будет, но читать SQL и схему станет тяжелее. Через пару недель вы откроете product и увидите amount, а мозг задаст резонный вопрос: «amount чего именно?». Префиксы вроде price_..., delivery_address_..., address_... — это маленькая вещь, которая сильно улучшает сопровождение.

Ошибка №4: пытаются решать converter-ом задачу, которая на самом деле multi-column.
Иногда возникает соблазн «упаковать Money в одну строку» и хранить его как "99.99|USD". Это выглядит как экономия колонок, но на практике усложняет запросы, индексацию, сортировки и вообще всю работу с данными. Converter — это хороший инструмент для one-column значений, а Money и Address остаются multi-column историей.

Ошибка №5: начинают использовать value object, но продолжают писать API «примитивами» и теряют половину пользы.
Если у вас в entity Money price, а в сервисах по-прежнему reprice(amount, currency), вы улучшили маппинг, но не улучшили чтение кода и защиту от ошибок. Хороший признак завершённого рефакторинга — когда бизнес-методы оперируют Money и Address, а примитивы остаются только на внешней границе (если она вообще есть в текущем учебном проекте).

1
Задача
Hibernate deep-dive, 14 уровень, 4 лекция
Недоступна
Рефакторинг цены блюда из примитивов в Money
Рефакторинг цены блюда из примитивов в Money
1
Задача
Hibernate deep-dive, 14 уровень, 4 лекция
Недоступна
Рефакторинг адреса доставки из примитивов в Address
Рефакторинг адреса доставки из примитивов в Address
1
Опрос
Доменные объекты, 14 уровень, 4 лекция
Недоступен
Доменные объекты
Entity, embeddable и конвертеры
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ