Полное руководство по объектам-значениям (value objects)
Этот пост — текстовая версия роликов по Value Object (раз и два). Здесь мы разбираем не столько теорию, сколько реальные примеры из проектов. DDD — не самая простая область, поэтому хочется показать не только удачные решения, но и грабли, на которые мы сами наступали. Код из примеров лежит в репозитории, ссылка на него есть в конце статьи.
Разберём паттерн на реальном примере с чуть более сложной бизнес-логикой, чем обычно приводится в книгах. Все примеры — на Kotlin, но перенести эту идею на другие языки не составит труда.
Определение
Value Object (объект-значение) — концепция из Domain-Driven Design для моделирования объектов, которые не имеют собственной идентичности и определяются исключительно своими свойствами. Их равенство определяется значениями полей, а не ссылкой или идентификатором — мы сравниваем только содержимое. Это могут быть, например, денежные суммы, координаты, даты, цвета, адреса и т. д.
У Value Object есть несколько основных свойств:
- Неизменяемость. Не во всех языках объект можно сделать неизменяемым, но предполагается, что после создания Value Object мы не меняем его состояние. Если нужно что-то изменить, создаём новый экземпляр.
- Сравнение по значению. Равенство объектов определяется значениями их полей.
- Отсутствие побочных эффектов. По сути, реализация работает как функция без побочных эффектов: результат зависит только от входных параметров.
Самый простой пример: Email
data class Email internal constructor(private val email: String) {
init {
check(isValid(email))
}
companion object {
private val REGEX =
Regex("^[a-zA-Z0-9_!#\$%&'*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+\$")
fun from(email: String) =
if (isValid(email)) {
Email(email).right()
} else {
InvalidFormatError.left()
}
private fun isValid(email: String) = REGEX.matches(email)
}
}
object InvalidFormatError
Для создания экземпляра используется фабричный метод from, внутри которого выполняется валидация: мы проверяем, что e-mail валиден, а конструктор имеет модификатор доступа internal. Если ввод валиден, возвращаем e-mail, если нет — InvalidFormatError.
Такая конструкция (.left() / .right()) взята из библиотеки Arrow. Если коротко, это способ обозначить ошибку без исключений и самодельных кодов возврата: метод возвращает объект Either. Если вернулась левая часть — значит, произошла ошибка, если правая — мы получили сам e-mail.
Ничего сверхъестественного здесь нет: вместо Either можно использовать исключения, если такой подход вам привычнее.
Что даёт паттерн?
Пройдёмся от самого очевидного к менее очевидному.
Наглядность и читабельность
Самое первое, что приходит в голову, — наглядность. Посмотрите на такую сигнатуру: метод рассчитывает стоимость доставки, на вход получает вес, дистанцию и скидку. Вес и дистанция обозначены как Int, скидка — как BigDecimal, а на выходе тоже BigDecimal.
fun calculateShippingCost(weight: Int, distance: Int, discount: BigDecimal): BigDecimal {
TODO()
}
По сигнатуре совершенно непонятно, что нужно передавать в эту функцию. Вес — в граммах или килограммах? Дистанция — в метрах или километрах? В чём задаётся скидка? И что вообще возвращается на выходе?
Если добавить Value Object, работать с этим становится проще: на вход принимаются экземпляры вполне понятных классов, а на выходе — некий Price:
fun calculateShippingCost(weight: Weight, distance: Distance, discount: Discount): Price {
TODO()
}
class Weight(val kg: Int)
class Distance(val km: Int)
class Discount(val percent: Int)
class Price(val amount: Int, currency: String)
Реализация здесь довольно примитивная: вес — в килограммах, дистанция — в километрах, скидка — в процентах. Обратите внимание: никакой валидации пока нет, до этого ещё дойдём. Сейчас задача только показать разницу в читабельности.
Когда мы видим такую сигнатуру, уже понятно, что где-то нужно получить Weight, Distance, Discount и Price. У каждого из них могут быть свои правила валидации и фабрики, поэтому читать такой код становится гораздо проще: хотя бы понятно, что именно нужно передать в метод.
Но повторимся: реализация здесь очень простая, без валидации, поэтому можно попасть в ту же ловушку. Килограммы — это Int, но какие у него допустимые значения? Какая валюта и какой amount у Price? Это тоже пока неясно. Тем не менее сигнатура становится заметно понятнее. Можно сказать, что это Value Object на минималках.
Инкапсуляция бизнес-логики
Item Count
Идём дальше. Чтобы понять, как можно инкапсулировать бизнес-логику, рассмотрим более сложный пример. Этот класс и многие из следующих примеров — из реального проекта, только немного переименованы, чтобы не раскрывать предметную область (а то мне за это по шапке дадут).
Этот Value Object представляет некоторое количество материала или вещества — пусть будет для химических процессов.
Небольшое отступление. В коде иногда встречаются маркерные интерфейсы вроде
interface ValueObject
Он нужен в основном для читабельности, но иногда используется для автоматизации проверок. Впрочем, его может не быть вовсе.
Итак, вот наш класс:
data class ItemCount internal constructor(private val itemCount: BigDecimal) : ValueObject {
fun bigDecimalValue() = itemCount
operator fun compareTo(other: ItemCount) = itemCount.compareTo(other.itemCount)
operator fun plus(other: ItemCount) =
ItemCount(itemCount + other.itemCount)
operator fun minus(other: ItemCount): Either<ResultNotPositive, ItemCount> {
val result = itemCount - other.itemCount
return if (result >= MIN_VALUE) {
ItemCount(result).right()
} else {
ResultNotPositive.left()
}
}
companion object {
const val SCALE = 1
val MIN_VALUE = BigDecimal("0.1")
fun from(itemCount: BigDecimal): Either<ItemCountCreationError, ItemCount> {
return when {
itemCount.scale() > SCALE -> ItemCountCreationError.ScaleTooLong.left()
itemCount < MIN_VALUE -> ItemCountCreationError.ValueTooLow.left()
else -> ItemCount(itemCount.setScale(SCALE)).right()
}
}
fun min() = ItemCount(MIN_VALUE)
}
}
sealed class ItemCountCreationError {
object ScaleTooLong : ItemCountCreationError()
object ValueTooLow : ItemCountCreationError()
}
object ResultNotPositive
Давайте посмотрим, как он устроен.
Само значение хранится как BigDecimal, а метод bigDecimalValue() его возвращает. Мы не обращаемся к полю напрямую, а используем метод доступа. Зачем это нужно? Мы хотим скрыть внутреннее представление ItemCount. Если оно вдруг поменяет тип, клиентский код, который использует этот метод, не пострадает — всю логику преобразования можно будет оставить внутри одного места.
Ещё одна причина прятать значение — внутреннее представление иногда бывает изменяемым, например массив. Если отдать его наружу напрямую, с ним можно сделать что угодно, поэтому лучше инкапсулировать доступ. Даже если внутри обычная строка, можно было бы воспользоваться toString(), но это тоже не лучший вариант: toString() обычно используют для отладки, а нам нужно именно строковое значение. К этому мы ещё вернёмся позже.
Дальше идут compareTo, сложение и вычитание одного ItemCount с другим. И здесь уже появляется более сложная логика: при вычитании может вернуться ResultNotPositive, если результат меньше минимального значения MIN_VALUE.
Почему так? По бизнес-логике в рецептуре, в веществах не может быть ItemCount меньше определённого значения. Если после вычитания мы получили такое значение, значит, где-то произошла ошибка в коде.
В фабричном методе from проверяется scale: если у переданного BigDecimal после запятой оказалось больше одного знака (SCALE = 1), мы не знаем, как его округлять. А округлять бизнес-логика не позволяет, потому что это нарушит точность и целостность значения. Поэтому возвращается ScaleTooLong.
То же самое с минимальным значением: если оно меньше MIN_VALUE (а это 0.1), возвращается ошибка ValueTooLow. Так делать нельзя.
Есть и небольшой вспомогательный фабричный метод min(), который просто возвращает минимальное значение. Он используется в некоторых сценариях для инициализации.
Собрав все эти методы вместе, мы можем сравнивать такие значения, складывать и вычитать их. В дальнейшем это используется для построения графиков, отчётов и другой, довольно сложной логики.
Причём обратите внимание: сложить ItemCount можно только с другим ItemCount, потому что внутри есть собственная логика приведения этих значений друг к другу.
ItemCounter
Есть ещё ItemCounter, который похож на ItemCount, но не совсем то же самое:
data class ItemCounter internal constructor(private val itemCounter: BigDecimal) {
fun bigDecimalValue() = itemCounter
companion object {
fun zero() = ItemCounter(BigDecimal.ZERO)
fun from(itemCount: ItemCount) = ItemCounter(itemCount.bigDecimalValue())
fun from(itemCounter: BigDecimal): Either<InputValueIsNegative, ItemCounter> {
return if (itemCounter < BigDecimal.ZERO) {
InputValueIsNegative.left()
} else {
ItemCounter(itemCounter).right()
}
}
}
operator fun plus(itemCount: ItemCount): ItemCounter {
val newValue = itemCounter.add(itemCount.bigDecimalValue())
return ItemCounter(newValue)
}
operator fun plus(other: ItemCounter): ItemCounter {
val newValue = itemCounter.add(other.bigDecimalValue())
return ItemCounter(newValue)
}
}
object InputValueIsNegative
Если ItemCount — это некое количество в рецепте, то ItemCounter — скорее количество потраченного вещества или материала. Поэтому у него уже другие граничные значения: в отличие от ItemCount, он может быть нулевым — для этого есть фабричный метод zero().
Есть фабричные методы, позволяющие создать ItemCounter из ItemCount, а также из BigDecimal с проверкой на отрицательное значение. Кроме того, есть два варианта сложения: можно сложить ItemCounter с ItemCount, то есть добавить количество в счётчик, а можно складывать счётчики между собой при агрегации:
val counter1 = ItemCounter.zero() val counter2 = ItemCounter.zero() val minCount = ItemCount.min() val updatedCounter = counter1 + minCount // ItemCounter, сложили с ItemCount val totalCounter = counter1 + counter2 // ItemCounter, сложили счётчики между собой
Обратите внимание, что ItemCounter и ItemCount очень похожи между собой, и есть соблазн использовать вместо одного другого. Но как только нарушается правило валидации — появляется необходимость в дополнительных проверках и приходится разбираться, в каком случае какое граничное значение использовать. В результате все это будет выглядеть как один большой костыль.
Поэтому они разделены: один — накопитель, второй — просто обозначение количества. В результате модель получается понятнее, а правила каждого типа остаются внутри самого Value Object.
ItemWeight
Есть ещё ItemWeight — вес некоторого Item:
data class ItemWeight internal constructor(private val itemWeightGram: Int): ValueObject {
fun intValue() = itemWeightGram
operator fun compareTo(other: ItemWeight) = itemWeightGram.compareTo(other.itemWeightGram)
operator fun plus(other: ItemWeight) =
ItemWeight(itemWeightGram + other.itemWeightGram)
operator fun minus(other: ItemWeight): Either<ItemSubtractionResultNotPositive, ItemWeight> {
val result = itemWeightGram - other.itemWeightGram
return if (result >= MIN_VALUE) {
ItemWeight(result).right()
} else {
ItemSubtractionResultNotPositive.left()
}
}
operator fun times(itemCount: ItemCount): ItemWeight {
val result = itemCount.bigDecimalValue()
.multiply(BigDecimal(intValue()))
val toInt = result.setScale(0, RoundingMode.HALF_UP).intValueExact()
return if (toInt == 0) {
ItemWeight(MIN_VALUE)
} else {
ItemWeight(toInt)
}
}
operator fun div(weight: ItemWeight): ItemCount {
val result = this.intValue().toBigDecimal() / weight.intValue().toBigDecimal()
return ItemCount.from(result.setScale(ItemCount.SCALE, RoundingMode.HALF_UP))
.fold(
ifLeft = {
when (it) {
ItemCountCreationError.ScaleTooLong -> error("Unreachable result")
ItemCountCreationError.ValueTooLow -> ItemCount.min()
}
},
ifRight = { it }
)
}
companion object {
const val MIN_VALUE = 1
fun from(itemWeightGram: Int): Either<ItemValueTooLow, ItemWeight> {
return when {
itemWeightGram < MIN_VALUE -> ItemValueTooLow.left()
else -> ItemWeight(itemWeightGram).right()
}
}
}
}
object ItemValueTooLow
object ItemSubtractionResultNotPositive
По сути, здесь всё устроено похожим образом: есть значение Int, compareTo, сложение и вычитание. Но есть и более интересные операции. Например, times — оператор умножения. Причём умножать ItemWeight можно только на ItemCount. Внутри выполняется округление с помощью RoundingMode.HALF_UP, а если в результате получился ноль, возвращается минимальное значение. По бизнес-логике нуля быть не должно — какое-то количество должно остаться в любом случае.
Есть и оператор деления div. На выходе он должен вернуть количество Item — мы делим один вес на другой и получаем ItemCount. Если from возвращает ValueTooLow, это ожидаемая ситуация по бизнес-логике, поэтому возвращаем ItemCount.min(). А вот если вдруг вернулся ScaleTooLong, такого результата быть не может: перед вызовом from мы сами выставляем нужный scale. Значит, где-то произошла ошибка в коде, и здесь стоит бросить исключение (error(«Unreachable result»)) — всё должно остановиться.
Тут можно возразить: с математической точки зрения это неправильно — при делении мы возвращаем минимальное значение вместо фактически получившегося. Но здесь логика не математическая, а предметная: по бизнес-правилам значение не может быть нулевым или отрицательным. Минимальное значение допустимо, а меньше него — нет.
Фабричный метод from принимает количество граммов. Параметр, кстати, можно было бы переименовать в grams, чтобы не заглядывать в сигнатуру и сразу понимать, что именно передаётся. Валидация здесь простая: вес не может быть меньше MIN_VALUE, то есть одного грамма.
ItemWeightCounter
По аналогии есть и ItemWeightCounter — устроен примерно так же, как ItemCounter:
data class ItemWeightCounter internal constructor(private val itemWeightGram: Int) {
fun intValue() = itemWeightGram
companion object {
fun zero() = ItemWeightCounter(0)
fun from(itemWeight: ItemWeight) = ItemWeightCounter(itemWeight.intValue())
fun from(itemWeight: Int): Either<ItemWeightLessThanZero, ItemWeightCounter> {
return if (itemWeight < 0) {
ItemWeightLessThanZero.left()
} else {
ItemWeightCounter(itemWeight).right()
}
}
}
operator fun plus(weight: ItemWeight) =
ItemWeightCounter(itemWeightGram + weight.intValue())
operator fun plus(other: ItemWeightCounter) =
ItemWeightCounter(itemWeightGram + other.intValue())
}
object ItemWeightLessThanZero
ItemWeightRange
Следующий объект — ItemWeightRange, диапазон «от и до»:
data class ItemWeightRange internal constructor(
val from: ItemWeight,
val to: ItemWeight,
): ValueObject {
companion object {
fun from(from: ItemWeight, to: ItemWeight): Either<InvalidItemWeightRange, ItemWeightRange> {
return if (from > to) {
InvalidItemWeightRange.left()
} else {
ItemWeightRange(from, to).right()
}
}
}
}
object InvalidItemWeightRange
Здесь есть один фабричный метод, который проверяет, не получается ли так, что начальное значение больше конечного. Если это условие нарушено, возвращается InvalidItemWeightRange.
Этот объект используется в определённых технологических процессах: он задаёт диапазон, внутри которого должно находиться целевое значение веса.
Count
Ещё один интересный класс — Count:
data class Count(private val value: Int): ValueObject {
init {
require(value >= 0)
}
companion object {
fun from(count: Int): Either<NegativeValueError, Count> {
return if (count < 0) {
NegativeValueError.left()
} else {
Count(count).right()
}
}
fun one() = Count(1)
fun zero() = Count(0)
}
fun increment(): Either<MaxValueReachedError, Count> {
val res = value + 1
return if (res > value) {
Count(res).right()
} else {
MaxValueReachedError.left()
}
}
fun decrement(): Either<MinValueReachedError, Count> {
val res = value - 1
return if (res >= 0) {
Count(res).right()
} else {
MinValueReachedError.left()
}
}
fun isMin() = value == 0
fun isMax() = value == Int.MAX_VALUE
fun toIntValue() = value
}
object NegativeValueError
object MaxValueReachedError
object MinValueReachedError
Это обычное целочисленное значение с фабричным методом from и удобными методами one() и zero(). Есть методы increment и decrement: при инкременте либо возвращается MaxValueReachedError, чтобы избежать переполнения счётчика, либо новый Count. При декременте аналогично: можно получить MinValueReachedError или новый Count. Ну и классический toIntValue().
Такой вспомогательный объект используется там, где нужно что-то посчитать и значение должно быть целым. В реальных проектах отдельно могут существовать позитивный вариант счётчика — для значений, которые не могут быть равны нулю и меньше. Если нужен именно такой объект, используется соответствующий класс. В разобранном репозитории эти варианты уже не включены, но принцип тот же.
IP-адрес
Ещё один показательный пример — IP-адрес. Он не вошёл в опубликованный репозиторий
data class IPv4Address private constructor(
private val octets: IntArray
) : ValueObject {
fun intArrayValue() = octets.copyOf()
fun stringValue() = octets.joinToString(".")
companion object {
fun from(value: String): Either<InvalidIpAddress, IPv4Address> {
val octets = value.split(".")
.mapNotNull { it.toIntOrNull() }
return if (
octets.size == 4 &&
octets.all { it in 0..255 } &&
!isLocalAddress(octets)
) {
IPv4Address(octets.toIntArray()).right()
} else {
InvalidIpAddress.left()
}
}
private fun isLocalAddress(octets: List<Int>): Boolean =
octets[0] == 10 ||
(octets[0] == 172 && octets[1] in 16..31) ||
(octets[0] == 192 && octets[1] == 168)
}
}
object InvalidIpAddress
На нём особенно хорошо видно, зачем прятать внутреннее представление и делать поля приватными.
Фабричный метод принимает строку, парсит её и заодно проверяет, не является ли адрес адресом локальной сети. По бизнес-логике приложения такие адреса недопустимы.
Внутри хранится массив октетов, но прямого доступа к нему нет (иначе его можно было бы изменить снаружи). Если нужен сам массив, есть метод, который возвращает его копию. Если нужно строковое представление, используется joinToString.
Если просто вызвать toString(), мы получим внутреннее представление массива, а не строковое представление IP-адреса. Поэтому значение и скрыто, а логика работы с ним инкапсулирована внутри объекта. Это и даёт защиту модели, о которой чуть позже поговорим подробнее.
Хаки
Иногда при работе с Value Object можно немного упростить себе жизнь. Например, для ItemCount можно добавить несколько методов-расширений:
fun ItemCount.Companion.fromAnyScale(
value: BigDecimal
): Either<ItemCountCreationFromAnyScaleError, ItemCount> {
val destValue = value.setScale(SCALE)
return from(destValue).mapLeft {
when (it) {
ItemCountCreationError.ScaleTooLong -> error("Can't be here")
ItemCountCreationError.ValueTooLow ->
ItemCountCreationFromAnyScaleError.ValueTooLow
}
}
}
fun ItemCount.Companion.fromOrError(value: BigDecimal) =
from(value).getOrElse { error("Cannot create ItemCount from $value") }
sealed class ItemCountCreationFromAnyScaleError {
object ValueTooLow : ItemCountCreationFromAnyScaleError()
}
fromAnyScale — это вариант для случаев, когда мы явно говорим, что готовы принять значение с любым scale. Внутри просто приводим его к нужному масштабу с помощью setScale, а затем используем обычный from.
При этом ScaleTooLong здесь уже в принципе не может возникнуть: мы сами заранее привели значение к нужному scale. А вот ValueTooLow остаётся вполне реальной ошибкой.
Такими методами, впрочем, легко злоупотребить — об этом чуть дальше. При этом строгая проверка scale уже помогла выловить немало ошибок, которые приходили из других систем: ожидаем данные с определённой точностью, а фактически приходит значение с другой. Такие данные нельзя просто молча округлить, потому что непонятно, как именно это делать с точки зрения бизнес-логики. Поэтому мы их отбрасываем и таким образом обнаруживаем ошибки в системах коллег.
fromOrError — ещё одно упрощение: вместо Either метод сразу бросает исключение через error(…), если создать ItemCount не удалось.
Это может быть полезно, например, при извлечении данных из хранилища: достали значение из базы, попытались создать ItemCount, получили ошибку — и здесь уже ничего не поделать. Разумно просто бросить исключение о том, что в базе лежит некорректное значение.
Но этой возможностью тоже легко злоупотребить: «удобненько же, подумаешь — кинется ошибка». Хотя по-хорошему в некоторых сценариях стоило бы вернуть пользователю осмысленную ошибку, а не InternalError.
Поэтому такие методы лучше описывать через функцию-расширение прямо в том модуле, где они нужны. Например, fromOrError можно определить в модуле хранения — тогда он не будет доступен во всём остальном коде.
Составные объекты
Мы уже видели Value Object, которые сами состоят из других Value Object. Хороший пример — координаты: долгота и широта.
data class Coordinates(
val lat: Latitude,
val lon: Longitude
)
data class Latitude private constructor(private val lat: BigDecimal) {
fun bigDecimalValue() = lat
companion object {
private const val MAX_ABS_VALUE = 90
fun from(lat: BigDecimal) =
when {
lat > BigDecimal(MAX_ABS_VALUE) ->
CoordinateCreationError.TooBig.left()
lat < BigDecimal(-MAX_ABS_VALUE) ->
CoordinateCreationError.TooLow.left()
else -> Latitude(lat).right()
}
}
}
data class Longitude private constructor(private val lon: BigDecimal) {
fun bigDecimalValue() = lon
companion object {
private const val MAX_ABS_VALUE = 180
fun from(lon: BigDecimal) =
when {
lon > BigDecimal(MAX_ABS_VALUE) ->
CoordinateCreationError.TooBig.left()
lon < BigDecimal(-MAX_ABS_VALUE) ->
CoordinateCreationError.TooLow.left()
else -> Longitude(lon).right()
}
}
}
sealed interface CoordinateCreationError { // можно разделить на 2 класса
data object TooBig : CoordinateCreationError
data object TooLow : CoordinateCreationError
}
У самих Coordinates нет собственной валидации — она находится непосредственно в Latitude и Longitude. Для широты максимальное значение по модулю составляет 90, для долготы — 180.
Классы получились довольно простыми. При желании можно было бы дополнительно ограничить точность, но в данном случае это не так важно.
Обратите внимание, что для обоих классов используется одна и та же ошибка CoordinateCreationError. При желании её можно разделить на две отдельные ошибки — ничего плохого в этом нет.
Можно пойти и другим путём: сделать Coordinates одним классом с двумя полями BigDecimal и поместить всю валидацию в один фабричный метод. Тогда отдельных Latitude и Longitude вообще не будет.
Но такой вариант не всегда удобен. Если у отдельных частей появляются собственные правила проверки, лучше изолировать их в своих Value Object, а не складывать всю валидацию в один класс.
Валидация
К вопросу про Value Object хочется вернуться к примеру из прошлого ролика про валидацию — контроллеру добавления блюда в меню.
Теперь идея должна быть понятнее: где-то нужно получить Value Object и передать их непосредственно в use case. Именно это и происходит в контроллере.
@ApiOperation("Add a meal to the menu")
@PostMapping(path = [API_V1_MENU_ADD_TO_MENU])
fun execute(@RequestBody request: AddMealToMenuRestRequest): ResponseEntity<*> {
return MealName.validated(request.name)
.zip(
MealDescription.validated(request.description),
Price.validated(request.price)
) { mealName: MealName, mealDescription: MealDescription, price: Price ->
addMealToMenu.execute(mealName, mealDescription, price)
}.fold({ validationErrors ->
validationErrors.toInvalidParamsBadRequest()
}, { addingMealToMenuResult ->
addingMealToMenuResult.fold(
{ it.toRestError() },
{ created(UriComponentsBuilder
.fromHttpUrl(ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString()
.plus(API_V1_MENU_GET_BY_ID))
.buildAndExpand(it.toLongValue()).toUri()) })
})
}
Через функцию-расширение ошибки создания преобразуются в ошибки валидации и возвращаются пользователю.
fun MealName.Companion.validated(name: String): ValidatedNel<ValidationError, MealName> {
return from(name).mapLeft {
when (it) {
is CreateMealNameError.EmptyMealNameError,
-> ValidationError(message = "Meal name is empty")
}
}.toValidatedNel()
}
Если use case сам не смог что-то сделать — выполняется соответствующая обработка. Если всё прошло успешно — возвращаем результат.
Более подробно код можно посмотреть по ссылке: github.com/stringconcat/ddd_practice
Механизм валидации Spring здесь принципиально не используется, потому что он плохо ложится на эту модель и выглядит довольно монструозно. Надо бы как-нибудь сесть и упростить, но пока руки не дошли.
Получается, что вся валидация происходит уже в контроллере, а в use case передаются гарантированно валидные объекты.
И здесь снова всплывает опасность методов вроде getOrError / fromOrError, о которых говорили выше. Кто-то может не писать нормальную валидацию с преобразованием ошибок, а просто вызвать mealName.fromOrError(…). Тогда при некорректном вводе пользователя прямо в контроллере вылетит непредвиденное исключение, и вместо адекватного 400 пользователь получит 500 с формулировкой в духе: «Внутри что-то сломалось, обратитесь к системному администратору, идентификатор ошибки такой-то».
Поэтому такие методы лучше либо вообще не заводить, либо делать менее доступными, как уже говорили ранее
Контекстная валидация
Ещё один пример — контекстная валидация и фабрики для Value Object. Здесь пример совсем примитивный, до конца его не реализовывали, но он хорошо показывает саму идею. Это Value Object для VIN-номера автомобиля (Vehicle Identification Number):
// en.wikipedia.org/wiki/Vehicle_identification_number sealed class VIN(private val vin: String) { fun stringValue() = vin fun year(): Int = TODO() fun country(): String = TODO() override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as VIN return vin == other.vin } override fun hashCode(): Int { return vin.hashCode() } //.... } class NorthAmericanVIN(vin: String) : VIN(vin) { fun checkSum(): Int = TODO() } class EuropeanVIN(vin: String) : VIN(vin) class VINFactory { fun from(vin: String): Either<VINCreationError, VIN> { // проверить формат, производителя и т. д. TODO() } } sealed interface VINCreationError { object InvalidCheckSum : VINCreationError object InvalidFormat : VINCreationError object UnknownManufacturer : VINCreationError //..... }
У каждого автопроизводителя и для каждой машины есть определённый формат идентификационного номера. Существует общий стандарт, но, например, в Европе и Америке форматы немного различаются. По VIN можно определить, в какой стране произведён автомобиль, кто его произвёл и когда.
Если нужно создать Value Object для VIN, всё становится сложнее. Понадобятся как минимум справочники производителей, стран и другие данные, которые к тому же периодически меняются.
Поэтому здесь может понадобиться отдельная VINFactory, которая содержит внутри себя все эти справочники, выполняет необходимую проверку и на выходе отдаёт готовый VIN.
При этом сам VIN может иметь несколько реализаций. Как видите, здесь используется sealed class с двумя наследниками: NorthAmericanVIN, у которого есть метод checkSum() — контрольная сумма внутри самого VIN, — и EuropeanVIN, где такой проверки нет. Получается небольшая иерархия.
При этом у фабрики нет никаких идентификаторов: VIN существует сам по себе. Просто для того, чтобы проверить его валидность, нужно основательно постараться.
Слой общих типов
Может возникнуть вопрос: раз уже есть готовые Email, координаты и прочие Value Object, нельзя ли использовать их как общие типы, вынесенные в условный common, который есть во всех проектах?
В целом — да. Если кому-то нужен Count именно с такой логикой и поведением, почему бы и нет. То же самое с Email — он вполне универсален. Но только до того момента, пока в конкретном контексте для Count не понадобится какая-то специфическая логика. Тогда его придётся вынести в свой модуль, то есть фактически сделать отдельную реализацию и пользоваться уже ей.
Здесь снова уместно вернуться к сигнатуре с доставкой:
fun calculateShippingCost(
weight: Weight,
distance: Distance,
discount: Discount
): Price {
TODO()
}
Если посмотреть на неё ещё раз, всё равно не совсем понятно, что именно нужно передавать, но это уже лучше, чем просто Int. И если что-то перепутать местами, хотя бы компилятор даст по шапке.
Это отличное свойство, особенно когда работаешь с подобными циферками вроде ItemCount и ItemCounter: такая типизация очень выручает. Плюс внутри уже инкапсулирована куча бизнес-логики. Попробуйте потаскать с собой все эти округления и граничные случаи, когда у вас с десяток таблиц с подобными вычислениями, — очень быстро запутаетесь и потонете.
Сигнатуру можно немного переработать и, если у Value Object нет никаких инвариантов и специфических ограничений, сделать примерно так:
fun calculateShippingCost(
weight: Kilograms,
distance: Kilometers,
discount: Percent
): Money {
TODO()
}
class Kilograms(val kg: Int)
class Kilometers(val km: Int)
class Percent(val percent: Int)
class Money(val amount: Int, currency: String)
Килограммы, километры и другие подобные типы можно вынести в какой-нибудь общий common, а на выходе получить абстрактный Money с amount и currency, то есть немного снизить бизнес-специфичность.
Но здесь стоит предупредить: где-нибудь это может «выстрелить». Кто-то возьмёт общий Percent и добавит в него специфическую логику или ограничение — например, что скидка не может быть больше 50%. И такой общий Percent уже не подойдёт.
Это снова вопрос защиты модели, о которой поговорим ниже.
При этом внутри агрегатов иногда вполне можно использовать стандартные типы языка. Например, Duration или обычный OffsetDateTime для даты создания агрегата. Если никакой особой логики нет и это просто объект для обозначения даты, ничего страшного в прямом использовании стандартного типа нет.
Но бывает и так, что даже у, казалось бы, простой даты есть свои ограничения. Пример из реального проекта — дата рождения:
data class BirthDate internal constructor(
private val birthdate: LocalDate
) {
fun localDateValue() = birthdate
fun age(clock: Clock) =
birthdate.until(
LocalDate.now(clock),
ChronoUnit.YEARS
).toInt()
companion object {
private const val MAX_AGE = 120
private const val MIN_AGE = 14
fun from(
birthdate: LocalDate,
clock: Clock
): Either<BirthdateCreationError, BirthDate> {
val today = LocalDate.now(clock)
val age = birthdate.until(today, ChronoUnit.YEARS)
if (age < MIN_AGE) {
return BirthdateCreationError.TooYoung.left()
}
if (age > MAX_AGE) {
return BirthdateCreationError.TooOld.left()
}
return BirthDate(birthdate).right()
}
}
}
sealed class BirthdateCreationError {
object TooYoung : BirthdateCreationError()
object TooOld : BirthdateCreationError()
}
Внутри здесь обычный LocalDate, но всё равно он инкапсулирован в отдельный BirthDate. Дело в том, что у даты есть ограничения: произвольный LocalDate сюда передать нельзя.
При создании BirthDate проверяются минимальный и максимальный возраст. Минимальный возраст для участия в бизнес-процессе — MIN_AGE = 14. На вход передаются LocalDate и Clock, а внутри вычисляется возраст через age(clock) и проверяется, может ли пользователь пользоваться системой.
Верхнее ограничение — MAX_AGE = 120. Оно тоже взято из бизнес-логики: некоторые расчёты могут просто сломаться при слишком большом значении, к тому же пользователь мог банально ошибиться при вводе.
В общем, даже для обычной даты иногда приходится защищаться от невалидной ереси.
Идентификаторы
Подобным образом можно обозначать и идентификаторы — например, идентификаторы агрегатов:
data class UserId(val id: UUID = UUID.randomUUID())
data class PaymentId(val id: Long) {
companion object {
fun generate(instanceId: Byte, clock: Clock): PaymentId {
TODO()
}
}
}
Вот UserId: внутри просто UUID, который по умолчанию генерируется случайным образом. Здесь можно рассчитывать на защиту компилятора: если где-то перепутать идентификаторы, компилятор не даст скомпилировать код. Особенно полезно это для идентификаторов вроде Long или UUID, которые легко случайно передать не туда.
Другой пример — PaymentId с идентификатором типа Long и отдельным фабричным методом generate для его генерации. На вход он принимает instanceId — идентификатор машины, на которой работает приложение, — и Clock для вычисления временного штампа. В итоге получается составной идентификатор, по которому можно определить, на какой машине и в какой момент он был сгенерирован.
Алгоритмов генерации таких идентификаторов существует множество. Здесь показан просто пример, похожий на то, что было в реальных проектах. Вся логика генерации при этом инкапсулирована внутри PaymentId.
При создании через метод вроде from можно даже возвращать ошибку: либо ошибка, либо сам PaymentId. Это позволяет проверить, действительно ли переданный идентификатор является «нашим»: был ли он сгенерирован одной из машин и соответствует ли ожидаемому формату.
Защита модели
Мы должны писать код так, чтобы им нельзя было воспользоваться неправильно. Для этого используются internal, приватные конструкторы и другие ограничения, но полностью решить проблему таким образом не получится. Вот несколько случаев из практики, чтобы было понятно, о чём речь.
Перепутанные методы с похожей сигнатурой. В модуле хранения создали ItemCount и передали его туда, где на самом деле ожидался ItemCounter. Проблема в том, что у них немного различаются граничные значения: в базе хранился itemCounter, а не count, поэтому там мог оказаться ноль — и всё развалилось.
Почему так вышло? При создании из BigDecimal нужно обработать возможную ошибку, а здесь были удобные методы вроде fromOrError, которые по сигнатуре сразу возвращают готовое значение, а не Either. Соблазн использовать их оказался слишком велик. В итоге получилось не очень.
Обход приватного конструктора через copy(). Возьмём тот же Email — там есть блок init (фактически конструктор), в котором выполняется проверка check(isValid(email)). Зачем это нужно, если конструктор и так internal? Дело в том, что даже спрятанный конструктор можно обойти через сгенерированный copy() у data class:
val email = Email.from("user@example.com").getOrElse { error("invalid") }
val brokenEmail = email.copy(email = "какая-нибудь невалидная ерунда")
Если бы в Email не было проверки в init, такой код успешно отработал бы: значение просто получилось бы невалидным. А поскольку проверка check(…) в init есть, при попытке сделать такой copy() мы получим IllegalStateException.
То есть даже ограниченный конструктор через средства языка можно обойти в обход проверок фабричного метода. Поэтому повторную проверку стоит закладывать именно в init.
toString() как утечка внутреннего представления. Если вызвать toString() у объекта вроде IP-адреса, который хранит внутри массив октетов, можно получить совсем не то, что ожидается — например, техническое представление массива. Поэтому не стоит полагаться на toString() там, где нужно именно строковое представление значения. Для этого лучше иметь отдельный явный метод.
В целом эти проблемы отчасти можно решать техническими средствами: более жёстким контролем линтерами и собственными проверками. Но по-хорошему лучше повышать понимание команды того, что вообще происходит в коде.
Пример с перепутанными ItemCounter и ItemCount — как раз тот случай, который никакими линтерами не предусмотреть. Можно просто взять и, не думая, вызвать одно вместо другого, хотя так делать нельзя.
Повышение грамотности команды — более правильный путь, чем обкладывать всё проверками: проверки тоже нужно поддерживать и далеко не все можно проверить. Лучше объяснять, чем бить палками.
Хранение Value Object
С хранением Value Object, например в базах данных, в целом ничего сложного нет, но стоит обратить внимание на пару моментов.
Совместимость при миграции
fun ItemCount.Companion.restore(count: BigDecimal): ItemCount {
val res = count.abs()
return ItemCount.fromOrError(res)
}
fun ItemCount.Companion.restoreWithVersion(
count: BigDecimal,
version: Int
): ItemCount {
val res = when (version) {
1 -> count.abs()
2 -> count.setScale(1)
else -> count
}
return ItemCount.fromOrError(res)
}
В Value Object можно закладывать логику, которая поддерживает совместимость с предыдущими версиями или форматами.
Например, допустим, раньше хранившееся значение count могло быть отрицательным. Неважно, почему так получилось: из-за бага или потому, что так исторически сложилась бизнес-логика. Метод restore берёт модуль числа (count.abs()) и таким образом приводит старое значение к актуальному формату.
Другой вариант — хранить версию где-то рядом. Обычно версию хранят на уровне агрегата. Это может понадобиться, например, при переходе на новое SQL-хранилище, когда нет возможности мигрировать все данные одним запросом или несколькими скриптами.
В таком случае restoreWithVersion получает версию и в зависимости от неё преобразует значение: для версии 1 приводим его к положительному через abs(), для версии 2 исправляем scale, который в какой-то момент мог стать неправильным, а для остальных версий просто возвращаем значение как есть.
Третий вариант — провести большую миграцию и целиком привести все данные к новому формату. Но это не всегда возможно и не всегда целесообразно.
Идентификаторы внутри Value Object
Другой момент — иногда у Value Object вдруг появляются собственные идентификаторы. Такое можно встретить даже в некоторых руководствах, но это уже повод задуматься: если у объекта появляется идентификатор, мы больше не можем полноценно сравнивать его по значению. Сам факт появления идентификатора внутри Value Object обычно говорит о проблеме с дизайном модели.
data class CustomerId(val id: UUID)
class Customer(
val id: CustomerId,
val created: OffsetDateTime,
val emails: Set<Email>
)
// customers(id, created)
// customer_email(customer_id, email) -- customer_id + email = PK
Например, у Customer есть набор e-mail’ов (Set<Email>). Даже если хочется сохранить нормальную форму в базе, можно сделать отдельную таблицу customer_email для этих e-mail. Поскольку они уникальны, ключом там может быть просто customer_id + email.
E-mail’ы хранятся отдельно, то никакого искусственного идентификатора самому Email заводить не нужно.
Другой пример — заказ Order, внутри которого есть OrderItem:
data class OrderId(val id: UUID)
data class ProductId(val id: UUID)
data class OrderItemId(val id: UUID)
data class OrderItem(
val id: OrderItemId,
val productId: ProductId,
val count: Count
)
class Order(
val id: OrderId,
val customer: CustomerId,
val created: OffsetDateTime,
val item: List<OrderItem>
)
// order(id, customer_id, created)
// order_item(id, order_id, count, product_id)
data class OrderContent(
val productId: ProductId,
val count: Count
)
data class OrderItemWithContent(
val id: OrderItemId,
val content: OrderContent
)
OrderItem здесь представляет собой что-то похожее на Value Object. Но у него есть OrderItemId, потому что сам OrderItem не уникален: заказ содержит список таких позиций (item: List<OrderItem>). Внутри находятся productId, count и другие данные.
В базе это можно хранить так: у Order всё довольно очевидно, а для OrderItem будет таблица с id, order_id, count и product_id:
order_item(id, order_id, count, product_id)
Если отбросить id, содержимое позиции можно интерпретировать как пару productId + count. Но при сравнении в бизнес-логике этот «лишний» идентификатор начинает выглядеть как костыль.
Здесь можно сделать небольшой рефакторинг. OrderContent (productId + count) будет уже настоящим Value Object, а OrderItemWithContent будет хранить идентификатор OrderItemId отдельно от содержимого:
data class OrderContent(
val productId: ProductId,
val count: Count
)
data class OrderItemWithContent(
val id: OrderItemId,
val content: OrderContent
)
Тогда идентификатор живёт своей отдельной жизнью, а в бизнес-логике мы можем сравнивать именно OrderContent. В базе данных при этом всё останется устроено точно так же, как и в предыдущем варианте.
В целом, если возникает ситуация, когда Value Object вдруг требуется идентификатор, скорее всего, речь на самом деле уже идёт об Entity, то есть о сущности внутри агрегата. Весьма вероятно, что со временем у неё появится собственный жизненный цикл, состояние и другие свойства сущности.
Поэтому старайтесь избегать идентификаторов у Value Object: их наличие обычно как раз и говорит о том, что перед вами уже не Value Object.
Коллекции как Value Object
Value Object может представлять не только одно значение, но и коллекцию значений — если у этой коллекции появляется собственный смысл, инварианты и бизнес-правила.
Когда мы проектируем доменную модель, часто хочется не просто передавать список чего-то, а задать ему смысл. Например, список ролей — это не просто List<Role>, а «набор ролей пользователя».
И здесь возникает вопрос: может ли такой набор сам быть Value Object и насколько это вообще оправдано? Давайте разберём по порядку, как работать с коллекциями.
Стандартная коллекция и её проблемы
Посмотрим на такой код — здесь у нас пара функций:
fun checkHasAdminRoles(roles: Set<Role>): Boolean {
if (roles.isEmpty()) {
return false
}
TODO("Is it admin?")
}
Первая функция проверяет по множеству ролей, есть ли у пользователя админские права:
enum class Role {
ADMIN,
DOCUMENT_VIEW,
DOCUMENT_EDIT,
PAYMENT_VIEW,
USER_VIEW,
USER_EDIT
}
Role — обычный enum. Есть отдельная роль администратора и несколько других ролей — возможно, даже правильнее назвать их пермишенами, но сейчас это не принципиально. Главное, что это обычное перечисление.
Внутри checkHasAdminRoles мы сначала проверяем, что список ролей не пустой. Если пустой — возвращаем false, то есть пользователь не администратор. Дальше идёт непосредственно проверка: каким-то образом нужно определить, есть ли у пользователя нужные права.
Если прикинуть возможный алгоритм, можно, например, считать пользователя администратором, если у него есть определённый пермишен или комбинация нескольких пермишенов. Допустим, если есть право на редактирование, мы считаем его администратором. Это просто гипотетический пример — сюда можно подставить любую необходимую бизнес-логику.
Есть и другой метод — он считает среднее значение ItemCount.
fun average(counts: List<ItemCount>): ItemCount {
check(counts.isNotEmpty()) {
"Count's must not be empty"
}
TODO("Calculate average")
}
И в обоих случаях мы сначала проверяем, есть ли вообще что-то внутри коллекции. Если коллекция ролей пустая, мы просто возвращаем false — здесь это не так страшно. А во втором случае, если элементов нет, мы не можем вычислить среднее и выбрасываем ошибку через check(…).
Именно эти проверки наталкивают нас на мысль, что можно сделать по-другому. Хочется, чтобы в функцию уже передавалась коллекция, которая гарантированно не является пустой.
Непустые коллекции
Как это решить? Можно воспользоваться непустыми коллекциями из библиотеки Arrow-kt:
/** * Непустые коллекции * arrow-kt.io/learn/collections-functions/non-empty */ fun checkHasAdminRoles(roles: NonEmptySet<Role>): Boolean { TODO("Is it admin?") } fun average(counts: NonEmptyList<ItemCount>): ItemCount { TODO("Calculate average") }
Особенность в том, что эти коллекции гарантируют, что они не пустые — это видно из названия: NonEmptySet, NonEmptyList. Поэтому проверки, которые были внутри функций, исчезают: мы уже знаем, что коллекции не пустые, и можем сразу приступать к вычислениям и проверкам.
То есть валидация теперь происходит там, где создаётся NonEmptySet или NonEmptyList, а не внутри каждой функции, которая их принимает.
Когда коллекция становится Value Object
Но можем пойти ещё немного дальше и усовершенствовать даже такой вариант. Посмотрим на следующий класс:
data class Roles(private val roles: NonEmptySet<Role>) {
fun isAdmin() =
roles.contains(Role.ADMIN) ||
(roles.contains(Role.USER_EDIT)
&& roles.contains(Role.DOCUMENT_EDIT))
fun canViewDocuments() =
roles.contains(Role.DOCUMENT_VIEW) ||
roles.contains(Role.DOCUMENT_EDIT)
fun addRole(role: Role): Roles {
val newRoles = roles + role
return Roles(newRoles)
}
fun removeRole(role: Role) =
either {
ensure(roles.contains(role)) {
RolesRemoveError.NotExists
}
val mutableRoles = mutableSetOf<Role>()
mutableRoles.remove(role)
val newRoles = mutableRoles.toNonEmptySetOrNull()
ensure(newRoles != null) {
RolesRemoveError.RolesIsEmpty
}
Roles(newRoles)
}
fun asSet(): Set<Role> = roles.toHashSet()
// не забудь про сравнение!
}
sealed interface RolesRemoveError {
object NotExists : RolesRemoveError
object RolesIsEmpty : RolesRemoveError
}
Это уже фактически Value Object, который внутри себя содержит коллекцию ролей. Сама по себе коллекция Value Object не является — особенно изменяемая. Если помните, у Value Object есть определённые свойства: он должен быть неизменяемым, сравниваться по значению и так далее. Поэтому просто коллекцию назвать Value Object нельзя. Но мы можем обернуть её в собственный объект — и тогда у нас появится Roles.
Здесь уже спрятана бизнес-логика. Есть метод isAdmin(), и если посмотреть внимательно, в нём как раз реализованы условия из нашего примера: пользователь считается администратором, если у него есть роль ADMIN либо определённая комбинация пермишенов — USER_EDIT и DOCUMENT_EDIT.
Есть и другой удобный метод — canViewDocuments(), который проверяет, может ли пользователь просматривать документы. Если у него есть либо DOCUMENT_VIEW, либо DOCUMENT_EDIT, значит, смотреть документы ему можно. Здесь мы сразу прячем бизнес-правило: если пользователь может редактировать документы, то, скорее всего, он может и просматривать их.
Всё это спрятано в бизнес-методе canViewDocuments(). Нам больше не нужно напрямую сравнивать enum’ы — мы можем просто спросить у Value Object, который содержит внутри коллекцию, разрешено ли пользователю выполнять определённое действие.
Теперь про изменения. Мы уже говорили, что Value Object должен быть неизменяемым, и здесь действует тот же принцип. При добавлении роли через addRole создаётся новый Value Object. Переопределённый оператор «+» у NonEmptySet создаёт новую коллекцию, после чего мы возвращаем новый Roles.
То же самое происходит при удалении роли через removeRole, но здесь ситуация немного сложнее. Во-первых, роли, которую мы пытаемся удалить, может не существовать. Во-вторых, поскольку у нас NonEmptySet, удаляемая роль может быть последней — то есть в коллекции останется ничего.
Поэтому здесь возвращается Either с тремя возможными результатами: RolesRemoveError.NotExists, если такой роли нет; RolesRemoveError.RolesIsEmpty, если после удаления коллекция окажется пустой; либо новый Roles, если всё прошло успешно.
Здесь используется DSL библиотеки Arrow — either { … } и ensure { … }. Мы просто задаём условия, а если какое-то из них не выполняется, возвращаем соответствующую ошибку. Это немного удобнее, чем постоянно писать left() и right(). При этом такой код может быть не сразу понятен новичкам, поэтому здесь уже стоит заглянуть в документацию Arrow — там есть много интересных возможностей.
Таким образом, при любом изменении мы не меняем существующую коллекцию, а создаём новую. Сам NonEmptySet остаётся неизменяемым, а вместе с ним неизменяемым остаётся и наш Value Object Roles.
Ну и есть метод asSet(). Здесь мы фактически делаем копию коллекции, чтобы не отдавать наружу внутреннее представление напрямую. В данном случае это не так критично, потому что NonEmptySet, насколько я помню, сам по себе неизменяемый. Но если внутри используется ArrayList или другая изменяемая коллекция, копию делать обязательно. Иначе вы отдадите наружу ссылку на внутреннее состояние, и его можно будет изменить в обход всех правил Value Object.
И ещё один важный момент — сравнение: equals и hashCode. Здесь тоже нужно внимательно посмотреть, как именно сравниваются объекты. Два Value Object считаются равными, если у них одинаковое содержимое. Для Roles это означает, что нужно сравнивать именно набор ролей, а не внутренний объект коллекции или какие-то другие технические детали. В общем, с реализацией сравнения тоже нужно быть внимательным.
Посмотрим ещё на один Value Object, который содержит внутри коллекцию, — ItemCounts:
data class ItemCounts(private val counts: NonEmptyList<ItemCount>) {
fun average(): ItemCount {
if (counts.size == 1) {
return counts.first()
} else {
val sumRes = counts.reduce { acc, ic ->
acc + ic
}
val count = Count.from(counts.size)
.getOrElse { error("Cannot create Count from ${counts.size}") }
return sumRes.div(count)
}
}
}
Вы уже видели метод average — здесь показана его реализация. Сначала складываются все значения через переопределённый оператор +:
counts.reduce { acc, ic -> acc + ic }
Затем сумма делится на Count, который мы получаем из размера коллекции.
Теоретически этот Count может оказаться равен нулю, что некорректно для деления. В данном случае это невозможно благодаря NonEmptyList, но сам тип Count этого не гарантирует. По-хорошему здесь можно было бы завести отдельный Value Object, например PositiveCount, который всегда содержит положительное значение. В разобранный код такой вариант уже не включали, но идея та же, что и с ItemCount и ItemCounter.
У ItemCount также есть оператор div, который позволяет разделить ItemCount на Count. И здесь снова появляется бизнес-логика: если результат оказался меньше минимального значения, мы возвращаем именно минимальное значение. С математической точки зрения это не совсем корректно, но мы про это уже говорили, Value Object отражает не математику как таковую, а правила предметной области. После этого выставляется нужный scale и формируется итоговый объект.
Вот такие полезные Value Object у нас получились. Но, конечно, у них есть и недостатки.
Во-первых, коллекции могут быть большими, а значит, для работы с их элементами нам приходится держать всю коллекцию в памяти. Во-вторых, получившиеся классы неизменяемые, поэтому при изменении мы вынуждены создавать копию. Для больших коллекций это тоже может быть заметной проблемой с точки зрения производительности.
Функции-расширения как альтернатива
Ещё один способ обойти неудобства — функции-расширения. Это актуально для Kotlin, хотя, возможно, в других языках есть похожие механизмы.
Суть в том, что мы можем добавить метод непосредственно к параметризованной коллекции:
fun Set<Role>.checkHasAdminRoles(): Boolean {
TODO("Is it admin?")
}
fun List<ItemCount>.average(): ItemCount {
check(isNotEmpty())
TODO("Calculate average")
}
fun NonEmptyList<ItemCount>.average(): ItemCount {
TODO("Calculate average")
}
Теперь для коллекции ролей мы можем просто вызвать checkHasAdminRoles() — неважно, откуда эта коллекция появилась. Главное, что у неё тип Set<Role>.
То же самое с ItemCount: для обычного List мы можем определить average(), но внутри придётся проверить, что список не пустой:
check(isNotEmpty())
Иначе вычислить среднее мы не сможем.
Другой вариант — сразу определить average() для NonEmptyList. Тогда проверка вообще не нужна: сам тип коллекции гарантирует, что она не пустая, и метод может сразу выполнять вычисления. Но это, конечно, уже не Value Object
Итого
Value Object — это не просто обёртка над String, Int или BigDecimal. Его основная задача — защитить бизнес-инварианты и сделать модель понятнее. Вместо набора безликих примитивов мы получаем ItemCount, ItemWeight, Email, BirthDate и другие типы, которые сами знают свои ограничения и допустимые операции.
Весь код из статьи — в репозитории: github.com/stringconcat/video_public/tree/main/value_object