Привет, Хабр!
Сегодня рассмотрим как обезопасить бизнес-логику от случайного (или злонамеренного) изменения 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.
