Привет, Хабр!

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

Способ

Как работает

Код

record

Создает тип со init-только свойствами

public record UserDto(string Id, string Role) { public string Email { get; init; } }

with-copy

Новый объект через сравнительно дешёвый копиконструктор

var safe = dto with { Role = "Guest" };

readonly struct

Значимый тип, не позволяющий менять поля

public readonly struct Coordinates(double Lat, double Lng);

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#

record, init, required, with

source generators, AutoMapper

Java

record (JEP 395)

Lombok @Value, MapStruct

Python

@dataclass(frozen), pydantic.ConfigDict(frozen)

attrs, typing-immutability plugin

Go

Передавать по значению, а не по указателю

линтеры staticcheck, go vet

Итоги

Immutable-подход — не панацея, но это дешевейшая страховка от пробелмы, которая рано или поздно возникает в микро- или макромонолитах. Чем раньше вы зацементируете DTO, тем меньше проблем будете решать потом.


Если вы отвечаете за развитие технической команды, то знаете, насколько важно вовремя закрывать дефицит навыков — без отрыва от работы и с фокусом на реальные задачи. В OTUS есть корпоративные программы именно под такие запросы: backend и frontend разработка, DevOps, аналитика, управление продуктами и процессами. Все курсы — практико-ориентированные, с возможностью адаптации под стек и цели вашей команды. Форматы — гибкие, чтобы обучение не мешало delivery. Подробнее — на сайте OTUS.

Комментарии (4)


  1. pnmv
    04.06.2025 16:43

    вот, раз уж, уже в заголовке, вы использовали аббревиатуру DTO, разместите, пожалуйста, определение этого дела, которым пользуетесь.

    я имею в виду - над кнопкой "читать дальше".


  1. olku
    04.06.2025 16:43

    В статьях про DDD раз из раза повторяется мантра что ValueObject не может иметь уникального идентификатора. Это не так. Если объект имеет естественный идентификатор, например, номер паспорта, адрес целиком, налоговый номер, гео тег итд, то он вполне может быть переиспользован и в инфраструктуре. Если инфраструктура по каким то причинам не может использовать его, она вводит свой искусственный вроде uuid, id, hash и пр. Инфраструктурный идентификатор живёт своей жизнью и не протекает в домен.


    1. pnmv
      04.06.2025 16:43

      они не читают комментарии. я совсем забыл об этом.


      1. MaxRokatansky
        04.06.2025 16:43

        Не сразу увидели. Спасибо за комментарий, добавили опрелеление