Search
Write a publication
Pull to refresh
523.24
OTUS
Развиваем технологии, обучая их создателей

Как защитить бизнес-логику от мутаций в DTO: коротко

Reading time5 min
Views2.4K

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

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

Tags:
Hubs:
Total votes 7: ↑3 and ↓40
Comments5

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS