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