Привет, Хабр!
Сегодня рассмотрим как обезопасить бизнес-логику от случайного (или злонамеренного) изменения 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.
ссылка на оригинал статьи https://habr.com/ru/articles/914418/
Добавить комментарий