Привет, Хабр!
Сегодня рассмотрим как обезопасить бизнес-логику от случайного (или злонамеренного) изменения DTO, чем опасна мутабельность моделей и какие инструменты дают C#, Java, Python и Go, чтобы вы больше никогда не ловили эти баги.
Классический затык: «невинный» UserDto
// Контроллер, отдаём наружу «чистый» UserDto
public record UserDto(string Id, string Role, string Email);
// Сервис авторизации внутри монолита
public sealed class AuthService
{
public bool CanEdit(UserDto user) => user.Role == "Admin";
}
// где-то глубже…
var dto = mapper.Map<UserDto>(entity); // entity.Role == "User"
DoBusiness(dto); // роль меняется по пути
if (authService.CanEdit(dto))
{
// неожиданно попадаем сюда
}
В одном слое DTO докрутили количество бонусных баллов и… нечаянно заменили Role
. Ничего криминального, кроме того, что контракт authService ожидает неизменяемый объект. Получаем фейл авторизации и дырку в безопасности.
Стратегия защиты
Защитные копии
Самый примитивный (и дорогостоящий) способ — копировать DTO при каждом входе/выходе слоя.
UserDto safeCopy = incomingUserDto.clone(); // Допускается только чтение
Минус: мусор в heap, забытые места, где копия не сделана.
Mapping-слой
Используем AutoMapper/MapStruct/StructMapper, чтобы всегда создавать новые экземпляры.
В .NET AutoMapper по умолчанию создаёт новый объект; добавляем PreserveReferences()
только там, где действительно нужны циклы. В Java MapStruct генерирует код копирования на compile-time — лишний GC шум минимален.
Value Object-ы
Сущность = данные + инварианты, но без идентичности.
public readonly record struct Money(decimal Amount, string Currency);
У Value Object нет сеттеров, и его легче валидировать на входе.
Языковые инструменты анти-мутабельности
C# 12/13
Способ |
Как работает |
Код |
---|---|---|
|
Создает тип со |
|
|
Новый объект через сравнительно дешёвый копиконструктор |
|
|
Значимый тип, не позволяющий менять поля |
|
positional record
по умолчанию immutable. А начиная с C# 12 к ним добавились required
-члены и source-генератор init
/required
, позволяющий фиксировать состояние.
Java 21: record как контракт на неизменяемость
Java сравнительно поздно подошла к теме иммутабельных структур, но сделала это основательно. Ключевая конструкция — record
. Когда вы пишете public record UserDto(String id, String role, String email) {}
, компилятор генерирует private final
поля, конструктор, equals
, hashCode
и toString
.
Полезно в API-слоях, где важно, чтобы DTO, переданное наружу, оставалось нетронутым. Обновление таких объектов происходит только через создание новой версии: new UserDto(user.id(), "Admin", user.email())
.
record
— это финальный класс. Его нельзя наследовать. Также, чтобы внедрить логику валидации, нужно использовать компактный конструктор:
public record Email(String value) {
public Email {
if (!value.contains("@")) throw new IllegalArgumentException("Invalid email");
}
}
До версии 2.13 Jackson не поддерживал record
-ы, но начиная с 2.13 это работает корректно. На момент 2025 года предпочтительно использовать как минимум 2.17.
В Java рекомендует использовать record
, когда объект не несёт поведения, а лишь передаёт данные.
Тем не менее, сами по себе record
в качестве JPA-сущностей исподьзовать не стоит: Hibernate требует пустой конструктор и публичные сеттеры, чего у record
-ов нет.
Python 3.13 + Pydantic v2: валидируем и замораживаем
В Pydantic v2 ключ к иммутабельности — параметр frozen=True
в конфигурации модели. Пример:
from pydantic import BaseModel, ConfigDict
class UserDto(BaseModel):
id: str
role: str
email: str
model_config = ConfigDict(frozen=True)
Этот флаг делает все поля модели неизменяемыми: попытка изменения dto.role = 'admin'
вызовет исключение. Модель становится hashable и может быть использована в set
или в качестве ключа словаря.
С выходом Pydantic v2, построенного на Rust, производительность таких моделей выросла. В отличие от v1, где frozen
работал непоследовательно, теперь это надёжная и быстрая конструкция.
Если использовать чистый Python, альтернатива — @dataclass(frozen=True)
. Пример:
from dataclasses import dataclass
@dataclass(frozen=True)
class UserDto:
id: str
role: str
email: str
Имеем ту же иммутабельность, но без встроенной валидации. Это просто структурный контракт. Чтобы добавить проверки, нужны отдельные функции.
Для статического анализа можно использовать mypy
с включённым плагином pydantic
. Он поможет отлавливать попытки мутаций ещё на этапе разработки. В версиях mypy >= 1.10
появились базовые возможности отслеживания неизменности и для dataclass'ов, и для pydantic-моделей.
Вложенные модели также должны быть frozen
, иначе вложенное состояние можно будет изменять. Об этом, к слову, часто забывают.
Go 1.22: значение по умолчанию — копия
В Go модель памяти устроена так, что передача структуры без указателя приводит к копированию. Это дает иммутабельность по дефолту. Рассмотрим структуру:
type UserDTO struct {
ID, Role, Email string
}
func Promote(user UserDTO) {
user.Role = "Admin"
}
В данном примере user
это копия. Изменения внутри Promote
не затрагивают оригинальный объект.
Проблемы начинаются, когда передаём указатель:
func PromotePtr(user *UserDTO) {
user.Role = "Admin"
}
В этом случае изменяем оригинальный объект. Поэтому в чистом сервис-слое рекомендуется использовать структуры по значению. Передача по указателю должна использоваться только там, где это оправдано: тяжёлые структуры, I/O операции, кэширование, необходимость синхронизации через sync.Mutex
.
Для защиты от мутаций можно делать поля приватными и предоставлять только геттеры:
type UserDTO struct {
id string
email string
role string
}
func (u UserDTO) ID() string { return u.id }
func (u UserDTO) Role() string { return u.role }
Своего рода ручная иммутабельность. В бизнес-логике работа идёт только с геттер-методами, а изменить поля можно только через явно описанный билдер или фабрику.
В целом, Go поощряет явность: если вы передаёте указатель — значит, сознательно допускаете мутацию.
Мини-резюме
Язык |
Основная фича |
Доп. инструменты |
---|---|---|
C# |
|
source generators, AutoMapper |
Java |
|
Lombok |
Python |
|
attrs, typing-immutability plugin |
Go |
Передавать по значению, а не по указателю |
линтеры |
Итоги
Immutable-подход — не панацея, но это дешевейшая страховка от пробелмы, которая рано или поздно возникает в микро- или макромонолитах. Чем раньше вы зацементируете DTO, тем меньше проблем будете решать потом.
Если вы отвечаете за развитие технической команды, то знаете, насколько важно вовремя закрывать дефицит навыков — без отрыва от работы и с фокусом на реальные задачи. В OTUS есть корпоративные программы именно под такие запросы: backend и frontend разработка, DevOps, аналитика, управление продуктами и процессами. Все курсы — практико-ориентированные, с возможностью адаптации под стек и цели вашей команды. Форматы — гибкие, чтобы обучение не мешало delivery. Подробнее — на сайте OTUS.
Комментарии (4)
olku
04.06.2025 16:43В статьях про DDD раз из раза повторяется мантра что ValueObject не может иметь уникального идентификатора. Это не так. Если объект имеет естественный идентификатор, например, номер паспорта, адрес целиком, налоговый номер, гео тег итд, то он вполне может быть переиспользован и в инфраструктуре. Если инфраструктура по каким то причинам не может использовать его, она вводит свой искусственный вроде uuid, id, hash и пр. Инфраструктурный идентификатор живёт своей жизнью и не протекает в домен.
pnmv
вот, раз уж, уже в заголовке, вы использовали аббревиатуру DTO, разместите, пожалуйста, определение этого дела, которым пользуетесь.
я имею в виду - над кнопкой "читать дальше".